mitai-jinkendo/frontend/src/components/NutritionFoodMap.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

586 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useMemo, useRef, useState } from 'react'
import { api } from '../utils/api'
import FoodSearchModal from './FoodSearchModal'
import FoodNutrientFields, { foodCreatePayload } from './FoodNutrientFields'
import FoodRecipeDialog from './FoodRecipeDialog'
const PERIODS = [
{ days: 14, label: '14 Tage' },
{ days: 28, label: '4 Wochen' },
{ days: 90, label: '90 Tage' },
{ days: 0, label: 'Alle' },
]
function formatDay(iso) {
const s = String(iso || '').slice(0, 10)
if (s.length < 10) return ''
const [y, m, d] = s.split('-')
return `${d}.${m}.${String(y).slice(2)}`
}
function suggestQuery(raw) {
let s = (raw || '').replace(/^\s*[!]?\s*\d+(?:[.,]\d+)?\s*(?:g|kg|ml|l|stück|stk)?\s*/i, '').trim()
s = s.replace(/^!+/, '').trim()
const comma = s.indexOf(',')
if (comma > 2) s = s.slice(0, comma).trim()
return s
}
function RecipePickModal({ recipes, sourceName, onPick, onClose }) {
const [q, setQ] = useState('')
const hint = suggestQuery(sourceName)
const filtered = recipes.filter((r) => {
const hay = `${r.name_raw || ''} ${r.name_normalized || ''}`.toLowerCase()
return !q.trim() || hay.includes(q.trim().toLowerCase())
})
return (
<div
style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)',
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 20000, padding: 16,
}}
onClick={onClose}
>
<div
role="dialog"
aria-modal="true"
onClick={(e) => e.stopPropagation()}
style={{
width: '100%', maxWidth: 480, maxHeight: 'min(88vh, 560px)',
background: 'var(--surface)', borderRadius: 16,
boxShadow: '0 8px 32px rgba(0,0,0,0.18)',
display: 'flex', flexDirection: 'column',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '14px 16px', borderBottom: '1px solid var(--border)' }}>
<h2 className="card-title" style={{ margin: 0, fontSize: 16 }}>Liste verknüpfen</h2>
<button type="button" className="btn btn-secondary" onClick={onClose}>Schließen</button>
</div>
<div style={{ padding: '12px 16px' }}>
<p style={{ fontSize: 13, color: 'var(--text2)', lineHeight: 1.5, margin: '0 0 10px' }}>
Diese Tagebuchzeile{hint ? ` („${hint}“)` : ''} einer bestehenden Zutatenliste zuordnen.
Der Eintrag verschwindet danach hier; die Zutaten der Liste ordnest du einzeln zu.
</p>
<input className="form-input" style={{ width: '100%', textAlign: 'left' }} autoFocus placeholder="Alle Listen durchsuchen…" value={q} onChange={(e) => setQ(e.target.value)} />
</div>
<div style={{ overflowY: 'auto', padding: '0 16px 16px', flex: 1 }}>
{recipes.length === 0 && <p style={{ fontSize: 13, color: 'var(--text3)' }}>Noch keine Listen. Unter „Listen“ anlegen oder FDDB importieren.</p>}
{recipes.length > 0 && filtered.length === 0 && <p style={{ fontSize: 13, color: 'var(--text3)' }}>Keine Liste passt zum Filter.</p>}
{filtered.map((r) => (
<button key={r.id} type="button" className="btn btn-secondary btn-full" style={{ marginTop: 8, justifyContent: 'flex-start', textAlign: 'left', height: 'auto', padding: '10px 12px' }} onClick={() => onPick(r.id)}>
<span>
<strong style={{ display: 'block' }}>{r.name_raw}</strong>
<span style={{ fontSize: 12, color: 'var(--text3)' }}>{r.ingredient_count ?? (r.ingredients || []).length} Zutaten</span>
</span>
</button>
))}
</div>
</div>
</div>
)
}
function InlineCreate({ defaultName, disabled, allowRecipe, onCreateFood, onWantRecipe }) {
const [kind, setKind] = useState(allowRecipe ? null : 'food')
const [name, setName] = useState(defaultName || '')
const [macros, setMacros] = useState({ kcal: '0', protein_g: '0', fat_g: '0', carbs_g: '0' })
const [extras, setExtras] = useState([])
const [servingG, setServingG] = useState('100')
if (!kind) {
return (
<div style={{ marginTop: 8, padding: 10, background: 'var(--surface2)', borderRadius: 8 }}>
<p style={{ fontSize: 12, margin: '0 0 8px', color: 'var(--text2)' }}>Lebensmittel (ein Eintrag im Katalog) oder Liste (Roh-Kombination, z. B. Müsli)?</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
<button type="button" className="btn btn-primary" disabled={disabled} onClick={() => setKind('food')}>Lebensmittel</button>
{allowRecipe && (
<button type="button" className="btn btn-secondary" disabled={disabled} onClick={onWantRecipe}>Liste</button>
)}
</div>
</div>
)
}
return (
<div style={{ marginTop: 8, padding: 10, background: 'var(--surface2)', borderRadius: 8 }}>
<p style={{ fontSize: 12, margin: '0 0 8px', color: 'var(--text2)' }}>Neuer Katalogeintrag. Salz: Makros 0. Öl/Supplement: Stoffe wie EPA dazusuchen.</p>
<input className="form-input" style={{ width: '100%', textAlign: 'left', marginBottom: 8 }} value={name} onChange={(e) => setName(e.target.value)} placeholder="Name" />
<FoodNutrientFields
macros={macros}
setMacros={setMacros}
extras={extras}
setExtras={setExtras}
servingG={servingG}
setServingG={setServingG}
/>
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<button type="button" className="btn btn-secondary" disabled={disabled} onClick={() => setKind(null)}>Zurück</button>
<button
type="button"
className="btn btn-primary"
style={{ flex: 1 }}
disabled={disabled || !name.trim()}
onClick={() => onCreateFood(foodCreatePayload(name, macros, extras, servingG))}
>
Anlegen und zuordnen
</button>
</div>
</div>
)
}
export default function NutritionFoodMap({ onChanged, onMapped, onManageLists, onCreatedList }) {
const [unmapped, setUnmapped] = useState([])
const [learned, setLearned] = useState([])
const [recipes, setRecipes] = useState([])
const [error, setError] = useState(null)
const [notice, setNotice] = useState(null)
const [saving, setSaving] = useState(null)
const [searchFor, setSearchFor] = useState(null)
const [recipeFor, setRecipeFor] = useState(null)
const [recipeDraft, setRecipeDraft] = useState(null)
const [createFor, setCreateFor] = useState(null)
const [filter, setFilter] = useState('all')
const [sinceDays, setSinceDays] = useState(28)
const [totalOpen, setTotalOpen] = useState(0)
const [q, setQ] = useState('')
const [visible, setVisible] = useState(80)
const [showLearned, setShowLearned] = useState(false)
const [importing, setImporting] = useState(false)
const [busy, setBusy] = useState(false)
const listRef = useRef(null)
const bundleRef = useRef(null)
const loadGen = useRef(0)
const askedSuggest = useRef(new Set())
const load = async () => {
const gen = ++loadGen.current
try {
const [u, m, r] = await Promise.all([
api.listUnmappedFoods(sinceDays, true),
api.listMyFoodMappings(),
api.listNutritionRecipes(true).catch(() => []),
])
if (gen !== loadGen.current) return
askedSuggest.current = new Set()
const items = Array.isArray(u) ? u : (u?.items || [])
setUnmapped(items)
setTotalOpen(Number(u?.total) || items.length)
setLearned(m)
setRecipes(Array.isArray(r) ? r : [])
} catch (e) {
if (gen !== loadGen.current) return
setError(e.message)
}
}
useEffect(() => { load() }, [sinceDays])
const filtered = useMemo(() => {
const term = q.trim().toLowerCase()
return unmapped.filter((u) => {
if (filter === 'suggested' && !(u.suggestions || []).length) return false
if (filter === 'none' && (u.suggestions || []).length) return false
if (!term) return true
const hay = `${u.source_name_raw || ''} ${u.source_name_normalized || ''}`.toLowerCase()
return hay.includes(term)
})
}, [unmapped, filter, q])
const shown = filtered.slice(0, visible)
useEffect(() => {
const need = []
const seen = new Set()
const take = (u) => {
const key = u.source_name_normalized
if (!key || askedSuggest.current.has(key) || seen.has(key)) return
seen.add(key)
need.push(u)
}
shown.forEach(take)
if (need.length < 80) {
for (const u of unmapped) {
take(u)
if (need.length >= 80) break
}
}
if (!need.length) return
need.forEach((u) => askedSuggest.current.add(u.source_name_normalized))
const names = need.map((u) => u.source_name_raw).filter(Boolean)
if (!names.length) return
api.suggestFoodsBatch(names, 3).then((packed) => {
if (!packed || typeof packed !== 'object') return
setUnmapped((list) => list.map((u) => {
const hit = packed[u.source_name_raw]
return hit ? { ...u, ...hit } : u
}))
}).catch(() => {
need.forEach((u) => askedSuggest.current.delete(u.source_name_normalized))
})
}, [visible, filter, q, unmapped.length])
const assign = async (row, foodId, extras = {}, foodMeta = {}) => {
const sourceName = row.source_name_raw
setSaving(sourceName)
setError(null)
try {
const res = await api.upsertMyFoodMapping({
source_name: sourceName,
food_id: foodId,
grams_per_unit: extras.grams_per_unit || null,
source_unit: extras.source_unit || null,
})
setSearchFor(null)
setCreateFor(null)
setUnmapped((list) => list.filter((x) => x.source_name_normalized !== row.source_name_normalized))
setLearned((list) => [{
id: res.mapping_id,
source_name_raw: sourceName,
food_name_de: res.food_name_de || foodMeta.name_de,
bls_code: res.bls_code || foodMeta.bls_code,
catalog_kind: res.catalog_kind || foodMeta.catalog_kind,
}, ...list])
setNotice(`Gespeichert: ${suggestQuery(sourceName) || sourceName}`)
onMapped?.()
} catch (e) {
setError(e.message)
} finally {
setSaving(null)
}
}
const createAndAssign = async (row, body) => {
setSaving(row.source_name_raw)
setError(null)
try {
const food = await api.createUserFood(body)
await assign(row, food.id, {}, { name_de: food.name_de, catalog_kind: food.catalog_kind })
} catch (e) {
setError(e.message)
setSaving(null)
}
}
const createRecipeAndAssign = async (row, body) => {
setSaving(row.source_name_raw)
setError(null)
try {
const rec = await api.createNutritionRecipe({ ...body, source: 'manual' })
await api.applyNutritionRecipe(rec.id, row.source_name_raw)
setCreateFor(null)
setSearchFor(null)
setUnmapped((list) => list.filter((x) => x.source_name_normalized !== row.source_name_normalized))
setNotice('Liste angelegt und mit der Tagebuchzeile verknüpft. Zutaten erscheinen unter Zuordnen, die Liste unter Listen.')
onCreatedList?.(rec)
if (!onCreatedList) await load()
onMapped?.()
} catch (e) {
setError(e.message)
throw e
} finally {
setSaving(null)
}
}
const applyRecipe = async (sourceName, recipeId) => {
setSaving(sourceName)
setError(null)
try {
await api.applyNutritionRecipe(recipeId, sourceName)
setRecipeFor(null)
setUnmapped((list) => list.filter((x) => x.source_name_raw !== sourceName && x.source_name_normalized !== sourceName))
setNotice('Liste verknüpft. Offene Zutaten der Liste erscheinen hier zum Zuordnen.')
await load()
onMapped?.()
} catch (e) {
setError(e.message)
} finally {
setSaving(null)
}
}
const remove = async (id) => {
if (!confirm('Zuordnung wirklich löschen?')) return
try {
await api.deleteMyFoodMapping(id)
setLearned((list) => list.filter((x) => x.id !== id))
await load()
onChanged?.()
} catch (e) {
setError(e.message)
}
}
const exportBundle = async () => {
setBusy(true)
setError(null)
try {
await api.exportFoodKnowledge()
setNotice('Zuordnungen und Listen als JSON heruntergeladen.')
} catch (e) {
setError(e.message)
} finally {
setBusy(false)
}
}
const importBundle = async (file) => {
if (!file) return
setBusy(true)
setError(null)
try {
const res = await api.importFoodKnowledge(file)
await load()
onChanged?.()
const skip = res.mappings_skipped ? `, ${res.mappings_skipped} ohne Katalogtreffer` : ''
setNotice(`${res.mappings || 0} Zuordnungen und ${(res.inserted || 0) + (res.updated || 0)} Listen übernommen${skip}.`)
} catch (e) {
setError(e.message)
} finally {
setBusy(false)
}
}
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)
}
}
const withSuggest = unmapped.filter((u) => (u.suggestions || []).length).length
return (
<div className="card section-gap">
<div className="card-title">Lebensmittel zuordnen</div>
<p style={{ fontSize: 13, color: 'var(--text2)', lineHeight: 1.6, marginBottom: 12 }}>
Zuerst die letzten Wochen zuordnen. Ältere Namen (z. B. Getreide, das du nicht mehr isst) bleiben unter „Alle“ liegen und müssen nicht gemappt werden.
</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: 8 }}>
<button type="button" className="btn btn-primary" onClick={() => onManageLists?.()}>Listen verwalten</button>
<button type="button" className="btn btn-secondary" disabled={importing} onClick={() => listRef.current?.click()}>
{importing ? 'Importiere Listen…' : 'FDDB-Listen importieren'}
</button>
</div>
<p style={{ fontSize: 12, color: 'var(--text3)', margin: '0 0 12px' }}>
{recipes.length ? `${recipes.length} Listen vorhanden.` : 'Noch keine Listen.'} Anlegen, bearbeiten und FDDB-Import unter Listen.
</p>
<input ref={bundleRef} type="file" accept=".json,application/json" style={{ display: 'none' }} onChange={(e) => { const f = e.target.files?.[0]; e.target.value = ''; if (f) importBundle(f) }} />
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 8 }}>
<button type="button" className="btn btn-secondary" disabled={busy} onClick={exportBundle}>Zuordnungen & Listen exportieren</button>
<button type="button" className="btn btn-secondary" disabled={busy} onClick={() => bundleRef.current?.click()}>Zuordnungen & Listen importieren</button>
</div>
<h3 style={{ fontSize: 14, margin: '16px 0 8px' }}>
Offen ({filtered.length}{sinceDays && totalOpen > unmapped.length ? ` von ${totalOpen}` : filtered.length !== unmapped.length ? ` / ${unmapped.length}` : ''})
</h3>
<p style={{ fontSize: 12, color: 'var(--text3)', margin: '0 0 8px' }}>{withSuggest} mit Vorschlag</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 8 }}>
{PERIODS.map((p) => (
<button
key={p.days}
type="button"
className={sinceDays === p.days ? 'btn btn-primary' : 'btn btn-secondary'}
style={{ fontSize: 12 }}
onClick={() => { setSinceDays(p.days); setVisible(80); setQ('') }}
>
{p.label}
</button>
))}
</div>
<input className="form-input" style={{ width: '100%', textAlign: 'left', marginBottom: 8 }} placeholder="Offene Liste filtern…" value={q} onChange={(e) => { setQ(e.target.value); setVisible(80) }} />
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 8 }}>
{[['all', 'Alle Treffer'], ['suggested', 'Mit Vorschlag'], ['none', 'Ohne Vorschlag']].map(([id, label]) => (
<button key={id} type="button" className={filter === id ? 'btn btn-primary' : 'btn btn-secondary'} style={{ fontSize: 12 }} onClick={() => { setFilter(id); setVisible(80) }}>{label}</button>
))}
</div>
{shown.length === 0 && (
<p className="muted">
{sinceDays && unmapped.length === 0 && totalOpen > 0
? `In diesem Zeitraum ist nichts Offen. ${totalOpen} ältere Namen liegen unter „Alle“ — die brauchst du nicht, wenn du sie nicht mehr isst.`
: 'Keine offenen Bezeichner in diesem Filter.'}
</p>
)}
{shown.map((u) => {
const key = u.source_name_normalized
const suggestions = u.suggestions || []
const best = suggestions[0]
const busyRow = saving === u.source_name_raw
return (
<div key={key} style={{ borderTop: '1px solid var(--border)', padding: '10px 0' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, alignItems: 'flex-start' }}>
<div>
<div style={{ fontWeight: 600 }}>{suggestQuery(u.source_name_raw) || u.source_name_raw}</div>
<div style={{ fontSize: 12, color: 'var(--text3)' }}>
{u.kind === 'recipe_ingredient' ? 'Listenzutat' : `${u.count}×`}
{u.matching_recipe_id ? ` · Liste${u.matching_recipe_name ? `: ${u.matching_recipe_name}` : ''}` : ''}
{u.variant_count > 1 ? ` · ${u.variant_count} Mengen` : ''}
{u.last_date ? ` · zuletzt ${formatDay(u.last_date)}` : ''}
</div>
{u.kind !== 'recipe_ingredient' && u.matching_recipe_id && (
<div style={{ fontSize: 12, color: 'var(--accent-dark)', marginTop: 4 }}>
Entspricht einer Zutatenliste — verknüpfen, dann die Zutaten zuordnen.
</div>
)}
</div>
{(u.ambiguous || suggestions.length > 1) && (
<span style={{ fontSize: 11, padding: '2px 8px', borderRadius: 999, background: 'var(--surface2)', color: 'var(--danger)', whiteSpace: 'nowrap' }}>
mehrere möglich
</span>
)}
</div>
{best && (
<div style={{ marginTop: 8, display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
<span style={{ fontSize: 13 }}>
→ <strong>{best.name_de}</strong>
<span style={{ color: 'var(--text3)' }}>{best.bls_code ? ` · ${best.bls_code}` : ' · manuell'}</span>
</span>
<button type="button" className="btn btn-primary" disabled={busyRow} onClick={() => assign(u, best.id, {}, best)}>
Bestätigen
</button>
</div>
)}
{suggestions.slice(1).map((s) => (
<div key={s.id} style={{ marginTop: 6, display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
<span style={{ fontSize: 12, color: 'var(--text2)' }}>oder {s.name_de}{s.bls_code ? ` · ${s.bls_code}` : ''}</span>
<button type="button" className="btn btn-secondary" disabled={busyRow} onClick={() => assign(u, s.id, {}, s)}>Übernehmen</button>
</div>
))}
{!best && <p style={{ fontSize: 12, color: 'var(--text3)', margin: '8px 0 0' }}>Kein Katalogvorschlag — selbst anlegen.</p>}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 8 }}>
<button type="button" className="btn btn-secondary" disabled={busyRow} onClick={() => setCreateFor(createFor === key ? null : key)}>
{createFor === key ? 'Anlegen schließen' : (u.kind === 'recipe_ingredient' ? 'Neu anlegen' : 'Lebensmittel oder Liste')}
</button>
<button type="button" className="btn btn-secondary" disabled={busyRow} onClick={() => setSearchFor(u)}>Mehr suchen</button>
{u.kind !== 'recipe_ingredient' && u.matching_recipe_id && (
<button type="button" className="btn btn-primary" disabled={busyRow} onClick={() => applyRecipe(u.source_name_raw, u.matching_recipe_id)}>Als Liste verknüpfen</button>
)}
{u.kind !== 'recipe_ingredient' && u.matching_recipe_id && (
<button type="button" className="btn btn-secondary" disabled={busyRow} onClick={() => onManageLists?.(u.matching_recipe_id)}>Liste öffnen</button>
)}
{u.kind !== 'recipe_ingredient' && (
<button type="button" className="btn btn-secondary" disabled={busyRow} onClick={() => setRecipeFor(u)}>Andere Liste wählen</button>
)}
</div>
{createFor === key && (
<InlineCreate
defaultName={suggestQuery(u.source_name_raw) || u.source_name_raw}
disabled={busyRow}
allowRecipe={u.kind !== 'recipe_ingredient'}
onCreateFood={(body) => createAndAssign(u, body)}
onWantRecipe={() => {
setCreateFor(null)
setSearchFor(null)
setRecipeDraft({
row: u,
defaultName: suggestQuery(u.source_name_raw) || u.source_name_raw,
})
}}
/>
)}
</div>
)
})}
{visible < filtered.length && (
<button type="button" className="btn btn-secondary btn-full" style={{ marginTop: 8 }} onClick={() => setVisible((n) => n + 80)}>
Weitere {Math.min(80, filtered.length - visible)} zeigen
</button>
)}
<h3 style={{ fontSize: 14, margin: '20px 0 8px' }}>
Gelernt ({learned.length}){' '}
<button type="button" className="btn btn-secondary" style={{ fontSize: 11, padding: '2px 8px' }} onClick={() => setShowLearned((v) => !v)}>
{showLearned ? 'Einklappen' : 'Anzeigen'}
</button>
</h3>
{showLearned && learned.map((m) => (
<div key={m.id} style={{ display: 'flex', justifyContent: 'space-between', gap: 8, padding: '8px 0', borderTop: '1px solid var(--border)' }}>
<div>
<div style={{ fontWeight: 500 }}>{m.source_name_raw}</div>
<div style={{ fontSize: 12, color: 'var(--text3)' }}>
→ {m.food_name_de}{m.bls_code ? ` · ${m.bls_code}` : ''}
{m.catalog_kind !== 'official_bls' ? ' (manuell)' : ''}
</div>
</div>
<button type="button" className="btn btn-secondary" onClick={() => remove(m.id)}>Löschen</button>
</div>
))}
{searchFor && (
<FoodSearchModal
title={`Suchen: ${searchFor.source_name_raw}`}
initialQuery={suggestQuery(searchFor.source_name_raw)}
quantityHint={searchFor.sample_quantity_raw || searchFor.source_name_raw}
allowRecipe={searchFor.kind !== 'recipe_ingredient'}
onClose={() => setSearchFor(null)}
onSelect={(food, extras) => assign(searchFor, food.id, extras, food)}
onWantRecipe={searchFor.kind === 'recipe_ingredient' ? undefined : () => {
const row = searchFor
setSearchFor(null)
setCreateFor(null)
setRecipeDraft({
row,
defaultName: suggestQuery(row.source_name_raw) || row.source_name_raw,
})
}}
/>
)}
{recipeDraft && (
<FoodRecipeDialog
title="Liste anlegen"
defaultName={recipeDraft.defaultName}
learned={learned}
disabled={saving === recipeDraft.row.source_name_raw}
submitLabel="Liste anlegen und zuordnen"
onClose={() => setRecipeDraft(null)}
onSave={async (body) => {
await createRecipeAndAssign(recipeDraft.row, body)
setRecipeDraft(null)
}}
/>
)}
{recipeFor && (
<RecipePickModal recipes={recipes} sourceName={recipeFor.source_name_raw} onClose={() => setRecipeFor(null)} onPick={(id) => applyRecipe(recipeFor.source_name_raw, id)} />
)}
</div>
)
}
export function DayMarkButtons({ date, markType, onChanged }) {
const setMark = async (type) => {
try {
if (markType === type) await api.deleteNutritionDayMark(date)
else await api.putNutritionDayMark(date, { mark_type: type })
onChanged?.()
} catch (e) {
alert(e.message)
}
}
return (
<span style={{ display: 'inline-flex', gap: 8 }}>
<button type="button" className="btn btn-secondary" style={{ fontSize: 11, padding: '4px 8px' }} onClick={() => setMark('fasting')}>
{markType === 'fasting' ? '✓ Fasten' : 'Fasten'}
</button>
<button type="button" className="btn btn-secondary" style={{ fontSize: 11, padding: '4px 8px' }} onClick={() => setMark('incomplete')}>
{markType === 'incomplete' ? '✓ Lücke' : 'Lücke'}
</button>
</span>
)
}