mitai-jinkendo/frontend/src/components/NutritionFoodMap.jsx

494 lines
21 KiB
React
Raw Normal View History

import { useEffect, useMemo, useRef, useState } from 'react'
import { api } from '../utils/api'
import FoodSearchModal from './FoodSearchModal'
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(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 }}>Eigenes Rezept wählen</h2>
<button type="button" className="btn btn-secondary" onClick={onClose}>Schließen</button>
</div>
<div style={{ padding: '12px 16px' }}>
<input className="form-input" style={{ width: '100%', textAlign: 'left' }} autoFocus placeholder="Rezeptname filtern" value={q} onChange={(e) => setQ(e.target.value)} />
</div>
<div style={{ overflowY: 'auto', padding: '0 16px 16px', flex: 1 }}>
{filtered.length === 0 && <p style={{ fontSize: 13, color: 'var(--text3)' }}>Kein passendes Rezept.</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.ingredients || []).length} Zutaten</span>
</span>
</button>
))}
</div>
</div>
</div>
)
}
function InlineCreate({ defaultName, disabled, onCreate }) {
const [name, setName] = useState(defaultName || '')
const [kcal, setKcal] = useState('0')
const [protein, setProtein] = useState('0')
const [fat, setFat] = useState('0')
const [carbs, setCarbs] = useState('0')
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 (Werte pro 100 g, Salz z. B. alles 0)</p>
<input className="form-input" style={{ width: '100%', textAlign: 'left', marginBottom: 8 }} value={name} onChange={(e) => setName(e.target.value)} placeholder="Name" />
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 6 }}>
{[['kcal', kcal, setKcal], ['Protein', protein, setProtein], ['Fett', fat, setFat], ['KH', carbs, setCarbs]].map(([label, val, set]) => (
<label key={label} style={{ fontSize: 11, color: 'var(--text3)' }}>
{label}
<input className="form-input" type="number" min="0" step="0.1" style={{ width: '100%', textAlign: 'left', marginTop: 2 }} value={val} onChange={(e) => set(e.target.value)} />
</label>
))}
</div>
<button
type="button"
className="btn btn-primary btn-full"
style={{ marginTop: 8 }}
disabled={disabled || !name.trim()}
onClick={() => onCreate({
name_de: name.trim(),
macros_per_100g: {
kcal: parseFloat(String(kcal).replace(',', '.')) || 0,
protein_g: parseFloat(String(protein).replace(',', '.')) || 0,
fat_g: parseFloat(String(fat).replace(',', '.')) || 0,
carbs_g: parseFloat(String(carbs).replace(',', '.')) || 0,
},
})}
>
Anlegen und zuordnen
</button>
</div>
)
}
export default function NutritionFoodMap({ onChanged, onMapped }) {
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 [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, counts] = await Promise.all([
api.listUnmappedFoods(sinceDays),
api.listMyFoodMappings(),
api.listNutritionRecipes().catch(() => []),
api.listUnmappedFoodCount(sinceDays).catch(() => null),
])
if (gen !== loadGen.current) return
askedSuggest.current = new Set()
setUnmapped(Array.isArray(u) ? u : [])
setTotalOpen(Number(counts?.total) || (Array.isArray(u) ? u.length : 0))
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 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))
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?.()
setNotice(`${res.recipes} Listen importiert, ${res.items_linked || 0} Tagebuchzeilen verknüpft.`)
} 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) }} />
<button type="button" className="btn btn-secondary btn-full" disabled={importing} onClick={() => listRef.current?.click()}>
{importing ? 'Importiere Listen…' : 'FDDB-Listen / Rezepte importieren'}
</button>
{recipes.length > 0 && <p style={{ fontSize: 12, color: 'var(--text3)', marginTop: 8 }}>{recipes.length} eigene Listen geladen</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' ? 'Rezeptzutat' : `${u.count}×`}
{u.variant_count > 1 ? ` · ${u.variant_count} Mengen` : ''}
{u.last_date ? ` · zuletzt ${formatDay(u.last_date)}` : ''}
</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' : 'Neu anlegen'}
</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-secondary" disabled={busyRow} onClick={() => applyRecipe(u.source_name_raw, u.matching_recipe_id)}>Als Rezept</button>
)}
{u.kind !== 'recipe_ingredient' && recipes.length > 0 && (
<button type="button" className="btn btn-secondary" disabled={busyRow} onClick={() => setRecipeFor(u)}>Rezept wählen</button>
)}
</div>
{createFor === key && (
<InlineCreate defaultName={suggestQuery(u.source_name_raw) || u.source_name_raw} disabled={busyRow} onCreate={(body) => createAndAssign(u, body)} />
)}
</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}
onClose={() => setSearchFor(null)}
onSelect={(food, extras) => assign(searchFor, food.id, extras, food)}
/>
)}
{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>
)
}