import { useEffect, useRef, useState } from 'react'
import { api } from '../utils/api'
import FoodSearchModal from './FoodSearchModal'
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 (
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',
}}
>
Eigenes Rezept wählen
Schließen
setQ(e.target.value)}
/>
{filtered.length === 0 &&
Kein passendes Rezept. Zuerst Listen-CSV importieren.
}
{filtered.map((r) => (
onPick(r.id)}
>
{r.name_raw}
{(r.ingredients || []).length} Zutaten
{r.portions ? ` · ${r.portions} Portionen` : ''}
))}
)
}
export default function NutritionFoodMap({ onChanged }) {
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 [importing, setImporting] = useState(false)
const [busy, setBusy] = useState(false)
const listRef = useRef(null)
const bundleRef = useRef(null)
const loadGen = useRef(0)
const load = async () => {
const gen = ++loadGen.current
try {
const [u, m, r] = await Promise.all([
api.listUnmappedFoods(),
api.listMyFoodMappings(),
api.listNutritionRecipes().catch(() => []),
])
if (gen !== loadGen.current) return
setUnmapped(u)
setLearned(m)
setRecipes(Array.isArray(r) ? r : [])
} catch (e) {
if (gen !== loadGen.current) return
setError(e.message)
}
}
useEffect(() => { load() }, [])
const assign = async (sourceName, foodId, extras = {}) => {
setSaving(sourceName)
setError(null)
try {
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)
setNotice(`Zuordnung gespeichert: ${sourceName}`)
await load()
onChanged?.()
} catch (e) {
setError(e.message)
} finally {
setSaving(null)
}
}
const applyRecipe = async (sourceName, recipeId) => {
setSaving(sourceName)
setError(null)
try {
await api.applyNutritionRecipe(recipeId, sourceName)
setRecipeFor(null)
await load()
onChanged?.()
} catch (e) {
setError(e.message)
} finally {
setSaving(null)
}
}
const remove = async (id) => {
if (!confirm('Zuordnung wirklich löschen?')) return
try {
await api.deleteMyFoodMapping(id)
await load()
onChanged?.()
} catch (e) {
setError(e.message)
}
}
const exportBundle = async () => {
setBusy(true)
setError(null)
setNotice(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)
setNotice(null)
try {
const res = await api.importFoodKnowledge(file)
await load()
onChanged?.()
const skip = res.mappings_skipped ? `, ${res.mappings_skipped} ohne Katalogtreffer` : ''
const lists = (res.inserted || 0) + (res.updated || 0)
setNotice(`${res.mappings || 0} Zuordnungen und ${lists} Listen übernommen${skip}.`)
} catch (e) {
setError(e.message)
} finally {
setBusy(false)
}
}
const importLists = async (file) => {
if (!file) return
setImporting(true)
setError(null)
setNotice(null)
try {
const res = await api.importFddbLists(file)
await load()
onChanged?.()
setNotice(`${res.recipes} Listen importiert, ${res.items_linked || 0} Tagebuchzeilen als Rezept verknüpft. Offene Zeilen sind jetzt die Zutaten.`)
} catch (e) {
setError(e.message)
} finally {
setImporting(false)
}
}
return (
Lebensmittel zuordnen
Tippe auf „Im Katalog suchen“ — du suchst nach dem Namen, nicht nach einem Code.
Eigene FDDB-Listen zuerst importieren, dann wird die Tagebuchzeile in Zutaten aufgelöst.
Für den Umzug nach Prod: Zuordnungen und Listen als JSON exportieren und dort wieder importieren.
{error &&
{error}
}
{notice &&
{notice}
}
{
const f = e.target.files?.[0]
e.target.value = ''
if (f) importLists(f)
}}
/>
listRef.current?.click()}
>
{importing ? 'Importiere Listen…' : 'FDDB-Listen / Rezepte importieren'}
{recipes.length > 0 && (
{recipes.length} eigene Listen geladen
)}
{
const f = e.target.files?.[0]
e.target.value = ''
if (f) importBundle(f)
}}
/>
Zuordnungen & Listen exportieren
bundleRef.current?.click()}>
Zuordnungen & Listen importieren
Offen ({unmapped.length})
{unmapped.length === 0 &&
Keine offenen Bezeichner.
}
{unmapped.map((u) => {
const key = `${u.kind || 'diary'}-${u.source_name_normalized}`
return (
{suggestQuery(u.source_name_raw) || u.source_name_raw}
{u.kind === 'recipe_ingredient' ? 'Rezeptzutat' : `${u.count}×`}
{u.variant_count > 1 ? ` · ${u.variant_count} Mengen-Varianten` : ''}
{u.first_date ? ` · ${u.first_date} – ${u.last_date}` : ''}
setSearchFor(u)}
>
Im Katalog suchen
{u.kind !== 'recipe_ingredient' && u.matching_recipe_id && (
applyRecipe(u.source_name_raw, u.matching_recipe_id)}
>
Als eigenes Rezept auflösen
)}
{u.kind !== 'recipe_ingredient' && recipes.length > 0 && (
setRecipeFor(u)}
>
Eigenes Rezept wählen
)}
)
})}
Gelernt ({learned.length})
{learned.map((m) => (
{m.source_name_raw}
→ {m.food_name_de}{m.bls_code ? ` · ${m.bls_code}` : ''}
{m.catalog_kind !== 'official_bls' ? ' (manuell)' : ''}
{m.grams_per_unit ? ` · 1 ${m.source_unit || 'Einheit'} = ${m.grams_per_unit} g` : ''}
remove(m.id)}>Löschen
))}
{searchFor && (
setSearchFor(null)}
onSelect={(food, extras) => assign(searchFor.source_name_raw, food.id, extras)}
/>
)}
{recipeFor && (
setRecipeFor(null)}
onPick={(id) => applyRecipe(recipeFor.source_name_raw, id)}
/>
)}
)
}
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 (