184 lines
7.7 KiB
React
184 lines
7.7 KiB
React
|
|
import { useState } from 'react'
|
||
|
|
import { api } from '../utils/api'
|
||
|
|
|
||
|
|
function emptyIng() {
|
||
|
|
return { source_name_raw: '', quantity_g: '' }
|
||
|
|
}
|
||
|
|
|
||
|
|
function mappingHint(name, learned) {
|
||
|
|
const n = (name || '').trim().toLowerCase()
|
||
|
|
if (!n) return null
|
||
|
|
return (learned || []).find((m) => {
|
||
|
|
const raw = (m.source_name_raw || '').toLowerCase()
|
||
|
|
const norm = (m.source_name_normalized || '').toLowerCase()
|
||
|
|
return raw === n || norm === n || raw.includes(n) || n.includes(norm)
|
||
|
|
}) || null
|
||
|
|
}
|
||
|
|
|
||
|
|
export function RecipeForm({ defaultName, defaultPortions, defaultIngredients, learned, disabled, submitLabel, onSave, onCancel }) {
|
||
|
|
const [name, setName] = useState(defaultName || '')
|
||
|
|
const [portions, setPortions] = useState(defaultPortions != null ? String(defaultPortions) : '1')
|
||
|
|
const [ings, setIngs] = useState(
|
||
|
|
(defaultIngredients || []).length
|
||
|
|
? defaultIngredients.map((i) => ({ source_name_raw: i.source_name_raw || '', quantity_g: i.quantity_g ?? '' }))
|
||
|
|
: [emptyIng()]
|
||
|
|
)
|
||
|
|
const [error, setError] = useState(null)
|
||
|
|
const [saving, setSaving] = useState(false)
|
||
|
|
|
||
|
|
const save = async () => {
|
||
|
|
const ingredients = ings
|
||
|
|
.map((i) => ({
|
||
|
|
source_name_raw: (i.source_name_raw || '').trim(),
|
||
|
|
quantity_g: i.quantity_g === '' ? null : parseFloat(String(i.quantity_g).replace(',', '.')),
|
||
|
|
}))
|
||
|
|
.filter((i) => i.source_name_raw)
|
||
|
|
if (!name.trim()) {
|
||
|
|
setError('Rezeptname fehlt')
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if (!ingredients.length) {
|
||
|
|
setError('Mindestens eine Zutat angeben')
|
||
|
|
return
|
||
|
|
}
|
||
|
|
setSaving(true)
|
||
|
|
setError(null)
|
||
|
|
try {
|
||
|
|
await onSave({
|
||
|
|
name_raw: name.trim(),
|
||
|
|
portions: parseFloat(String(portions).replace(',', '.')) || 1,
|
||
|
|
ingredients,
|
||
|
|
})
|
||
|
|
} catch (e) {
|
||
|
|
setError(e.message)
|
||
|
|
} finally {
|
||
|
|
setSaving(false)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div style={{ marginTop: 8, padding: 10, background: 'var(--surface2)', borderRadius: 8 }}>
|
||
|
|
<p style={{ fontSize: 12, margin: '0 0 8px', color: 'var(--text2)' }}>
|
||
|
|
Zutaten, die schon zugeordnet sind, werden übernommen. Offene erscheinen danach in der Liste zum Zuordnen.
|
||
|
|
</p>
|
||
|
|
{error && <div style={{ color: 'var(--danger)', fontSize: 13, marginBottom: 8 }}>{error}</div>}
|
||
|
|
<input className="form-input" style={{ width: '100%', textAlign: 'left', marginBottom: 8 }} placeholder="Rezeptname" value={name} onChange={(e) => setName(e.target.value)} />
|
||
|
|
<label style={{ fontSize: 12, color: 'var(--text2)' }}>
|
||
|
|
Portionen
|
||
|
|
<input className="form-input" type="number" min="0.1" step="0.1" style={{ width: 80, textAlign: 'left', marginLeft: 8 }} value={portions} onChange={(e) => setPortions(e.target.value)} />
|
||
|
|
</label>
|
||
|
|
<p style={{ fontSize: 12, margin: '10px 0 6px', color: 'var(--text2)' }}>Zutaten (Name + Gramm)</p>
|
||
|
|
{ings.map((ing, i) => {
|
||
|
|
const mapped = mappingHint(ing.source_name_raw, learned)
|
||
|
|
return (
|
||
|
|
<div key={i} style={{ marginBottom: 8 }}>
|
||
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 80px', gap: 6 }}>
|
||
|
|
<input className="form-input" style={{ textAlign: 'left' }} placeholder="z. B. Haferflocken" value={ing.source_name_raw} onChange={(e) => setIngs((list) => list.map((x, j) => j === i ? { ...x, source_name_raw: e.target.value } : x))} />
|
||
|
|
<input className="form-input" type="number" min="0" step="0.1" style={{ textAlign: 'left' }} placeholder="g" value={ing.quantity_g} onChange={(e) => setIngs((list) => list.map((x, j) => j === i ? { ...x, quantity_g: e.target.value } : x))} />
|
||
|
|
</div>
|
||
|
|
{ing.source_name_raw.trim() && (
|
||
|
|
<div style={{ fontSize: 11, color: mapped ? 'var(--accent-dark)' : 'var(--text3)', marginTop: 2 }}>
|
||
|
|
{mapped ? `zugeordnet: ${mapped.food_name_de || mapped.source_name_raw}` : 'noch offen — nach dem Speichern in der Liste zuordnen'}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
})}
|
||
|
|
<button type="button" className="btn btn-secondary" style={{ fontSize: 12 }} onClick={() => setIngs((list) => [...list, emptyIng()])}>Zutat hinzufügen</button>
|
||
|
|
<div style={{ display: 'flex', gap: 8, marginTop: 10 }}>
|
||
|
|
{onCancel && (
|
||
|
|
<button type="button" className="btn btn-secondary" disabled={saving} onClick={onCancel}>Zurück</button>
|
||
|
|
)}
|
||
|
|
<button type="button" className="btn btn-primary" style={{ flex: 1 }} disabled={disabled || saving} onClick={save}>
|
||
|
|
{saving ? 'Speichere…' : (submitLabel || 'Rezept speichern')}
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
export default function FoodRecipeEditor({ recipes, learned, onChanged }) {
|
||
|
|
const [open, setOpen] = useState(false)
|
||
|
|
const [editing, setEditing] = useState(null)
|
||
|
|
const [draft, setDraft] = useState(null)
|
||
|
|
const [error, setError] = useState(null)
|
||
|
|
|
||
|
|
const startNew = () => {
|
||
|
|
setEditing('new')
|
||
|
|
setDraft({ name: '', portions: '1' })
|
||
|
|
setError(null)
|
||
|
|
setOpen(true)
|
||
|
|
}
|
||
|
|
|
||
|
|
const startEdit = (r) => {
|
||
|
|
setEditing(r)
|
||
|
|
setDraft(r)
|
||
|
|
setError(null)
|
||
|
|
setOpen(true)
|
||
|
|
}
|
||
|
|
|
||
|
|
const save = async (body) => {
|
||
|
|
if (editing === 'new') await api.createNutritionRecipe(body)
|
||
|
|
else await api.updateNutritionRecipe(editing.id, body)
|
||
|
|
setEditing(null)
|
||
|
|
setDraft(null)
|
||
|
|
onChanged?.()
|
||
|
|
}
|
||
|
|
|
||
|
|
const remove = async (id) => {
|
||
|
|
if (!confirm('Rezept wirklich löschen?')) return
|
||
|
|
try {
|
||
|
|
await api.deleteNutritionRecipe(id)
|
||
|
|
if (editing && editing !== 'new' && editing.id === id) setEditing(null)
|
||
|
|
onChanged?.()
|
||
|
|
} catch (e) {
|
||
|
|
setError(e.message)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div style={{ marginTop: 16 }}>
|
||
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8 }}>
|
||
|
|
<h3 style={{ fontSize: 14, margin: 0 }}>Eigene Rezepte / Listen ({recipes.length})</h3>
|
||
|
|
<button type="button" className="btn btn-secondary" style={{ fontSize: 12 }} onClick={() => { setOpen((v) => !v); if (open) setEditing(null) }}>
|
||
|
|
{open ? 'Einklappen' : 'Anzeigen'}
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
{open && (
|
||
|
|
<div style={{ marginTop: 8 }}>
|
||
|
|
{error && <div style={{ color: 'var(--danger)', fontSize: 13, marginBottom: 8 }}>{error}</div>}
|
||
|
|
<button type="button" className="btn btn-secondary btn-full" onClick={startNew}>Rezept anlegen</button>
|
||
|
|
{recipes.map((r) => (
|
||
|
|
<div key={r.id} style={{ borderTop: '1px solid var(--border)', padding: '8px 0', marginTop: 8 }}>
|
||
|
|
<div style={{ fontWeight: 600 }}>{r.name_raw}</div>
|
||
|
|
<div style={{ fontSize: 12, color: 'var(--text3)' }}>
|
||
|
|
{(r.ingredients || []).length} Zutaten · {r.portions || 1} Portionen
|
||
|
|
{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>
|
||
|
|
))}
|
||
|
|
{editing === 'new' && (
|
||
|
|
<RecipeForm learned={learned} submitLabel="Rezept anlegen" onSave={save} onCancel={() => setEditing(null)} />
|
||
|
|
)}
|
||
|
|
{editing && editing !== 'new' && (
|
||
|
|
<RecipeForm
|
||
|
|
key={editing.id}
|
||
|
|
defaultName={draft?.name_raw}
|
||
|
|
defaultPortions={draft?.portions}
|
||
|
|
defaultIngredients={draft?.ingredients}
|
||
|
|
learned={learned}
|
||
|
|
submitLabel="Rezept speichern"
|
||
|
|
onSave={save}
|
||
|
|
onCancel={() => setEditing(null)}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|