mitai-jinkendo/frontend/src/components/FoodSearchModal.jsx
Lars e452d9a8fe
All checks were successful
Deploy Development / deploy (push) Successful in 1m7s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 23s
fix: zentrale Lebensmittel fuer Admins aenderbar
Persoenliche Liste bleibt beim Owner; zentrale Eintraege oeffnen den Admin-Editor. Admin-Katalog trennt beide Listen, persoenliche bleiben sichtbar.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 13:01:41 +02:00

291 lines
11 KiB
JavaScript

import { useEffect, useRef, useState } from 'react'
import { api } from '../utils/api'
import { useAuth } from '../context/AuthContext'
import FoodNutrientFields, { foodCreatePayload } from './FoodNutrientFields'
import FoodEditDialog from './FoodEditDialog'
import { foodVisibilitySuffix } from '../utils/foodVisibility'
const COUNT_UNITS = new Set(['stück', 'scheibe', 'portion', 'becher', 'tasse'])
const UNIT_LABEL = {
stück: 'Stück', scheibe: 'Scheibe', portion: 'Portion',
becher: 'Becher', tasse: 'Tasse', el: 'EL', tl: 'TL', prise: 'Prise',
}
function detectUnit(...texts) {
for (const text of texts) {
const m = String(text || '').match(
/(\d+(?:[.,]\d+)?)\s*(stück|stk|st\.?|scheibe|portion(?:en)?|becher|tasse|el|esslöffel|tl|teelöffel|prise)\b/i
)
if (!m) continue
const raw = m[2].toLowerCase().replace(/\.$/, '')
const unit = raw.startsWith('st') && !raw.startsWith('scheibe') ? 'stück'
: raw.startsWith('ess') || raw === 'el' ? 'el'
: raw.startsWith('tee') || raw === 'tl' ? 'tl'
: raw.startsWith('portion') ? 'portion'
: raw
return unit
}
return null
}
export default function FoodSearchModal({ title, initialQuery, quantityHint, allowRecipe, onSelect, onWantRecipe, onClose, zIndex }) {
const { isAdmin } = useAuth()
const [q, setQ] = useState(initialQuery || '')
const [hits, setHits] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const [creating, setCreating] = useState(false)
const [showCreate, setShowCreate] = useState(false)
const [createKind, setCreateKind] = useState(null)
const [editId, setEditId] = useState(null)
const [manual, setManual] = useState({
name_de: initialQuery || '',
kcal: '', protein_g: '', fat_g: '', carbs_g: '',
})
const [nutrientExtras, setNutrientExtras] = useState([])
const [servingG, setServingG] = useState('100')
const suggestedUnit = detectUnit(quantityHint, initialQuery)
const [gramsPerUnit, setGramsPerUnit] = useState(
suggestedUnit === 'el' ? '15' : suggestedUnit === 'tl' ? '5' : ''
)
const inputRef = useRef(null)
const timer = useRef(null)
const abortRef = useRef(null)
const seqRef = useRef(0)
const unitExtras = () => {
const g = parseFloat(String(gramsPerUnit).replace(',', '.'))
if (!suggestedUnit || !g || g <= 0) return {}
return { grams_per_unit: g, source_unit: suggestedUnit }
}
const runSearch = async (term) => {
const query = (term || '').trim()
abortRef.current?.abort()
if (query.length < 2) {
setHits([])
setLoading(false)
return
}
const seq = ++seqRef.current
const ac = new AbortController()
abortRef.current = ac
setLoading(true)
setError(null)
try {
const next = await api.searchBlsFoods(query, 20, ac.signal)
if (seq !== seqRef.current) return
setHits(next)
} catch (e) {
if (e.name === 'AbortError') return
if (seq !== seqRef.current) return
setError(e.message)
setHits([])
} finally {
if (seq === seqRef.current) setLoading(false)
}
}
useEffect(() => {
inputRef.current?.focus()
inputRef.current?.select()
if ((initialQuery || '').trim().length >= 2) runSearch(initialQuery)
const onKey = (e) => { if (e.key === 'Escape') onClose() }
window.addEventListener('keydown', onKey)
return () => {
window.removeEventListener('keydown', onKey)
clearTimeout(timer.current)
abortRef.current?.abort()
}
}, [])
const onChange = (value) => {
setQ(value)
clearTimeout(timer.current)
if (value.trim().length < 2) {
abortRef.current?.abort()
setHits([])
setLoading(false)
return
}
timer.current = setTimeout(() => runSearch(value), 400)
}
const createManual = async () => {
const name = (manual.name_de || q || '').trim()
if (!name) {
setError('Name für den Eintrag fehlt')
return
}
setCreating(true)
setError(null)
try {
const food = await api.createUserFood(foodCreatePayload(name, manual, nutrientExtras, servingG))
onSelect(food, unitExtras())
} catch (e) {
setError(e.message)
} finally {
setCreating(false)
}
}
const unitLabel = UNIT_LABEL[suggestedUnit] || suggestedUnit
return (
<>
<div
style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)',
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: zIndex || 20000,
padding: 16,
}}
onClick={onClose}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="food-search-title"
onClick={(e) => e.stopPropagation()}
style={{
width: '100%', maxWidth: 520, maxHeight: 'min(88vh, 720px)',
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 id="food-search-title" className="card-title" style={{ margin: 0, fontSize: 16 }}>{title || 'Lebensmittel suchen'}</h2>
<button type="button" className="btn btn-secondary" onClick={onClose}>Schließen</button>
</div>
<div style={{ padding: '12px 16px' }}>
<input
ref={inputRef}
className="form-input"
style={{ width: '100%', textAlign: 'left' }}
placeholder="Name und Fettgehalt, z. B. Joghurt 10%"
value={q}
onChange={(e) => onChange(e.target.value)}
/>
<p style={{ fontSize: 12, color: 'var(--text3)', margin: '8px 0 0' }}>
Fettgehalt mit eingeben, z. B. Joghurt 10 % oder 9,5 %. Ohne Zahl erscheint oft nur die 3,5 %-Klasse. Fehlt der Treffer, eigenen Eintrag anlegen.
</p>
{suggestedUnit && (
<label style={{ display: 'block', marginTop: 10, fontSize: 13 }}>
1 {unitLabel} ={' '}
<input
className="form-input"
type="number"
min="0.1"
step="0.1"
style={{ width: 90, display: 'inline-block', textAlign: 'left' }}
value={gramsPerUnit}
onChange={(e) => setGramsPerUnit(e.target.value)}
placeholder={COUNT_UNITS.has(suggestedUnit) ? 'z. B. 60' : ''}
/>
{' '}g
</label>
)}
</div>
<div style={{ overflowY: 'auto', padding: '0 16px 16px', flex: 1 }}>
{loading && <p style={{ fontSize: 13, color: 'var(--text2)' }}>Suche…</p>}
{error && <p style={{ color: 'var(--danger)', fontSize: 13 }}>{error}</p>}
{!loading && q.trim().length >= 2 && hits.length === 0 && (
<p style={{ fontSize: 13, color: 'var(--text3)' }}>Kein Treffer im Katalog.</p>
)}
{hits.map((h) => {
const manual = h.catalog_kind === 'manual_user' || (isAdmin && h.catalog_kind === 'manual_admin')
return (
<div key={h.id} style={{ display: 'flex', gap: 8, marginTop: 8, alignItems: 'stretch' }}>
<button
type="button"
className="btn btn-secondary"
style={{ flex: 1, justifyContent: 'flex-start', textAlign: 'left', height: 'auto', padding: '10px 12px' }}
onClick={() => onSelect(h, unitExtras())}
>
<span>
<strong style={{ display: 'block' }}>{h.name_de}</strong>
<span style={{ fontSize: 12, color: 'var(--text3)' }}>
{h.bls_code ? `BLS ${h.bls_code}` : 'ohne Code'}
{h.food_group ? ` · Gruppe ${h.food_group}` : ''}
{foodVisibilitySuffix(h.catalog_kind, h.visibility)}
</span>
</span>
</button>
{manual && (
<button type="button" className="btn btn-secondary" style={{ flexShrink: 0 }} onClick={() => setEditId(h.id)}>
Bearbeiten
</button>
)}
</div>
)
})}
<div style={{ marginTop: 16, paddingTop: 12, borderTop: '1px solid var(--border)' }}>
{!showCreate ? (
<button type="button" className="btn btn-secondary btn-full" onClick={() => {
setShowCreate(true)
setCreateKind(allowRecipe && onWantRecipe ? null : 'food')
setManual((s) => ({ ...s, name_de: s.name_de || q || initialQuery || '' }))
}}>
{allowRecipe && onWantRecipe ? 'Lebensmittel oder Liste anlegen' : 'Eigenes Lebensmittel anlegen'}
</button>
) : !createKind ? (
<div>
<p style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>Was möchtest du anlegen?</p>
<p style={{ fontSize: 12, color: 'var(--text2)', margin: '0 0 8px' }}>Lebensmittel = ein Katalogeintrag. Liste = Roh-Kombination (z. B. Müsli) mit Zutatensuche.</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
<button type="button" className="btn btn-primary" onClick={() => setCreateKind('food')}>Lebensmittel</button>
<button type="button" className="btn btn-secondary" onClick={onWantRecipe}>Liste</button>
<button type="button" className="btn btn-secondary" onClick={() => { setShowCreate(false); setCreateKind(null) }}>Zurück</button>
</div>
</div>
) : (
<div>
<p style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>Eigener Eintrag</p>
<input
className="form-input"
style={{ width: '100%', textAlign: 'left', marginBottom: 8 }}
placeholder="Name, z. B. Norsan Omega-3 Algenöl"
value={manual.name_de}
onChange={(e) => setManual({ ...manual, name_de: e.target.value })}
/>
<FoodNutrientFields
macros={manual}
setMacros={setManual}
extras={nutrientExtras}
setExtras={setNutrientExtras}
servingG={servingG}
setServingG={setServingG}
/>
<div style={{ display: 'flex', gap: 8, marginTop: 10 }}>
{allowRecipe && onWantRecipe && (
<button type="button" className="btn btn-secondary" disabled={creating} onClick={() => setCreateKind(null)}>Zurück</button>
)}
<button
type="button"
className="btn btn-primary"
style={{ flex: 1 }}
disabled={creating}
onClick={createManual}
>
{creating ? 'Speichere…' : 'Anlegen und zuordnen'}
</button>
</div>
</div>
)}
</div>
</div>
</div>
</div>
{editId && (
<FoodEditDialog
foodId={editId}
zIndex={(zIndex || 20000) + 2000}
onClose={() => setEditId(null)}
onSaved={() => runSearch(q)}
/>
)}
</>
)
}