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