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>
291 lines
11 KiB
JavaScript
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)}
|
|
/>
|
|
)}
|
|
</>
|
|
)
|
|
}
|