mitai-jinkendo/frontend/src/components/FoodRecipeEditor.jsx
Lars b37005be01
All checks were successful
Deploy Development / deploy (push) Successful in 59s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 22s
feat: Listen-Tab und sichtbare Zutatenlisten-Verwaltung
Listen anlegen, bearbeiten und importieren liegt unter Ernährung.
Tagebuchzeilen werden auch mit Mengen-Prefix verknüpft.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-13 12:40:53 +02:00

193 lines
7.0 KiB
JavaScript

import { useEffect, useRef, useState } from 'react'
import { api } from '../utils/api'
import FoodRecipeDialog from './FoodRecipeDialog'
export default function FoodRecipeEditor({ learned: learnedProp, highlightId, onChanged }) {
const [recipes, setRecipes] = useState([])
const [learned, setLearned] = useState(learnedProp || [])
const [q, setQ] = useState('')
const [visible, setVisible] = useState(40)
const [editing, setEditing] = useState(null)
const [error, setError] = useState(null)
const [notice, setNotice] = useState(null)
const [importing, setImporting] = useState(false)
const [loading, setLoading] = useState(true)
const listRef = useRef(null)
const highlightRef = useRef(null)
const openedHighlight = useRef(null)
const load = async () => {
try {
const [r, m] = await Promise.all([
api.listNutritionRecipes(true),
learnedProp ? Promise.resolve(learnedProp) : api.listMyFoodMappings().catch(() => []),
])
setRecipes(Array.isArray(r) ? r : [])
setLearned(Array.isArray(m) ? m : (learnedProp || []))
setError(null)
} catch (e) {
setError(e.message)
} finally {
setLoading(false)
}
}
useEffect(() => { load() }, [])
useEffect(() => { if (learnedProp) setLearned(learnedProp) }, [learnedProp])
const startNew = () => {
setEditing('new')
setError(null)
}
const startEdit = async (r) => {
setError(null)
try {
const full = (r.ingredients && r.ingredients.length)
? r
: await api.getNutritionRecipe(r.id)
setEditing(full)
} catch (e) {
setError(e.message)
}
}
useEffect(() => {
if (!highlightId || !recipes.length) return
highlightRef.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
if (openedHighlight.current === highlightId) return
const r = recipes.find((x) => x.id === highlightId)
if (!r) return
openedHighlight.current = highlightId
startEdit(r)
}, [highlightId, recipes])
const filtered = recipes.filter((r) => {
const term = q.trim().toLowerCase()
if (!term) return true
return `${r.name_raw || ''} ${r.name_normalized || ''}`.toLowerCase().includes(term)
})
const shown = filtered.slice(0, visible)
const save = async (body) => {
if (editing === 'new') await api.createNutritionRecipe(body)
else await api.updateNutritionRecipe(editing.id, body)
setEditing(null)
await load()
onChanged?.()
}
const remove = async (id) => {
if (!confirm('Liste wirklich löschen? Tagebuchzeilen fallen dann wieder unter Zuordnen.')) return
try {
await api.deleteNutritionRecipe(id)
if (editing && editing !== 'new' && editing.id === id) setEditing(null)
await load()
onChanged?.()
} catch (e) {
setError(e.message)
}
}
const importLists = async (file) => {
if (!file) return
setImporting(true)
setError(null)
try {
const res = await api.importFddbLists(file)
await load()
onChanged?.()
const pending = res.rebuild_pending ? ' Tageswerte werden im Hintergrund nachgerechnet.' : ''
setNotice(`${res.recipes} Listen importiert, ${res.items_linked || 0} Tagebuchzeilen verknüpft.${pending}`)
} catch (e) {
setError(e.message)
} finally {
setImporting(false)
}
}
return (
<div className="card section-gap">
<div className="card-title">Zutatenlisten</div>
<p style={{ fontSize: 13, color: 'var(--text2)', lineHeight: 1.6, marginBottom: 12 }}>
Roh-Kombinationen (Müsli, Bowl, Smoothie) nicht gekochte Gerichte.
Import aus FDDB oder selbst anlegen. Zutaten danach unter Zuordnen mappen.
</p>
{error && <div style={{ color: 'var(--danger)', fontSize: 13, marginBottom: 10 }}>{error}</div>}
{notice && <div style={{ fontSize: 13, color: 'var(--accent-dark)', marginBottom: 10 }}>{notice}</div>}
<input ref={listRef} type="file" accept=".csv,text/csv" style={{ display: 'none' }} onChange={(e) => { const f = e.target.files?.[0]; e.target.value = ''; if (f) importLists(f) }} />
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 12 }}>
<button type="button" className="btn btn-primary" onClick={startNew}>Liste anlegen</button>
<button type="button" className="btn btn-secondary" disabled={importing} onClick={() => listRef.current?.click()}>
{importing ? 'Importiere…' : 'FDDB-Listen importieren'}
</button>
</div>
<p style={{ fontSize: 13, margin: '0 0 8px' }}>{loading ? 'Lade Listen…' : `${recipes.length} Listen`}</p>
{recipes.length > 8 && (
<input
className="form-input"
style={{ width: '100%', textAlign: 'left', marginBottom: 8 }}
placeholder="Liste suchen…"
value={q}
onChange={(e) => { setQ(e.target.value); setVisible(40) }}
/>
)}
{!loading && recipes.length === 0 && (
<p className="muted">Noch keine Listen. FDDB-Export (`lists_*.csv`) importieren oder eine neue anlegen.</p>
)}
{shown.map((r) => (
<div
key={r.id}
ref={r.id === highlightId ? highlightRef : undefined}
style={{
borderTop: '1px solid var(--border)',
padding: '10px 0',
background: r.id === highlightId ? 'var(--surface2)' : undefined,
borderRadius: r.id === highlightId ? 8 : 0,
}}
>
<div style={{ fontWeight: 600 }}>{r.name_raw}</div>
<div style={{ fontSize: 12, color: 'var(--text3)' }}>
{r.ingredient_count ?? (r.ingredients || []).length} Zutaten · {r.portions || 1} Portionen
{r.source === 'fddb_list' ? ' · FDDB' : r.source === 'manual' ? ' · selbst angelegt' : ''}
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 6 }}>
<button type="button" className="btn btn-secondary" style={{ fontSize: 12 }} onClick={() => startEdit(r)}>Bearbeiten</button>
<button type="button" className="btn btn-secondary" style={{ fontSize: 12 }} onClick={() => remove(r.id)}>Löschen</button>
</div>
</div>
))}
{visible < filtered.length && (
<button type="button" className="btn btn-secondary btn-full" style={{ marginTop: 8 }} onClick={() => setVisible((n) => n + 40)}>
Weitere {Math.min(40, filtered.length - visible)} zeigen
</button>
)}
{editing === 'new' && (
<FoodRecipeDialog
title="Liste anlegen"
learned={learned}
submitLabel="Liste anlegen"
onSave={save}
onClose={() => setEditing(null)}
/>
)}
{editing && editing !== 'new' && (
<FoodRecipeDialog
key={editing.id}
title="Liste bearbeiten"
defaultName={editing.name_raw}
defaultPortions={editing.portions}
defaultIngredients={editing.ingredients}
learned={learned}
submitLabel="Liste speichern"
onSave={save}
onClose={() => setEditing(null)}
/>
)}
</div>
)
}