feat: Zutaten im Listendialog dem BLS zuordnen
All checks were successful
Deploy Development / deploy (push) Successful in 59s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 23s

Mapping-Status pro Zutat, Suche zuerst Listen und eigene Foods.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-09-13 14:38:42 +02:00
parent b37005be01
commit a5adff6196
10 changed files with 237 additions and 23 deletions

View File

@ -8,7 +8,7 @@ Optionale Grundlage für verlässliche Nährwerte: offizieller Bundeslebensmitte
## Zuordnung (UX)
Offene Zuordnungen zeigen **Vorschläge in der Zeile** (z. B. Haferflocken → Hafer Flocken); Bestätigen ohne Dialog. Mehrere nahe Treffer werden gekennzeichnet. Fehlt ein Treffer, **Lebensmittel oder Liste**. Ein Lebensmittel wird ein Katalogeintrag. Eine **Liste / Kombination** öffnet den Dialog: Zutaten per Suche aus bereits gemappten Namen oder dem Katalog (Fettgehalt wie Joghurt 10 %). Jede Zutat hat **Menge + Einheit** (g, ml, EL, TL, Prise, Stück, …). Standardeinheit für Nährwerte ist Gramm; Haushaltsmaße werden umgerechnet (EL 15 g, TL 5 g, Prise 0,3 g, oder Gramm pro Stück am Mapping). Fehlt der Faktor, bleibt die Einheit erhalten und ist später umrechenbar. Katalogtreffer werden beim Speichern zugeordnet. Freitext-Zutaten bleiben offen und erscheinen in der Offene-Liste (Kennzeichnung „Listenzutat“) — dort nur als Lebensmittel zuordenbar. Nicht-Gramm-Einheiten (Stück, EL, TL, …) bekommen ein **Gramm-pro-Einheit**-Feld am Mapping. Vorschläge und Katalogsuche laufen nur für die sichtbare Arbeit. Die Offene-Liste startet bei den **letzten 4 Wochen**; ältere Namen bleiben unter „Alle“. Offene Listenzutaten zählen mit dem Datum der letzten Listen-Nutzung oder der letzten Listenänderung — nicht nur mit einem Tagebuch-Datum der Zutat selbst. FDDB-Listenimport speichert zuerst; Tagesnährwerte folgen im Hintergrund. Zutatenlisten liegen im Tab **Listen** (sichtbar, durchsuchbar, bearbeitbar). „Andere Liste wählen“ verknüpft eine Tagebuchzeile mit einer bestehenden Liste; der Dialog zeigt alle Listen, nicht nur Namens-Treffer. Nach dem Verknüpfen verschwindet die Zeile unter Zuordnen — Kennzeichnung „Liste: …“, solange sie noch offen ist.
Offene Zuordnungen zeigen **Vorschläge in der Zeile** (z. B. Haferflocken → Hafer Flocken); Bestätigen ohne Dialog. Mehrere nahe Treffer werden gekennzeichnet. Fehlt ein Treffer, **Lebensmittel oder Liste**. Ein Lebensmittel wird ein Katalogeintrag. Eine **Liste / Kombination** öffnet den Dialog: Zutaten per Suche aus bereits gemappten Namen oder dem Katalog (Fettgehalt wie Joghurt 10 %). Jede Zutat hat **Menge + Einheit** (g, ml, EL, TL, Prise, Stück, …). Standardeinheit für Nährwerte ist Gramm; Haushaltsmaße werden umgerechnet (EL 15 g, TL 5 g, Prise 0,3 g, oder Gramm pro Stück am Mapping). Fehlt der Faktor, bleibt die Einheit erhalten und ist später umrechenbar. Katalogtreffer werden beim Speichern zugeordnet. Freitext-Zutaten bleiben offen und erscheinen in der Offene-Liste (Kennzeichnung „Listenzutat“) — dort nur als Lebensmittel zuordenbar. Nicht-Gramm-Einheiten (Stück, EL, TL, …) bekommen ein **Gramm-pro-Einheit**-Feld am Mapping. Vorschläge und Katalogsuche laufen nur für die sichtbare Arbeit. Die Offene-Liste startet bei den **letzten 4 Wochen**; ältere Namen bleiben unter „Alle“. Offene Listenzutaten zählen mit dem Datum der letzten Listen-Nutzung oder der letzten Listenänderung — nicht nur mit einem Tagebuch-Datum der Zutat selbst. FDDB-Listenimport speichert zuerst; Tagesnährwerte folgen im Hintergrund. Zutatenlisten liegen im Tab **Listen** (sichtbar, durchsuchbar, bearbeitbar). Im Listendialog sieht man pro Zutat, ob ein Mapping fehlt, und kann dort bestätigen oder suchen — zusätzlich zum Tab Zuordnen. Die Zutatensuche zeigt zuerst Namen aus importierten/angelegten Listen, dann eigene Lebensmittel, dann den BLS. „Andere Liste wählen“ verknüpft eine Tagebuchzeile mit einer bestehenden Liste; der Dialog zeigt alle Listen, nicht nur Namens-Treffer. Nach dem Verknüpfen verschwindet die Zeile unter Zuordnen — Kennzeichnung „Liste: …“, solange sie noch offen ist.
## Listen / Kombinationen (Mitai) vs. Gerichte (Tandoor)

