feat: Zutaten im Listendialog dem BLS zuordnen
Mapping-Status pro Zutat, Suche zuerst Listen und eigene Foods. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
b37005be01
commit
a5adff6196
|
|
@ -8,7 +8,7 @@ Optionale Grundlage für verlässliche Nährwerte: offizieller Bundeslebensmitte
|
||||||
|
|
||||||
## Zuordnung (UX)
|
## 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)
|
## Listen / Kombinationen (Mitai) vs. Gerichte (Tandoor)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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/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`).
|
- `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.
|
- 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)
|
- 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**)
|
- `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).
|
- `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).
|
||||||
|
|
|
||||||
|
|
@ -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“.
|
- **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.
|
- **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.
|
- **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
|
- **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.
|
- **Doku:** `.claude/docs/functional/BLS_FOOD_REFERENCE.md`, `.claude/docs/technical/BLS_FOOD_REFERENCE.md`, `docs/issues/issue-bls-food-mapping.md`. Folge #75.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,36 @@ def link_recipes_to_items(cur, profile_id: str) -> tuple[int, list[str]]:
|
||||||
return linked, list(dates)
|
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]]:
|
def list_recipes(cur, profile_id: str, with_ingredients: bool = True) -> list[dict[str, Any]]:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -377,6 +377,17 @@ def list_unmapped_foods(
|
||||||
return list_unmapped_payload(get_cursor(conn), pid, since_days, count_only, meta)
|
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")
|
@router.get("/recipes")
|
||||||
def list_food_recipes(
|
def list_food_recipes(
|
||||||
brief: bool = False,
|
brief: bool = False,
|
||||||
|
|
|
||||||
|
|
@ -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():
|
def test_save_recipe_requires_name():
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ MODULE_VERSIONS = {
|
||||||
"circumference": "1.0.1",
|
"circumference": "1.0.1",
|
||||||
"caliper": "1.0.1",
|
"caliper": "1.0.1",
|
||||||
"activity": "1.2.1", # Legacy CSV import: activity_entries feature enforcement
|
"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)
|
"tandoor": "1.0.1", # Router-Import ohne future-annotations (Backend startet)
|
||||||
"bls": "1.0.5", # GET /bls/units
|
"bls": "1.0.5", # GET /bls/units
|
||||||
"photos": "1.0.0",
|
"photos": "1.0.0",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { api } from '../utils/api'
|
import { api } from '../utils/api'
|
||||||
|
import FoodSearchModal from './FoodSearchModal'
|
||||||
|
|
||||||
const FALLBACK_UNITS = [
|
const FALLBACK_UNITS = [
|
||||||
{ id: 'g', label: 'g', default_g: 1, needs_unit_map: false },
|
{ id: 'g', label: 'g', default_g: 1, needs_unit_map: false },
|
||||||
|
|
@ -73,10 +74,11 @@ function fromSavedIngredient(i, mapped) {
|
||||||
function mappingFor(name, learned) {
|
function mappingFor(name, learned) {
|
||||||
const n = (name || '').trim().toLowerCase()
|
const n = (name || '').trim().toLowerCase()
|
||||||
if (!n) return null
|
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) => {
|
return (learned || []).find((m) => {
|
||||||
const raw = (m.source_name_raw || '').toLowerCase()
|
const raw = (m.source_name_raw || '').toLowerCase()
|
||||||
const norm = (m.source_name_normalized || '').toLowerCase()
|
const norm = (m.source_name_normalized || '').toLowerCase()
|
||||||
return raw === n || norm === n
|
return raw === n || norm === n || raw === stripped || norm === stripped
|
||||||
}) || null
|
}) || null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -89,6 +91,15 @@ function filterLearned(term, learned) {
|
||||||
}).slice(0, 8)
|
}).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({
|
export default function FoodRecipeDialog({
|
||||||
title,
|
title,
|
||||||
defaultName,
|
defaultName,
|
||||||
|
|
@ -114,13 +125,48 @@ export default function FoodRecipeDialog({
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [localLearned, setLocalLearned] = useState(learned || [])
|
const [localLearned, setLocalLearned] = useState(learned || [])
|
||||||
|
const [knownNames, setKnownNames] = useState([])
|
||||||
|
const [suggestions, setSuggestions] = useState({})
|
||||||
|
const [mapFor, setMapFor] = useState(null)
|
||||||
const nameRef = useRef(null)
|
const nameRef = useRef(null)
|
||||||
const qtyRefs = useRef({})
|
const qtyRefs = useRef({})
|
||||||
const timer = useRef(null)
|
const timer = useRef(null)
|
||||||
const abortRef = useRef(null)
|
const abortRef = useRef(null)
|
||||||
const seqRef = useRef(0)
|
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(() => {
|
useEffect(() => {
|
||||||
api.listFoodUnits().then((list) => {
|
api.listFoodUnits().then((list) => {
|
||||||
|
|
@ -140,8 +186,13 @@ export default function FoodRecipeDialog({
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const mappedHits = filterLearned(q, localLearned)
|
const mappedHits = filterLearned(q, localLearned)
|
||||||
const mappedNames = new Set(mappedHits.map((m) => (m.food_name_de || '').toLowerCase()))
|
const listHits = filterKnownIngredients(q, knownNames).filter((n) => {
|
||||||
const catalogOnly = catalogHits.filter((h) => !mappedNames.has((h.name_de || '').toLowerCase()))
|
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 runCatalog = async (term) => {
|
||||||
const query = (term || '').trim()
|
const query = (term || '').trim()
|
||||||
|
|
@ -216,6 +267,41 @@ export default function FoodRecipeDialog({
|
||||||
mappedLabel: h.name_de,
|
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 addFreeText = () => {
|
||||||
const raw = q.trim()
|
const raw = q.trim()
|
||||||
if (!raw) return
|
if (!raw) return
|
||||||
|
|
@ -317,7 +403,7 @@ export default function FoodRecipeDialog({
|
||||||
|
|
||||||
<div style={{ overflowY: 'auto', padding: '12px 16px 16px', flex: 1 }}>
|
<div style={{ overflowY: 'auto', padding: '12px 16px 16px', flex: 1 }}>
|
||||||
<p style={{ fontSize: 13, color: 'var(--text2)', lineHeight: 1.5, margin: '0 0 12px' }}>
|
<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>
|
</p>
|
||||||
{error && <div style={{ color: 'var(--danger)', fontSize: 13, marginBottom: 10 }}>{error}</div>}
|
{error && <div style={{ color: 'var(--danger)', fontSize: 13, marginBottom: 10 }}>{error}</div>}
|
||||||
|
|
||||||
|
|
@ -352,6 +438,25 @@ export default function FoodRecipeDialog({
|
||||||
/>
|
/>
|
||||||
{showResults && (
|
{showResults && (
|
||||||
<div style={{ marginTop: 8, border: '1px solid var(--border)', borderRadius: 10, background: 'var(--surface2)' }}>
|
<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 && (
|
{mappedHits.length > 0 && (
|
||||||
<div style={{ padding: '8px 10px 4px', fontSize: 11, color: 'var(--text3)' }}>Bereits zugeordnet</div>
|
<div style={{ padding: '8px 10px 4px', fontSize: 11, color: 'var(--text3)' }}>Bereits zugeordnet</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -370,10 +475,10 @@ export default function FoodRecipeDialog({
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{searching && <p style={{ fontSize: 12, color: 'var(--text2)', padding: '6px 10px' }}>Katalog…</p>}
|
{searching && <p style={{ fontSize: 12, color: 'var(--text2)', padding: '6px 10px' }}>Katalog…</p>}
|
||||||
{catalogOnly.length > 0 && (
|
{userHits.length > 0 && (
|
||||||
<div style={{ padding: '8px 10px 4px', fontSize: 11, color: 'var(--text3)' }}>Katalog</div>
|
<div style={{ padding: '8px 10px 4px', fontSize: 11, color: 'var(--text3)' }}>Eigene Lebensmittel</div>
|
||||||
)}
|
)}
|
||||||
{catalogOnly.map((h) => (
|
{userHits.map((h) => (
|
||||||
<button
|
<button
|
||||||
key={h.id}
|
key={h.id}
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -383,10 +488,24 @@ export default function FoodRecipeDialog({
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
<strong style={{ display: 'block' }}>{h.name_de}</strong>
|
<strong style={{ display: 'block' }}>{h.name_de}</strong>
|
||||||
<span style={{ fontSize: 12, color: 'var(--text3)' }}>
|
<span style={{ fontSize: 12, color: 'var(--text3)' }}>{h.bls_code ? `BLS ${h.bls_code}` : 'manuell'}</span>
|
||||||
{h.bls_code ? `BLS ${h.bls_code}` : 'ohne Code'}
|
</span>
|
||||||
{h.catalog_kind !== 'official_bls' ? ' · manuell' : ''}
|
</button>
|
||||||
</span>
|
))}
|
||||||
|
{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>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|
@ -396,12 +515,19 @@ export default function FoodRecipeDialog({
|
||||||
</div>
|
</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 && (
|
{ings.length === 0 && (
|
||||||
<p style={{ fontSize: 13, color: 'var(--text3)', margin: 0 }}>Noch keine Zutaten — oben suchen und übernehmen.</p>
|
<p style={{ fontSize: 13, color: 'var(--text3)', margin: 0 }}>Noch keine Zutaten — oben suchen und übernehmen.</p>
|
||||||
)}
|
)}
|
||||||
{ings.map((ing, i) => {
|
{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 meta = unitMeta(units, ing.unit)
|
||||||
const approx = gramsHint(ing, units)
|
const approx = gramsHint(ing, units)
|
||||||
const needsFactor = Boolean(meta?.needs_unit_map) && !approx
|
const needsFactor = Boolean(meta?.needs_unit_map) && !approx
|
||||||
|
|
@ -456,9 +582,24 @@ export default function FoodRecipeDialog({
|
||||||
{' '}g
|
{' '}g
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
<div style={{ fontSize: 11, color: mapped ? 'var(--accent-dark)' : 'var(--text3)', marginTop: 4 }}>
|
<div style={{ fontSize: 12, marginTop: 6, color: mapped ? 'var(--accent-dark)' : 'var(--danger)' }}>
|
||||||
{approx != null ? `≈ ${String(approx).replace('.', ',')} g` : (needsFactor ? 'Gramm pro Einheit fehlt — später umrechenbar' : '')}
|
{mapped
|
||||||
{mapped ? ` · zugeordnet: ${mapped.food_name_de || ing.mappedLabel}` : ' · noch offen — nach dem Speichern in der Liste zuordnen'}
|
? `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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -472,6 +613,16 @@ export default function FoodRecipeDialog({
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ function detectUnit(...texts) {
|
||||||
return null
|
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 [q, setQ] = useState(initialQuery || '')
|
||||||
const [hits, setHits] = useState([])
|
const [hits, setHits] = useState([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
@ -130,7 +130,7 @@ export default function FoodSearchModal({ title, initialQuery, quantityHint, all
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)',
|
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,
|
padding: 16,
|
||||||
}}
|
}}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
|
|
|
||||||
|
|
@ -246,6 +246,7 @@ export const api = {
|
||||||
listUnmappedFoods: (sinceDays=0, meta=false) => req(`/nutrition/unmapped?since_days=${sinceDays || 0}${meta ? '&meta=true' : ''}`),
|
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}`),
|
listUnmappedFoodCount: (sinceDays=28) => req(`/nutrition/unmapped?count_only=true&since_days=${sinceDays || 0}`),
|
||||||
listNutritionRecipes: (brief=false) => req(`/nutrition/recipes${brief ? '?brief=true' : ''}`),
|
listNutritionRecipes: (brief=false) => req(`/nutrition/recipes${brief ? '?brief=true' : ''}`),
|
||||||
|
listNutritionIngredientNames: () => req('/nutrition/ingredient-names'),
|
||||||
getNutritionRecipe: (id) => req(`/nutrition/recipes/${id}`),
|
getNutritionRecipe: (id) => req(`/nutrition/recipes/${id}`),
|
||||||
createNutritionRecipe: (d) => req('/nutrition/recipes', json(d)),
|
createNutritionRecipe: (d) => req('/nutrition/recipes', json(d)),
|
||||||
updateNutritionRecipe: (id, d) => req(`/nutrition/recipes/${id}`, jput(d)),
|
updateNutritionRecipe: (id, d) => req(`/nutrition/recipes/${id}`, jput(d)),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user