2026-09-12 15:31:20 +02:00
|
|
|
|
import { useEffect, useRef, useState } from 'react'
|
2026-09-12 14:35:57 +02:00
|
|
|
|
import { api } from '../utils/api'
|
2026-09-12 15:31:20 +02:00
|
|
|
|
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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
2026-09-12 14:35:57 +02:00
|
|
|
|
|
|
|
|
|
|
export default function NutritionFoodMap({ onChanged }) {
|
|
|
|
|
|
const [unmapped, setUnmapped] = useState([])
|
|
|
|
|
|
const [learned, setLearned] = useState([])
|
2026-09-12 15:31:20 +02:00
|
|
|
|
const [recipes, setRecipes] = useState([])
|
2026-09-12 14:35:57 +02:00
|
|
|
|
const [error, setError] = useState(null)
|
2026-09-12 15:31:20 +02:00
|
|
|
|
const [notice, setNotice] = useState(null)
|
2026-09-12 14:35:57 +02:00
|
|
|
|
const [saving, setSaving] = useState(null)
|
2026-09-12 15:31:20 +02:00
|
|
|
|
const [searchFor, setSearchFor] = useState(null)
|
|
|
|
|
|
const [recipeFor, setRecipeFor] = useState(null)
|
|
|
|
|
|
const [importing, setImporting] = useState(false)
|
|
|
|
|
|
const listRef = useRef(null)
|
2026-09-12 14:35:57 +02:00
|
|
|
|
|
|
|
|
|
|
const load = async () => {
|
|
|
|
|
|
try {
|
2026-09-12 15:31:20 +02:00
|
|
|
|
const [u, m, r] = await Promise.all([
|
|
|
|
|
|
api.listUnmappedFoods(),
|
|
|
|
|
|
api.listMyFoodMappings(),
|
|
|
|
|
|
api.listNutritionRecipes().catch(() => []),
|
|
|
|
|
|
])
|
2026-09-12 14:35:57 +02:00
|
|
|
|
setUnmapped(u)
|
|
|
|
|
|
setLearned(m)
|
2026-09-12 15:31:20 +02:00
|
|
|
|
setRecipes(Array.isArray(r) ? r : [])
|
2026-09-12 14:35:57 +02:00
|
|
|
|
} catch (e) {
|
|
|
|
|
|
setError(e.message)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => { load() }, [])
|
|
|
|
|
|
|
2026-09-12 15:31:20 +02:00
|
|
|
|
const assign = async (sourceName, foodId) => {
|
|
|
|
|
|
setSaving(sourceName)
|
|
|
|
|
|
setError(null)
|
2026-09-12 14:35:57 +02:00
|
|
|
|
try {
|
2026-09-12 15:31:20 +02:00
|
|
|
|
await api.upsertMyFoodMapping({ source_name: sourceName, food_id: foodId })
|
|
|
|
|
|
setSearchFor(null)
|
|
|
|
|
|
await load()
|
|
|
|
|
|
onChanged?.()
|
2026-09-12 14:35:57 +02:00
|
|
|
|
} catch (e) {
|
|
|
|
|
|
setError(e.message)
|
2026-09-12 15:31:20 +02:00
|
|
|
|
} finally {
|
|
|
|
|
|
setSaving(null)
|
2026-09-12 14:35:57 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-12 15:31:20 +02:00
|
|
|
|
const applyRecipe = async (sourceName, recipeId) => {
|
|
|
|
|
|
setSaving(sourceName)
|
2026-09-12 14:35:57 +02:00
|
|
|
|
setError(null)
|
|
|
|
|
|
try {
|
2026-09-12 15:31:20 +02:00
|
|
|
|
await api.applyNutritionRecipe(recipeId, sourceName)
|
|
|
|
|
|
setRecipeFor(null)
|
2026-09-12 14:35:57 +02:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-12 15:31:20 +02:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-12 14:35:57 +02:00
|
|
|
|
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 }}>
|
2026-09-12 15:31:20 +02:00
|
|
|
|
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.
|
2026-09-12 14:35:57 +02:00
|
|
|
|
</p>
|
|
|
|
|
|
{error && <div style={{ color: 'var(--danger)', fontSize: 13, marginBottom: 10 }}>{error}</div>}
|
2026-09-12 15:31:20 +02:00
|
|
|
|
{notice && <div style={{ fontSize: 13, color: 'var(--accent-dark)', marginBottom: 10 }}>{notice}</div>}
|
2026-09-12 14:35:57 +02:00
|
|
|
|
|
2026-09-12 15:31:20 +02:00
|
|
|
|
<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>}
|
2026-09-12 14:35:57 +02:00
|
|
|
|
{unmapped.map((u) => {
|
2026-09-12 15:31:20 +02:00
|
|
|
|
const key = `${u.kind || 'diary'}-${u.source_name_normalized}`
|
2026-09-12 14:35:57 +02:00
|
|
|
|
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)' }}>
|
2026-09-12 15:31:20 +02:00
|
|
|
|
{u.kind === 'recipe_ingredient' ? 'Rezeptzutat' : `${u.count}×`}
|
|
|
|
|
|
{u.first_date ? ` · ${u.first_date} – ${u.last_date}` : ''}
|
2026-09-12 14:35:57 +02:00
|
|
|
|
</div>
|
2026-09-12 15:31:20 +02:00
|
|
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 8 }}>
|
2026-09-12 14:35:57 +02:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
2026-09-12 15:31:20 +02:00
|
|
|
|
className="btn btn-primary"
|
|
|
|
|
|
disabled={saving === u.source_name_raw}
|
|
|
|
|
|
onClick={() => setSearchFor(u)}
|
2026-09-12 14:35:57 +02:00
|
|
|
|
>
|
2026-09-12 15:31:20 +02:00
|
|
|
|
Im Katalog suchen
|
2026-09-12 14:35:57 +02:00
|
|
|
|
</button>
|
2026-09-12 15:31:20 +02:00
|
|
|
|
{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>
|
2026-09-12 14:35:57 +02:00
|
|
|
|
</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)' }}>
|
2026-09-12 15:31:20 +02:00
|
|
|
|
→ {m.food_name_de}{m.bls_code ? ` · ${m.bls_code}` : ''}
|
2026-09-12 14:35:57 +02:00
|
|
|
|
{m.catalog_kind !== 'official_bls' ? ' (manuell)' : ''}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button type="button" className="btn btn-secondary" onClick={() => remove(m.id)}>Löschen</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
2026-09-12 15:31:20 +02:00
|
|
|
|
|
|
|
|
|
|
{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)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2026-09-12 14:35:57 +02:00
|
|
|
|
</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 (
|
2026-09-12 15:31:20 +02:00
|
|
|
|
<span style={{ display: 'inline-flex', gap: 8 }}>
|
2026-09-12 14:35:57 +02:00
|
|
|
|
<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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|