View File

@ -42,6 +42,7 @@ FDDB: Items persistieren; `nutrition_log` nur bei leerem Tag oder laut Policy /
- `GET /nutrition/unmapped?since_days=28` — nur Namen mit `last_date` im Fenster; `count_only` liefert `{count, total, since_days}`; `meta=true` liefert `{items, count, total, since_days}`. Listenzutaten-Datum: letzte `recipe_id`-Nutzung oder `food_recipes.updated_at`/`created_at` — kein Join über den Listennamen auf alle Tagebuchzeilen. Migration **068**: Partial-Index unmapped-open + `(profile_id, recipe_id, date)`.
- `GET /nutrition/recipes?brief=true` — Listen ohne Zutaten, nur `ingredient_count`. `GET /nutrition/recipes/{id}` lädt eine Liste. FDDB-Listenimport antwortet nach Upsert; `rebuild_daily_nutrients` im Hintergrund (`rebuild_pending`).
- Listen-Verknüpfung (`apply` / `link_recipes_to_items`) matcht über `names_match_list` (Mengen-Prefix in gespeichertem `source_name_normalized` zählt). Unmapped-Zeilen bekommen `matching_recipe_id` + `matching_recipe_name`. Frontend: Tab Ernährung → Listen.
- `GET /nutrition/ingredient-names` — Distinct-Zutatennamen aller Listen inkl. Mapping-Status. Listendialog mappt manuell (`upsertMyFoodMapping`) an der Zeile.
- Mapping-Schreiben und Nährwert-Rebuild sind getrennte Transaktionen; Rebuild läuft nach der API-Antwort im Hintergrund (UI bleibt bedienbar)
- `food_name_mappings.grams_per_unit` / `source_unit` (Migration **064**)
- `GET/POST /api/nutrition/food-knowledge` — portable JSON (`mitai-food-knowledge` v1): manuelle Foods, Mappings (über `bls_code` / Name, keine UUIDs), Listen. Import löst Katalog auf dem Zielsystem auf (BLS muss dort importiert sein).

View File

