Listen anlegen, bearbeiten und importieren liegt unter Ernährung. Tagebuchzeilen werden auch mit Mengen-Prefix verknüpft. Co-authored-by: Cursor <cursoragent@cursor.com>
193 lines
7.0 KiB
JavaScript
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>
|
|
)
|
|
}
|