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

297 lines
10 KiB
React
Raw Normal View History

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 (
<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. Zuerst Listen-CSV importieren.</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
{r.portions ? ` · ${r.portions} Portionen` : ''}
</span>
</span>
</button>
))}
</div>
</div>
</div>
)
}
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 listRef = useRef(null)
const load = async () => {
try {
const [u, m, r] = await Promise.all([
api.listUnmappedFoods(),
api.listMyFoodMappings(),
api.listNutritionRecipes().catch(() => []),
])
setUnmapped(u)
setLearned(m)
setRecipes(Array.isArray(r) ? r : [])
} catch (e) {
setError(e.message)
}
}
useEffect(() => { load() }, [])
const assign = async (sourceName, foodId) => {
setSaving(sourceName)
setError(null)
try {
await api.upsertMyFoodMapping({ source_name: sourceName, food_id: foodId })
setSearchFor(null)
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 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 (
<div className="card section-gap">
<div className="card-title">Lebensmittel zuordnen</div>
<p style={{ fontSize: 13, color: 'var(--text2)', lineHeight: 1.6, marginBottom: 12 }}>
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.
</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>
)}
<h3 style={{ fontSize: 14, margin: '16px 0 8px' }}>Offen ({unmapped.length})</h3>
{unmapped.length === 0 && <p className="muted">Keine offenen Bezeichner.</p>}
{unmapped.map((u) => {
const key = `${u.kind || 'diary'}-${u.source_name_normalized}`
return (
<div key={key} style={{ borderTop: '1px solid var(--border)', padding: '10px 0' }}>
<div style={{ fontWeight: 600 }}>{u.source_name_raw}</div>
<div style={{ fontSize: 12, color: 'var(--text3)' }}>
{u.kind === 'recipe_ingredient' ? 'Rezeptzutat' : `${u.count}×`}
{u.first_date ? ` · ${u.first_date} – ${u.last_date}` : ''}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 8 }}>
<button
type="button"
className="btn btn-primary"
disabled={saving === u.source_name_raw}
onClick={() => setSearchFor(u)}
>
Im Katalog suchen
</button>
{u.kind !== 'recipe_ingredient' && u.matching_recipe_id && (
<button
type="button"
className="btn btn-secondary"
disabled={saving === u.source_name_raw}
onClick={() => applyRecipe(u.source_name_raw, u.matching_recipe_id)}
>
Als eigenes Rezept auflösen
</button>
)}
{u.kind !== 'recipe_ingredient' && recipes.length > 0 && (
<button
type="button"
className="btn btn-secondary"
disabled={saving === u.source_name_raw}
onClick={() => setRecipeFor(u)}
>
Eigenes Rezept wählen
</button>
)}
</div>
</div>
)
})}
<h3 style={{ fontSize: 14, margin: '20px 0 8px' }}>Gelernt ({learned.length})</h3>
{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)}
onClose={() => setSearchFor(null)}
onSelect={(food) => assign(searchFor.source_name_raw, food.id)}
/>
)}
{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>
)
}