@ -126,7 +126,7 @@ frontend/src/
- **Zuordnen-Zeitraum:** Standard letzte 4 Wochen (`since_days`); ältere ungemappte Namen (z. B. Getreide nach Glutenverzicht) bleiben unter „Alle“.
- **Katalogsuche:** Fettgehalt mitsuchen (`Joghurt 10%` / `9,5`); Dezimal-Komma bleibt erhalten.
- **Manuelle Foods:** Stoffe über EAV (`GET /bls/attributes`, `attributes` + `serving_g` beim Anlegen). Supplemente wie Norsan: EPA/DHA aus Etikett, Portionsgramm → Speicherung /100 g.
- **Listen / Kombinationen (Mitai):** Tab **Listen** (anlegen, bearbeiten, FDDB-Import). Tagebuchzeile per Name oder „Andere Liste wählen“ verknüpfen; Eintrag verschwindet danach, Zutaten bleiben offen. **Tandoor:** User-Einstellungen URL + Token, Verbindungstest (`/api/tandoor/*`). Gerichte bleiben in Tandoor; später Mapping + `cooked_yield_g`. Admin-Mappings: `PUT /admin/food-mappings/{id}`.
- **Listen / Kombinationen (Mitai):** Tab **Listen** (anlegen, bearbeiten, FDDB-Import). Im Listendialog Zutaten direkt BLS-zuordnen (Status offen/zugeordnet); Suche zuerst Listen-Namen und eigene Foods, dann BLS. **Tandoor:** User-Einstellungen URL + Token, Verbindungstest (`/api/tandoor/*`). Gerichte bleiben in Tandoor; später Mapping + `cooked_yield_g`. Admin-Mappings: `PUT /admin/food-mappings/{id}`.
- **Gitea #106:** BLS-Stammdaten, FDDB-Mapping, Item-Tagebuch — http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/106
- **Doku:** `.claude/docs/functional/BLS_FOOD_REFERENCE.md`, `.claude/docs/technical/BLS_FOOD_REFERENCE.md`, `docs/issues/issue-bls-food-mapping.md`. Folge #75.

View File

@ -146,6 +146,36 @@ def link_recipes_to_items(cur, profile_id: str) -> tuple[int, list[str]]:
return linked, list(dates)
def list_known_ingredient_names(cur, profile_id: str, limit: int = 2000) -> list[dict[str, Any]]:
"""Distinct list-ingredient names, with mapping if already learned."""
cur.execute(
"""
SELECT i.source_name_raw, i.source_name_normalized,
MIN(m.food_id::text) AS food_id,
MIN(f.name_de) AS food_name_de,
MIN(f.bls_code) AS bls_code,
BOOL_OR(m.id IS NOT NULL) AS mapped
FROM food_recipe_ingredients i
JOIN food_recipes r ON r.id = i.recipe_id
LEFT JOIN food_name_mappings m
ON m.profile_id = r.profile_id
AND m.source_name_normalized = i.source_name_normalized
LEFT JOIN food_catalog f ON f.id = m.food_id
WHERE r.profile_id = %s
GROUP BY i.source_name_raw, i.source_name_normalized
ORDER BY i.source_name_normalized
LIMIT %s
""",
(profile_id, limit),
)
out = []
for row in cur.fetchall():
rec = dict(row)
rec["mapped"] = bool(rec.get("mapped"))
out.append(rec)
return out
def list_recipes(cur, profile_id: str, with_ingredients: bool = True) -> list[dict[str, Any]]:
cur.execute(
"""

View File

@ -377,6 +377,17 @@ def list_unmapped_foods(
return list_unmapped_payload(get_cursor(conn), pid, since_days, count_only, meta)
@router.get("/ingredient-names")
def list_food_ingredient_names(
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth),
):
from data_layer.food_recipes import list_known_ingredient_names
pid = get_pid(x_profile_id)
with get_db() as conn:
return list_known_ingredient_names(get_cursor(conn), pid)
@router.get("/recipes")
def list_food_recipes(
brief: bool = False,

View File

@ -1,4 +1,24 @@
from data_layer.food_recipes import save_recipe
from data_layer.food_recipes import list_known_ingredient_names, save_recipe
class _FakeCur:
def __init__(self, rows):
self.rows = rows
def execute(self, sql, params=None):
return None
def fetchall(self):
return self.rows
def test_known_ingredient_names_flags_mapping():
rows = list_known_ingredient_names(_FakeCur([
{"source_name_raw": "Hafer", "source_name_normalized": "hafer", "food_id": "1", "food_name_de": "Haferflocken", "bls_code": "B123", "mapped": True},
{"source_name_raw": "Salz", "source_name_normalized": "salz", "food_id": None, "food_name_de": None, "bls_code": None, "mapped": False},
]), "p")
assert rows[0]["mapped"] is True
assert rows[1]["mapped"] is False
def test_save_recipe_requires_name():

View File

@ -20,7 +20,7 @@ MODULE_VERSIONS = {
"circumference": "1.0.1",
"caliper": "1.0.1",
"activity": "1.2.1", # Legacy CSV import: activity_entries feature enforcement
"nutrition": "1.3.4", # Listen-Tab; Verknüpfen trotz Mengen-Prefix; Liste wählen ohne Vorfilter
"nutrition": "1.3.5", # Listen-Dialog: Mapping an der Zutat; Suche Listen/eigene vor BLS
"tandoor": "1.0.1", # Router-Import ohne future-annotations (Backend startet)
"bls": "1.0.5", # GET /bls/units
"photos": "1.0.0",

View File

@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react'
import { api } from '../utils/api'
import FoodSearchModal from './FoodSearchModal'
const FALLBACK_UNITS = [
{ id: 'g', label: 'g', default_g: 1, needs_unit_map: false },
@ -73,10 +74,11 @@ function fromSavedIngredient(i, mapped) {
function mappingFor(name, learned) {
const n = (name || '').trim().toLowerCase()
if (!n) return null
const stripped = n.replace(/^\d+(?:[.,]\d+)?\s*(?:g|kg|ml|l|stück|stk)?\s*/i, '').trim()
return (learned || []).find((m) => {
const raw = (m.source_name_raw || '').toLowerCase()
const norm = (m.source_name_normalized || '').toLowerCase()
return raw === n || norm === n
return raw === n || norm === n || raw === stripped || norm === stripped
}) || null
}
@ -89,6 +91,15 @@ function filterLearned(term, learned) {
}).slice(0, 8)
}
function filterKnownIngredients(term, names) {
const t = (term || '').trim().toLowerCase()
if (t.length < 2) return []
return (names || []).filter((n) => {
const hay = `${n.source_name_raw || ''} ${n.source_name_normalized || ''} ${n.food_name_de || ''}`.toLowerCase()
return hay.includes(t)
}).slice(0, 8)
}
export default function FoodRecipeDialog({
title,
defaultName,
@ -114,13 +125,48 @@ export default function FoodRecipeDialog({
const [error, setError] = useState(null)
const [saving, setSaving] = useState(false)
const [localLearned, setLocalLearned] = useState(learned || [])
const [knownNames, setKnownNames] = useState([])
const [suggestions, setSuggestions] = useState({})
const [mapFor, setMapFor] = useState(null)
const nameRef = useRef(null)
const qtyRefs = useRef({})
const timer = useRef(null)
const abortRef = useRef(null)
const seqRef = useRef(0)
useEffect(() => { setLocalLearned(learned || []) }, [learned])
useEffect(() => {
setLocalLearned(learned || [])
setIngs((list) => list.map((ing) => {
const m = mappingFor(ing.source_name_raw, learned)
if (!m) return ing
return {
...ing,
foodId: ing.foodId || m.food_id,
mappedLabel: ing.mappedLabel || m.food_name_de,
gramsPerUnit: ing.gramsPerUnit || m.grams_per_unit || '',
}
}))
}, [learned])
useEffect(() => {
api.listNutritionIngredientNames().then((rows) => {
setKnownNames(Array.isArray(rows) ? rows : [])
}).catch(() => {})
}, [])
const unmappedKey = ings
.filter((i) => i.source_name_raw && !mappingFor(i.source_name_raw, localLearned) && !i.foodId)
.map((i) => i.source_name_raw)
.join('\n')
useEffect(() => {
const names = unmappedKey ? unmappedKey.split('\n') : []
if (!names.length) return
api.suggestFoodsBatch(names, 3).then((packed) => {
if (!packed || typeof packed !== 'object') return
setSuggestions((prev) => ({ ...prev, ...packed }))
}).catch(() => {})
}, [unmappedKey])
useEffect(() => {
api.listFoodUnits().then((list) => {
@ -140,8 +186,13 @@ export default function FoodRecipeDialog({
}, [])
const mappedHits = filterLearned(q, localLearned)
const mappedNames = new Set(mappedHits.map((m) => (m.food_name_de || '').toLowerCase()))
const catalogOnly = catalogHits.filter((h) => !mappedNames.has((h.name_de || '').toLowerCase()))
const listHits = filterKnownIngredients(q, knownNames).filter((n) => {
const raw = (n.source_name_raw || '').toLowerCase()
return !mappedHits.some((m) => (m.source_name_raw || '').toLowerCase() === raw)
})
const mappedFoodNames = new Set(mappedHits.map((m) => (m.food_name_de || '').toLowerCase()))
const userHits = catalogHits.filter((h) => h.catalog_kind && h.catalog_kind !== 'official_bls')
const blsHits = catalogHits.filter((h) => (!h.catalog_kind || h.catalog_kind === 'official_bls') && !mappedFoodNames.has((h.name_de || '').toLowerCase()))
const runCatalog = async (term) => {
const query = (term || '').trim()
@ -216,6 +267,41 @@ export default function FoodRecipeDialog({
mappedLabel: h.name_de,
})
const addKnown = (n) => addIngredient({
source_name_raw: n.source_name_raw,
foodId: n.food_id || null,
mappedLabel: n.food_name_de || '',
})
const rememberMapping = (sourceName, food, extras = {}) => {
setLocalLearned((list) => [{
id: food.mapping_id,
source_name_raw: sourceName,
source_name_normalized: food.source_name_normalized,
food_id: food.id || food.food_id,
food_name_de: food.name_de || food.food_name_de,
bls_code: food.bls_code,
grams_per_unit: extras.grams_per_unit,
source_unit: extras.source_unit,
}, ...list.filter((m) => (m.source_name_raw || '').toLowerCase() !== sourceName.toLowerCase())])
setIngs((list) => list.map((x) => (
(x.source_name_raw || '').toLowerCase() === sourceName.toLowerCase()
? { ...x, foodId: food.id || food.food_id, mappedLabel: food.name_de || food.food_name_de || '' }
: x
)))
}
const assignIngredient = async (sourceName, food, extras = {}) => {
const res = await api.upsertMyFoodMapping({
source_name: sourceName,
food_id: food.id || food.food_id,
grams_per_unit: extras.grams_per_unit || null,
source_unit: extras.source_unit || null,
})
rememberMapping(sourceName, { ...food, ...res }, extras)
setMapFor(null)
}
const addFreeText = () => {
const raw = q.trim()
if (!raw) return
@ -317,7 +403,7 @@ export default function FoodRecipeDialog({
<div style={{ overflowY: 'auto', padding: '12px 16px 16px', flex: 1 }}>
<p style={{ fontSize: 13, color: 'var(--text2)', lineHeight: 1.5, margin: '0 0 12px' }}>
Roh-Kombination ohne Kochen (z. B. Müsli). Zutaten suchen, Menge und Einheit angeben. Gramm ist die Standardeinheit EL, TL, Prise usw. werden umgerechnet, sobald der Faktor bekannt ist. Gekochte Gerichte bleiben in Tandoor.
Roh-Kombination ohne Kochen (z. B. Müsli). Zuerst Namen aus deinen Listen, dann eigene Lebensmittel, dann BLS. Jede Zutat kannst du hier dem Katalog zuordnen offene Zutaten bleiben rot markiert.
</p>
{error && <div style={{ color: 'var(--danger)', fontSize: 13, marginBottom: 10 }}>{error}</div>}
@ -352,6 +438,25 @@ export default function FoodRecipeDialog({
/>
{showResults && (
<div style={{ marginTop: 8, border: '1px solid var(--border)', borderRadius: 10, background: 'var(--surface2)' }}>
{listHits.length > 0 && (
<div style={{ padding: '8px 10px 4px', fontSize: 11, color: 'var(--text3)' }}>Aus deinen Listen</div>
)}
{listHits.map((n) => (
<button
key={`list-${n.source_name_normalized || n.source_name_raw}`}
type="button"
className="btn btn-secondary btn-full"
style={{ margin: '0 8px 8px', justifyContent: 'flex-start', textAlign: 'left', height: 'auto', padding: '8px 10px' }}
onClick={() => addKnown(n)}
>
<span>
<strong style={{ display: 'block' }}>{n.source_name_raw}</strong>
<span style={{ fontSize: 12, color: 'var(--text3)' }}>
{n.mapped ? `${n.food_name_de}${n.bls_code ? ` · ${n.bls_code}` : ''}` : 'noch ohne Mapping'}
</span>
</span>
</button>
))}
{mappedHits.length > 0 && (
<div style={{ padding: '8px 10px 4px', fontSize: 11, color: 'var(--text3)' }}>Bereits zugeordnet</div>
)}
@ -370,10 +475,10 @@ export default function FoodRecipeDialog({
</button>
))}
{searching && <p style={{ fontSize: 12, color: 'var(--text2)', padding: '6px 10px' }}>Katalog</p>}
{catalogOnly.length > 0 && (
<div style={{ padding: '8px 10px 4px', fontSize: 11, color: 'var(--text3)' }}>Katalog</div>
{userHits.length > 0 && (
<div style={{ padding: '8px 10px 4px', fontSize: 11, color: 'var(--text3)' }}>Eigene Lebensmittel</div>
)}
{catalogOnly.map((h) => (
{userHits.map((h) => (
<button
key={h.id}
type="button"
@ -383,10 +488,24 @@ export default function FoodRecipeDialog({
>
<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.catalog_kind !== 'official_bls' ? ' · manuell' : ''}
<span style={{ fontSize: 12, color: 'var(--text3)' }}>{h.bls_code ? `BLS ${h.bls_code}` : 'manuell'}</span>
</span>
</button>
))}
{blsHits.length > 0 && (
<div style={{ padding: '8px 10px 4px', fontSize: 11, color: 'var(--text3)' }}>BLS-Katalog</div>
)}
{blsHits.map((h) => (
<button
key={h.id}
type="button"
className="btn btn-secondary btn-full"
style={{ margin: '0 8px 8px', justifyContent: 'flex-start', textAlign: 'left', height: 'auto', padding: '8px 10px' }}
onClick={() => addCatalog(h)}
>
<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'}</span>
</span>
</button>
))}
@ -396,12 +515,19 @@ export default function FoodRecipeDialog({
</div>
)}
<p style={{ fontSize: 13, fontWeight: 600, margin: '16px 0 8px' }}>Zutaten ({ings.length})</p>
<p style={{ fontSize: 13, fontWeight: 600, margin: '16px 0 8px' }}>
Zutaten ({ings.length}
{ings.filter((i) => !mappingFor(i.source_name_raw, localLearned) && !i.foodId).length
? ` · ${ings.filter((i) => !mappingFor(i.source_name_raw, localLearned) && !i.foodId).length} ohne Mapping`
: ings.length ? ' · alle zugeordnet' : ''})
</p>
{ings.length === 0 && (
<p style={{ fontSize: 13, color: 'var(--text3)', margin: 0 }}>Noch keine Zutaten oben suchen und übernehmen.</p>
)}
{ings.map((ing, i) => {
const mapped = mappingFor(ing.source_name_raw, localLearned) || (ing.foodId ? { food_name_de: ing.mappedLabel } : null)
const mapped = mappingFor(ing.source_name_raw, localLearned) || (ing.foodId ? { food_name_de: ing.mappedLabel, food_id: ing.foodId } : null)
const packed = suggestions[ing.source_name_raw] || {}
const hints = packed.suggestions || []
const meta = unitMeta(units, ing.unit)
const approx = gramsHint(ing, units)
const needsFactor = Boolean(meta?.needs_unit_map) && !approx
@ -456,9 +582,24 @@ export default function FoodRecipeDialog({
{' '}g
</label>
)}
<div style={{ fontSize: 11, color: mapped ? 'var(--accent-dark)' : 'var(--text3)', marginTop: 4 }}>
{approx != null ? `${String(approx).replace('.', ',')} g` : (needsFactor ? 'Gramm pro Einheit fehlt — später umrechenbar' : '')}
{mapped ? ` · zugeordnet: ${mapped.food_name_de || ing.mappedLabel}` : ' · noch offen — nach dem Speichern in der Liste zuordnen'}
<div style={{ fontSize: 12, marginTop: 6, color: mapped ? 'var(--accent-dark)' : 'var(--danger)' }}>
{mapped
? `Zugeordnet: ${mapped.food_name_de || ing.mappedLabel}${mapped.bls_code ? ` · ${mapped.bls_code}` : ''}`
: 'Noch kein Mapping'}
{approx != null ? ` · ≈ ${String(approx).replace('.', ',')} g` : (needsFactor ? ' · Gramm pro Einheit fehlt' : '')}
</div>
{!mapped && hints[0] && (
<div style={{ marginTop: 6, display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center' }}>
<span style={{ fontSize: 12 }}> {hints[0].name_de}{hints[0].bls_code ? ` · ${hints[0].bls_code}` : ''}</span>
<button type="button" className="btn btn-primary" style={{ fontSize: 12 }} disabled={saving || disabled} onClick={() => assignIngredient(ing.source_name_raw, hints[0]).catch((e) => setError(e.message))}>
Bestätigen
</button>
</div>
)}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 6 }}>
<button type="button" className="btn btn-secondary" style={{ fontSize: 12 }} disabled={saving || disabled} onClick={() => setMapFor(ing)}>
{mapped ? 'Mapping ändern' : 'BLS zuordnen'}
</button>
</div>
</div>
)
@ -472,6 +613,16 @@ export default function FoodRecipeDialog({
</button>
</div>
</div>
{mapFor && (
<FoodSearchModal
zIndex={22000}
title={`Zuordnen: ${mapFor.source_name_raw}`}
initialQuery={mapFor.source_name_raw}
quantityHint={`${mapFor.amount || ''} ${mapFor.unit || ''}`.trim()}
onClose={() => setMapFor(null)}
onSelect={(food, extras) => assignIngredient(mapFor.source_name_raw, food, extras).catch((e) => setError(e.message))}
/>
)}
</div>
)
}

View File

@ -25,7 +25,7 @@ function detectUnit(...texts) {
return null
}
export default function FoodSearchModal({ title, initialQuery, quantityHint, allowRecipe, onSelect, onWantRecipe, onClose }) {
export default function FoodSearchModal({ title, initialQuery, quantityHint, allowRecipe, onSelect, onWantRecipe, onClose, zIndex }) {
const [q, setQ] = useState(initialQuery || '')
const [hits, setHits] = useState([])
const [loading, setLoading] = useState(false)
@ -130,7 +130,7 @@ export default function FoodSearchModal({ title, initialQuery, quantityHint, all
<div
style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)',
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 20000,
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: zIndex || 20000,
padding: 16,
}}
onClick={onClose}

View File

@ -246,6 +246,7 @@ export const api = {
listUnmappedFoods: (sinceDays=0, meta=false) => req(`/nutrition/unmapped?since_days=${sinceDays || 0}${meta ? '&meta=true' : ''}`),
listUnmappedFoodCount: (sinceDays=28) => req(`/nutrition/unmapped?count_only=true&since_days=${sinceDays || 0}`),
listNutritionRecipes: (brief=false) => req(`/nutrition/recipes${brief ? '?brief=true' : ''}`),
listNutritionIngredientNames: () => req('/nutrition/ingredient-names'),
getNutritionRecipe: (id) => req(`/nutrition/recipes/${id}`),
createNutritionRecipe: (d) => req('/nutrition/recipes', json(d)),
updateNutritionRecipe: (id, d) => req(`/nutrition/recipes/${id}`, jput(d)),