feat: Listen-Dialog mit Mengen/Einheiten, Mitai ohne Kochrezepte
Roh-Kombinationen statt Rezept-Sprache; Zutaten suchen, EL/TL/Prise auf Gramm. Gekochte Gerichte und Ausbeute bleiben Tandoor. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
a7e5511039
commit
fa542e0fb9
|
|
@ -8,11 +8,27 @@ 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 Rezept** in derselben Zeile (oder im Such-Popup). Ein Lebensmittel wird ein Katalogeintrag. Ein Rezept bekommt Zutaten (Name + Gramm): schon zugeordnete Namen werden übernommen, offene Zutaten erscheinen danach in der Offene-Liste (Kennzeichnung „Rezeptzutat“) und lassen sich dort nur als Lebensmittel zuordnen. 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, nicht über alle offenen Namen auf einmal. Die Offene-Liste startet bei den **letzten 4 Wochen**; ältere Namen (z. B. Getreide nach Glutenverzicht) bleiben unter „Alle“ und müssen nicht gemappt werden.
|
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“.
|
||||||
|
|
||||||
## FDDB-Listen / eigene Rezepte
|
## Listen / Kombinationen (Mitai) vs. Gerichte (Tandoor)
|
||||||
|
|
||||||
FDDB-Tagebuchexport fasst selbst angelegte Listen oft zu **einer Zeile** (Rezeptname + Menge) zusammen. Die Zutaten stehen in einem **separaten Listen-Export** (`lists_*.csv`, Spalte `produkte`). Ablauf: Listen importieren → passende Tagebuchzeilen werden als Rezept verknüpft → **Zutaten** zuordnen, nicht das Rezept als Ganzes. Unvollständige Zutaten-Mappings fallen auf die FDDB-Makros der Tagebuchzeile zurück.
|
**Mitai speichert nur Roh-Kombinationen** — einen Namen für eine Zutatenliste ohne Kochvorgang (Müsli, Bowl, Smoothie). Summe der Zutatengramm = gegessene Gramm. Tabelle bleibt `food_recipes` (kein Rename).
|
||||||
|
|
||||||
|
**Gekochte Familienrezepte** liegen in **Tandoor**. Kein zweites Rezeptbuch in Mitai. Kochschwund (Wasserverlust beim Köcheln) darf die Rohzutaten nicht 1:1 auf die gegessene Menge skalieren.
|
||||||
|
|
||||||
|
Spätere Ausbeute (nicht in Phase 1):
|
||||||
|
|
||||||
|
- `ingredients_g` = Summe der auf Gramm aufgelösten Zutaten
|
||||||
|
- `cooked_yield_g` = gewogenes Fertiggewicht nach dem Kochen
|
||||||
|
- `yield_factor = cooked_yield_g / ingredients_g` (oft 0,5–0,9 bei langem Köcheln)
|
||||||
|
- Nährwerte **pro 100 g fertig** = `Summe(Zutat_Nährwerte) / cooked_yield_g × 100`
|
||||||
|
- Gegessen: `portion_g / 100 × Werte_pro_100g`
|
||||||
|
|
||||||
|
Ohne Fertiggewicht: nicht raten — FDDB-Makros oder Marke „unvollständig“. Für Listen gilt implizit `cooked_yield_g = ingredients_g` (Faktor 1). Tandoor liefert Zutaten und Schritte; die Ausbeute und die **pro-100-g**-Rechnung führt Mitai. Importiertes Gericht wird ein Katalogeintrag (`catalog_kind` z. B. `recipe_cooked`), keine zweite Wertetabelle.
|
||||||
|
|
||||||
|
## FDDB-Listen
|
||||||
|
|
||||||
|
FDDB-Tagebuchexport fasst selbst angelegte Listen oft zu **einer Zeile** (Listenname + Menge) zusammen. Die Zutaten stehen in einem **separaten Listen-Export** (`lists_*.csv`, Spalte `produkte`). Ablauf: Listen importieren → passende Tagebuchzeilen werden als Liste verknüpft → **Zutaten** zuordnen, nicht die Liste als Ganzes. Unvollständige Zutaten-Mappings fallen auf die FDDB-Makros der Tagebuchzeile zurück.
|
||||||
|
|
||||||
Gelernte Zuordnungen und Listen lassen sich als **JSON sichern** und auf einer anderen Instanz (Dev → Prod) wieder einspielen. Offizielle Lebensmittel werden über den BLS-Code gefunden — der BLS-Katalog muss auf dem Ziel bereits importiert sein.
|
Gelernte Zuordnungen und Listen lassen sich als **JSON sichern** und auf einer anderen Instanz (Dev → Prod) wieder einspielen. Offizielle Lebensmittel werden über den BLS-Code gefunden — der BLS-Katalog muss auf dem Ziel bereits importiert sein.
|
||||||
|
|
||||||
|
|
@ -35,3 +51,5 @@ Weitere Quellen (USDA, Schweizer Nährwertdatenbank) kommen später als zusätzl
|
||||||
## Später
|
## Später
|
||||||
|
|
||||||
Platzhalter (Registry) für Mikros, Esszeitpunkte (`logged_at`), Fasten; Bezug Gitea #106 (Grundlage) und #75 (Folge).
|
Platzhalter (Registry) für Mikros, Esszeitpunkte (`logged_at`), Fasten; Bezug Gitea #106 (Grundlage) und #75 (Folge).
|
||||||
|
|
||||||
|
**Tandoor:** Familienrezepte bleiben dort. Später: Import/Verknüpfung, Zutaten gegen Mitai-Mappings, `cooked_yield_g`, Katalogeintrag Fertiggericht pro 100 g. Kein Connector in Phase 1.
|
||||||
|
|
|
||||||
|
|
@ -11,13 +11,13 @@
|
||||||
- `nutrition_items` — Tagebuchzeilen inkl. `logged_at`
|
- `nutrition_items` — Tagebuchzeilen inkl. `logged_at`
|
||||||
- `nutrition_daily_nutrients` — Tages-Rollup numerischer Attribute
|
- `nutrition_daily_nutrients` — Tages-Rollup numerischer Attribute
|
||||||
- `nutrition_day_marks` — `fasting` | `incomplete`
|
- `nutrition_day_marks` — `fasting` | `incomplete`
|
||||||
- `food_recipes` / `food_recipe_ingredients` — FDDB-Listen; `nutrition_items.recipe_id`
|
- `food_recipes` / `food_recipe_ingredients` — Mitai-Listen/Kombinationen (Rohmischung, kein Kochschwund); `nutrition_items.recipe_id`. Kein Tabellen-Rename. Gekochte Gerichte später Tandoor + `cooked_yield_g`, nicht hier.
|
||||||
|
|
||||||
## Layer 1
|
## Layer 1
|
||||||
|
|
||||||
- `data_layer/food_mapping.py` — Normalisierung, Lookup, Learn, Apply, Delete
|
- `data_layer/food_mapping.py` — Normalisierung, Lookup, Learn, Apply, Delete
|
||||||
- `data_layer/nutrition_items.py` — Ingest, drei Makro-Summen, Policy, `resolve_*_attributes`
|
- `data_layer/nutrition_items.py` — Ingest, drei Makro-Summen, Policy, `resolve_*_attributes`
|
||||||
- `data_layer/food_recipes.py` — Listen-Upsert, Link auf Tagebuchzeilen, Rezept-Makros (Skala: gegessen_g / Summe Zutaten, sonst 1/Portionen)
|
- `data_layer/food_recipes.py` — Listen-Upsert, Link auf Tagebuchzeilen, Listen-Makros (Skala: gegessen_g / Summe Zutaten, sonst 1/Portionen). Diese Skala gilt nur für Roh-Kombinationen, nicht für gekochte Gerichte.
|
||||||
- `bls/recipe_parser.py` — `produkte`-Feld: Split nur vor nächstem `\d+ (g|kg|ml|l)` (Kommas im Namen bleiben)
|
- `bls/recipe_parser.py` — `produkte`-Feld: Split nur vor nächstem `\d+ (g|kg|ml|l)` (Kommas im Namen bleiben)
|
||||||
|
|
||||||
## Import
|
## Import
|
||||||
|
|
@ -35,8 +35,9 @@ FDDB: Items persistieren; `nutrition_log` nur bei leerem Tag oder laut Policy /
|
||||||
- `/api/nutrition/*` — Items, Unmapped, Bulk-Map, Marken, Konflikt-Resolve
|
- `/api/nutrition/*` — Items, Unmapped, Bulk-Map, Marken, Konflikt-Resolve
|
||||||
- `GET/POST /api/nutrition/recipes`, `PUT/DELETE /api/nutrition/recipes/{id}`, `POST …/import-fddb-lists`, `POST …/{id}/apply`
|
- `GET/POST /api/nutrition/recipes`, `PUT/DELETE /api/nutrition/recipes/{id}`, `POST …/import-fddb-lists`, `POST …/{id}/apply`
|
||||||
- `PUT /api/admin/food-mappings/{id}` — Ziel-Lebensmittel einer bestehenden Zuordnung wechseln
|
- `PUT /api/admin/food-mappings/{id}` — Ziel-Lebensmittel einer bestehenden Zuordnung wechseln
|
||||||
- Unmapped = Tagebuchzeilen ohne `food_id`/`recipe_id` **plus** Rezeptzutaten ohne Mapping
|
- Unmapped = Tagebuchzeilen ohne `food_id`/`recipe_id` **plus** Listenzutaten ohne Mapping
|
||||||
- Frontend: Inline-Vorschläge auf Zuordnen (`food_suggest.py`: Collapse-Key ohne Leerzeichen, Index 5 Min. Cache, Fett-%-Zahlen 9,5/10), `FoodSearchModal` nur noch Zusatzsuche (Abort + Debounce). **Neu anlegen** fragt zuerst Lebensmittel vs. Rezept; Rezept-Zutaten ohne Mapping erscheinen als `kind=recipe_ingredient` in Unmapped.
|
- Frontend: Inline-Vorschläge auf Zuordnen (`food_suggest.py`: Collapse-Key ohne Leerzeichen, Index 5 Min. Cache, Fett-%-Zahlen 9,5/10), `FoodSearchModal` nur noch Zusatzsuche (Abort + Debounce). Listen-UI: `FoodRecipeDialog` (Suche gelernt + Katalog; Katalogpick upsertet Mapping). Offene Zutaten: `kind=recipe_ingredient` (API-Wert unverändert, UI: Listenzutat). Tandoor-Import und `cooked_yield_g` später, kein Connector in Phase 1.
|
||||||
|
- Migration **066** — `food_recipe_ingredients.quantity_amount` + `source_unit`; `quantity_g` = Umrechnung (`resolve_quantity` in `food_mapping.py`). `GET /bls/units` = Einheitenkatalog (EL/TL/Prise/Stück, default_g).
|
||||||
- `GET /nutrition/unmapped?since_days=28` — nur Namen mit `last_date` im Fenster; `count_only` liefert `{count, total, since_days}`; `POST /bls/foods/suggest-batch` für sichtbare Zeilen (max. 80)
|
- `GET /nutrition/unmapped?since_days=28` — nur Namen mit `last_date` im Fenster; `count_only` liefert `{count, total, since_days}`; `POST /bls/foods/suggest-batch` für sichtbare Zeilen (max. 80)
|
||||||
- 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**)
|
||||||
|
|
|
||||||
|
|
@ -121,12 +121,12 @@ frontend/src/
|
||||||
|
|
||||||
- **Migration 062:** `food_catalog` (BLS-Code bleibt Identität), dynamische `food_attributes` + EAV, `food_name_mappings`, `nutrition_items`, `nutrition_daily_nutrients`, `nutrition_day_marks`, Import-Policy am Profil.
|
- **Migration 062:** `food_catalog` (BLS-Code bleibt Identität), dynamische `food_attributes` + EAV, `food_name_mappings`, `nutrition_items`, `nutrition_daily_nutrients`, `nutrition_day_marks`, Import-Policy am Profil.
|
||||||
- **Admin:** Gruppe Ernährung — BLS-Import, Katalog, Attribute, Mappings.
|
- **Admin:** Gruppe Ernährung — BLS-Import, Katalog, Attribute, Mappings.
|
||||||
- **Nutzer:** Einzelerfassung unverändert; Tab Zuordnen mit Namenssuche (Popup); FDDB-Listen/Rezepte; JSON-Export/Import der Zuordnungen; Fasten/Lücke; Import-Abgleich.
|
- **Nutzer:** Einzelerfassung unverändert; Tab Zuordnen mit Namenssuche (Popup); FDDB-Listen/Kombinationen; JSON-Export/Import der Zuordnungen; Fasten/Lücke; Import-Abgleich.
|
||||||
- **Zuordnen-Performance:** Katalog-Index im Prozess (5 Min.), Vorschläge nur für sichtbare Zeilen (`POST /bls/foods/suggest-batch`), Suche mit Abort; nach Bestätigen kein Reload der ganzen Ernährungseite.
|
- **Zuordnen-Performance:** Katalog-Index im Prozess (5 Min.), Vorschläge nur für sichtbare Zeilen (`POST /bls/foods/suggest-batch`), Suche mit Abort; nach Bestätigen kein Reload der ganzen Ernährungseite.
|
||||||
- **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.
|
||||||
- **Rezepte:** Nutzer-CRUD `POST/PUT/DELETE /nutrition/recipes`. Beim Zuordnen zuerst **Lebensmittel oder Rezept**; Rezept legt Zutaten an — bereits gemappte werden übernommen, offene erscheinen in der Liste. Admin-Mappings: `PUT /admin/food-mappings/{id}` zum direkten Wechsel.
|
- **Listen / Kombinationen (Mitai):** Dialog mit Zutatensuche (Mappings + Katalog). Menge + Einheit; Umrechnung auf Gramm. Rohmischung ohne Kochschwund. Gekochte Familienrezepte: Tandoor + später `cooked_yield_g` / Fertiggericht pro 100 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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -168,6 +168,8 @@ def export_food_knowledge(cur, profile_id: str) -> dict[str, Any]:
|
||||||
"source_name_normalized": ing.get("source_name_normalized"),
|
"source_name_normalized": ing.get("source_name_normalized"),
|
||||||
"quantity_raw": ing.get("quantity_raw"),
|
"quantity_raw": ing.get("quantity_raw"),
|
||||||
"quantity_g": _jsonable(ing.get("quantity_g")),
|
"quantity_g": _jsonable(ing.get("quantity_g")),
|
||||||
|
"quantity_amount": _jsonable(ing.get("quantity_amount")),
|
||||||
|
"source_unit": ing.get("source_unit"),
|
||||||
"sort_order": ing.get("sort_order") or 0,
|
"sort_order": ing.get("sort_order") or 0,
|
||||||
}
|
}
|
||||||
for ing in rec.get("ingredients") or []
|
for ing in rec.get("ingredients") or []
|
||||||
|
|
|
||||||
|
|
@ -132,6 +132,16 @@ UNIT_ALIASES = {
|
||||||
MASS_VOLUME_TO_G = {"g": 1.0, "kg": 1000.0, "ml": 1.0, "l": 1000.0}
|
MASS_VOLUME_TO_G = {"g": 1.0, "kg": 1000.0, "ml": 1.0, "l": 1000.0}
|
||||||
DEFAULT_UNIT_G = {"el": 15.0, "tl": 5.0, "prise": 0.3, "msp": 1.0}
|
DEFAULT_UNIT_G = {"el": 15.0, "tl": 5.0, "prise": 0.3, "msp": 1.0}
|
||||||
COUNT_UNITS = frozenset({"stück", "scheibe", "portion", "becher", "tasse"})
|
COUNT_UNITS = frozenset({"stück", "scheibe", "portion", "becher", "tasse"})
|
||||||
|
UNIT_LABELS = {
|
||||||
|
"g": "g", "kg": "kg", "ml": "ml", "l": "l",
|
||||||
|
"el": "EL", "tl": "TL", "prise": "Prise", "msp": "Msp.",
|
||||||
|
"stück": "Stück", "scheibe": "Scheibe", "portion": "Portion",
|
||||||
|
"becher": "Becher", "tasse": "Tasse",
|
||||||
|
}
|
||||||
|
UNIT_ORDER = (
|
||||||
|
"g", "ml", "el", "tl", "prise", "msp",
|
||||||
|
"stück", "scheibe", "portion", "becher", "tasse", "kg", "l",
|
||||||
|
)
|
||||||
QTY_PARSE_RE = re.compile(
|
QTY_PARSE_RE = re.compile(
|
||||||
r"^\s*(\d+(?:[.,]\d+)?)\s*([a-zA-ZäöüÄÖÜß.]+)?\s*$",
|
r"^\s*(\d+(?:[.,]\d+)?)\s*([a-zA-ZäöüÄÖÜß.]+)?\s*$",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
|
|
@ -186,6 +196,74 @@ def detect_quantity_unit(*texts: str | None) -> str | None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def format_quantity_raw(value: float | None, unit: str | None) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
label = UNIT_LABELS.get(unit or "g", unit or "g")
|
||||||
|
num = int(value) if float(value) == int(value) else value
|
||||||
|
return f"{num} {label}".replace(".", ",")
|
||||||
|
|
||||||
|
|
||||||
|
def list_quantity_units() -> list[dict[str, Any]]:
|
||||||
|
out = []
|
||||||
|
for uid in UNIT_ORDER:
|
||||||
|
default_g = MASS_VOLUME_TO_G.get(uid)
|
||||||
|
if default_g is None:
|
||||||
|
default_g = DEFAULT_UNIT_G.get(uid)
|
||||||
|
out.append({
|
||||||
|
"id": uid,
|
||||||
|
"label": UNIT_LABELS[uid],
|
||||||
|
"default_g": default_g,
|
||||||
|
"needs_unit_map": uid in COUNT_UNITS,
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_quantity(
|
||||||
|
*,
|
||||||
|
quantity_raw: str | None = None,
|
||||||
|
quantity_amount: float | None = None,
|
||||||
|
source_unit: str | None = None,
|
||||||
|
quantity_g: float | None = None,
|
||||||
|
grams_per_unit: float | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Amount + unit → quantity_g. Standard unit is grams; conversion uses mapping or defaults."""
|
||||||
|
amount = None
|
||||||
|
if quantity_amount not in (None, ""):
|
||||||
|
try:
|
||||||
|
amount = float(quantity_amount)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
amount = None
|
||||||
|
explicit_g = None
|
||||||
|
if quantity_g not in (None, ""):
|
||||||
|
try:
|
||||||
|
explicit_g = float(quantity_g)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
explicit_g = None
|
||||||
|
if amount is not None:
|
||||||
|
unit = _canon_unit(source_unit) or "g"
|
||||||
|
raw = format_quantity_raw(amount, unit)
|
||||||
|
parsed = parse_quantity(raw.replace(",", "."), grams_per_unit)
|
||||||
|
elif quantity_raw:
|
||||||
|
parsed = parse_quantity(quantity_raw, grams_per_unit)
|
||||||
|
elif explicit_g is not None:
|
||||||
|
parsed = {"value": explicit_g, "unit": "g", "quantity_g": explicit_g, "needs_unit_map": False}
|
||||||
|
else:
|
||||||
|
parsed = {"value": None, "unit": _canon_unit(source_unit), "quantity_g": None, "needs_unit_map": False}
|
||||||
|
qty_g = parsed.get("quantity_g")
|
||||||
|
if qty_g is None and explicit_g is not None:
|
||||||
|
qty_g = explicit_g
|
||||||
|
unit = parsed.get("unit")
|
||||||
|
value = parsed.get("value")
|
||||||
|
return {
|
||||||
|
"quantity_amount": value,
|
||||||
|
"source_unit": unit,
|
||||||
|
"quantity_raw": format_quantity_raw(value, unit) or (str(quantity_raw).strip() if quantity_raw else None),
|
||||||
|
"quantity_g": qty_g,
|
||||||
|
"needs_unit_map": bool(parsed.get("needs_unit_map")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_food_mapping_with_cursor(
|
def get_food_mapping_with_cursor(
|
||||||
cur,
|
cur,
|
||||||
source_name: str,
|
source_name: str,
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,32 @@ from __future__ import annotations
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from data_layer.food_mapping import get_food_mapping_with_cursor, normalize_food_name
|
from data_layer.food_mapping import (
|
||||||
|
get_food_mapping_with_cursor,
|
||||||
|
normalize_food_name,
|
||||||
|
resolve_quantity,
|
||||||
|
)
|
||||||
from data_layer.nutrition_items import catalog_macros_for_item, _f
|
from data_layer.nutrition_items import catalog_macros_for_item, _f
|
||||||
|
|
||||||
|
|
||||||
|
def _resolved_ingredient_qty(cur, profile_id: str, source_name_raw: str, ing: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
mapping = get_food_mapping_with_cursor(cur, source_name_raw, profile_id)
|
||||||
|
gpu = ing.get("grams_per_unit")
|
||||||
|
if gpu in (None, ""):
|
||||||
|
gpu = mapping.get("grams_per_unit") if mapping else None
|
||||||
|
try:
|
||||||
|
gpu = float(gpu) if gpu not in (None, "") else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
gpu = None
|
||||||
|
return resolve_quantity(
|
||||||
|
quantity_raw=ing.get("quantity_raw"),
|
||||||
|
quantity_amount=ing.get("quantity_amount"),
|
||||||
|
source_unit=ing.get("source_unit"),
|
||||||
|
quantity_g=ing.get("quantity_g"),
|
||||||
|
grams_per_unit=gpu,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def upsert_recipes(cur, profile_id: str, recipes: list[dict[str, Any]]) -> dict[str, int]:
|
def upsert_recipes(cur, profile_id: str, recipes: list[dict[str, Any]]) -> dict[str, int]:
|
||||||
inserted = updated = ingredients = 0
|
inserted = updated = ingredients = 0
|
||||||
for rec in recipes:
|
for rec in recipes:
|
||||||
|
|
@ -46,16 +68,19 @@ def upsert_recipes(cur, profile_id: str, recipes: list[dict[str, Any]]) -> dict[
|
||||||
inorm = ing.get("source_name_normalized") or normalize_food_name(ing.get("source_name_raw"))
|
inorm = ing.get("source_name_normalized") or normalize_food_name(ing.get("source_name_raw"))
|
||||||
if not inorm:
|
if not inorm:
|
||||||
continue
|
continue
|
||||||
|
raw_name = ing.get("source_name_raw") or inorm
|
||||||
|
qty = _resolved_ingredient_qty(cur, profile_id, raw_name, ing)
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO food_recipe_ingredients
|
INSERT INTO food_recipe_ingredients
|
||||||
(id, recipe_id, source_name_raw, source_name_normalized,
|
(id, recipe_id, source_name_raw, source_name_normalized,
|
||||||
quantity_raw, quantity_g, sort_order)
|
quantity_raw, quantity_g, quantity_amount, source_unit, sort_order)
|
||||||
VALUES (%s,%s,%s,%s,%s,%s,%s)
|
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
str(uuid.uuid4()), rid, ing["source_name_raw"], inorm,
|
str(uuid.uuid4()), rid, raw_name, inorm,
|
||||||
ing.get("quantity_raw"), ing.get("quantity_g"), ing.get("sort_order") or 0,
|
qty["quantity_raw"], qty["quantity_g"], qty["quantity_amount"], qty["source_unit"],
|
||||||
|
ing.get("sort_order") or 0,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
ingredients += 1
|
ingredients += 1
|
||||||
|
|
@ -111,7 +136,8 @@ def list_recipes(cur, profile_id: str) -> list[dict[str, Any]]:
|
||||||
ids = [str(r["id"]) for r in recipes]
|
ids = [str(r["id"]) for r in recipes]
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT recipe_id, source_name_raw, source_name_normalized, quantity_raw, quantity_g, sort_order
|
SELECT recipe_id, source_name_raw, source_name_normalized,
|
||||||
|
quantity_raw, quantity_g, quantity_amount, source_unit, sort_order
|
||||||
FROM food_recipe_ingredients
|
FROM food_recipe_ingredients
|
||||||
WHERE recipe_id = ANY(%s::uuid[])
|
WHERE recipe_id = ANY(%s::uuid[])
|
||||||
ORDER BY sort_order, source_name_raw
|
ORDER BY sort_order, source_name_raw
|
||||||
|
|
@ -195,19 +221,18 @@ def save_recipe(cur, profile_id: str, rec: dict[str, Any], recipe_id: str | None
|
||||||
inorm = ing.get("source_name_normalized") or normalize_food_name(raw)
|
inorm = ing.get("source_name_normalized") or normalize_food_name(raw)
|
||||||
if not inorm:
|
if not inorm:
|
||||||
continue
|
continue
|
||||||
qty = ing.get("quantity_g")
|
qty = _resolved_ingredient_qty(cur, profile_id, raw or inorm, ing)
|
||||||
try:
|
|
||||||
qty = float(qty) if qty not in (None, "") else None
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
qty = None
|
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO food_recipe_ingredients
|
INSERT INTO food_recipe_ingredients
|
||||||
(id, recipe_id, source_name_raw, source_name_normalized,
|
(id, recipe_id, source_name_raw, source_name_normalized,
|
||||||
quantity_raw, quantity_g, sort_order)
|
quantity_raw, quantity_g, quantity_amount, source_unit, sort_order)
|
||||||
VALUES (%s,%s,%s,%s,%s,%s,%s)
|
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||||
""",
|
""",
|
||||||
(str(uuid.uuid4()), rid, raw or inorm, inorm, ing.get("quantity_raw"), qty, i),
|
(
|
||||||
|
str(uuid.uuid4()), rid, raw or inorm, inorm,
|
||||||
|
qty["quantity_raw"], qty["quantity_g"], qty["quantity_amount"], qty["source_unit"], i,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
link_recipes_to_items(cur, profile_id)
|
link_recipes_to_items(cur, profile_id)
|
||||||
saved = get_recipe(cur, profile_id, rid)
|
saved = get_recipe(cur, profile_id, rid)
|
||||||
|
|
@ -249,7 +274,7 @@ def mapped_ingredient_quantities(
|
||||||
return None
|
return None
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT source_name_raw, quantity_g
|
SELECT source_name_raw, quantity_g, quantity_raw, quantity_amount, source_unit
|
||||||
FROM food_recipe_ingredients
|
FROM food_recipe_ingredients
|
||||||
WHERE recipe_id = %s
|
WHERE recipe_id = %s
|
||||||
ORDER BY sort_order
|
ORDER BY sort_order
|
||||||
|
|
@ -259,22 +284,29 @@ def mapped_ingredient_quantities(
|
||||||
ings = cur.fetchall()
|
ings = cur.fetchall()
|
||||||
if not ings:
|
if not ings:
|
||||||
return None
|
return None
|
||||||
total_g = sum(_f(i.get("quantity_g")) for i in ings)
|
resolved_ings = []
|
||||||
|
for ing in ings:
|
||||||
|
mapping = get_food_mapping_with_cursor(cur, ing["source_name_raw"], profile_id)
|
||||||
|
if not mapping:
|
||||||
|
return None
|
||||||
|
qty = resolve_quantity(
|
||||||
|
quantity_raw=ing.get("quantity_raw"),
|
||||||
|
quantity_amount=ing.get("quantity_amount"),
|
||||||
|
source_unit=ing.get("source_unit"),
|
||||||
|
quantity_g=ing.get("quantity_g"),
|
||||||
|
grams_per_unit=mapping.get("grams_per_unit"),
|
||||||
|
)
|
||||||
|
grams = _f(qty.get("quantity_g"))
|
||||||
|
if grams <= 0:
|
||||||
|
return None
|
||||||
|
resolved_ings.append({"food_id": mapping["food_id"], "quantity_g": grams})
|
||||||
|
total_g = sum(i["quantity_g"] for i in resolved_ings)
|
||||||
if eaten_qty_g and eaten_qty_g > 0 and total_g > 0:
|
if eaten_qty_g and eaten_qty_g > 0 and total_g > 0:
|
||||||
scale = float(eaten_qty_g) / total_g
|
scale = float(eaten_qty_g) / total_g
|
||||||
else:
|
else:
|
||||||
portions = float(rec.get("portions") or 1) or 1.0
|
portions = float(rec.get("portions") or 1) or 1.0
|
||||||
scale = 1.0 / portions
|
scale = 1.0 / portions
|
||||||
out = []
|
return [{**i, "quantity_g": i["quantity_g"] * scale} for i in resolved_ings]
|
||||||
for ing in ings:
|
|
||||||
mapping = get_food_mapping_with_cursor(cur, ing["source_name_raw"], profile_id)
|
|
||||||
if not mapping:
|
|
||||||
return None
|
|
||||||
out.append({
|
|
||||||
"food_id": mapping["food_id"],
|
|
||||||
"quantity_g": _f(ing.get("quantity_g")) * scale,
|
|
||||||
})
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def catalog_macros_for_recipe(cur, profile_id: str, recipe_id: str, eaten_qty_g: float | None) -> dict[str, float] | None:
|
def catalog_macros_for_recipe(cur, profile_id: str, recipe_id: str, eaten_qty_g: float | None) -> dict[str, float] | None:
|
||||||
|
|
|
||||||
18
backend/migrations/066_recipe_ingredient_units.sql
Normal file
18
backend/migrations/066_recipe_ingredient_units.sql
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
-- Menge + Einheit an Rezeptzutaten; quantity_g bleibt die Standardeinheit (Gramm).
|
||||||
|
|
||||||
|
ALTER TABLE food_recipe_ingredients
|
||||||
|
ADD COLUMN IF NOT EXISTS quantity_amount NUMERIC(10,3),
|
||||||
|
ADD COLUMN IF NOT EXISTS source_unit VARCHAR(20);
|
||||||
|
|
||||||
|
COMMENT ON COLUMN food_recipe_ingredients.quantity_amount IS 'Menge in source_unit (z. B. 2 TL)';
|
||||||
|
COMMENT ON COLUMN food_recipe_ingredients.source_unit IS 'Kanonische Einheit: g, ml, el, tl, prise, stück, …';
|
||||||
|
COMMENT ON COLUMN food_recipe_ingredients.quantity_g IS 'Umrechnung auf Gramm, wenn Faktor bekannt';
|
||||||
|
|
||||||
|
UPDATE food_recipe_ingredients
|
||||||
|
SET quantity_amount = quantity_g, source_unit = 'g'
|
||||||
|
WHERE quantity_g IS NOT NULL AND quantity_amount IS NULL AND source_unit IS NULL;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
RAISE NOTICE 'Migration 066: recipe ingredient amount + unit';
|
||||||
|
END $$;
|
||||||
|
|
@ -13,6 +13,7 @@ from data_layer.food_mapping import (
|
||||||
apply_mapping_to_items,
|
apply_mapping_to_items,
|
||||||
apply_quantities_to_items,
|
apply_quantities_to_items,
|
||||||
clear_mapping_from_items,
|
clear_mapping_from_items,
|
||||||
|
list_quantity_units,
|
||||||
normalize_food_name,
|
normalize_food_name,
|
||||||
upsert_food_mapping,
|
upsert_food_mapping,
|
||||||
)
|
)
|
||||||
|
|
@ -79,6 +80,11 @@ def _schedule_rebuild(profile_id: str, dates: list[str], context: str) -> None:
|
||||||
).start()
|
).start()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/units")
|
||||||
|
def list_food_units(session: dict = Depends(require_auth)):
|
||||||
|
return list_quantity_units()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/foods")
|
@router.get("/foods")
|
||||||
def search_foods(
|
def search_foods(
|
||||||
q: str = "",
|
q: str = "",
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,9 @@ class RecipeIngredientIn(BaseModel):
|
||||||
source_name_raw: str
|
source_name_raw: str
|
||||||
quantity_raw: Optional[str] = None
|
quantity_raw: Optional[str] = None
|
||||||
quantity_g: Optional[float] = None
|
quantity_g: Optional[float] = None
|
||||||
|
quantity_amount: Optional[float] = None
|
||||||
|
source_unit: Optional[str] = None
|
||||||
|
grams_per_unit: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
class RecipeIn(BaseModel):
|
class RecipeIn(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,13 @@ from datetime import date
|
||||||
from csv_parser.executor import guess_nutrition_item_fields
|
from csv_parser.executor import guess_nutrition_item_fields
|
||||||
from data_layer.food_mapping import (
|
from data_layer.food_mapping import (
|
||||||
filter_unmapped_since,
|
filter_unmapped_since,
|
||||||
|
list_quantity_units,
|
||||||
merge_unmapped_rows,
|
merge_unmapped_rows,
|
||||||
normalize_food_name,
|
normalize_food_name,
|
||||||
parse_quantity,
|
parse_quantity,
|
||||||
parse_quantity_g,
|
parse_quantity_g,
|
||||||
primary_search_query,
|
primary_search_query,
|
||||||
|
resolve_quantity,
|
||||||
)
|
)
|
||||||
from data_layer.nutrition_items import macros_differ
|
from data_layer.nutrition_items import macros_differ
|
||||||
|
|
||||||
|
|
@ -46,6 +48,20 @@ def test_parse_quantity_g():
|
||||||
assert parse_quantity("2 EL")["unit"] == "el"
|
assert parse_quantity("2 EL")["unit"] == "el"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_quantity_household_units():
|
||||||
|
two_tl = resolve_quantity(quantity_amount=2, source_unit="tl")
|
||||||
|
assert two_tl["quantity_g"] == 10.0
|
||||||
|
assert two_tl["quantity_raw"] == "2 TL"
|
||||||
|
pinch = resolve_quantity(quantity_amount=1, source_unit="prise")
|
||||||
|
assert pinch["quantity_g"] == 0.3
|
||||||
|
slice_ = resolve_quantity(quantity_amount=1, source_unit="stück")
|
||||||
|
assert slice_["quantity_g"] is None
|
||||||
|
assert slice_["needs_unit_map"] is True
|
||||||
|
assert resolve_quantity(quantity_amount=1, source_unit="stück", grams_per_unit=60)["quantity_g"] == 60.0
|
||||||
|
ids = [u["id"] for u in list_quantity_units()]
|
||||||
|
assert "tl" in ids and "prise" in ids
|
||||||
|
|
||||||
|
|
||||||
def test_guess_fddb_bezeichnung_without_template_mapping():
|
def test_guess_fddb_bezeichnung_without_template_mapping():
|
||||||
name, qty = guess_nutrition_item_fields({
|
name, qty = guess_nutrition_item_fields({
|
||||||
"bezeichnung": "50 g Hähnchen",
|
"bezeichnung": "50 g Hähnchen",
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ Semantic Versioning: MAJOR.MINOR.PATCH
|
||||||
|
|
||||||
APP_VERSION = "0.9v"
|
APP_VERSION = "0.9v"
|
||||||
BUILD_DATE = "2026-09-12"
|
BUILD_DATE = "2026-09-12"
|
||||||
DB_SCHEMA_VERSION = "20260912b" # 065 omega attribute extensions
|
DB_SCHEMA_VERSION = "20260912c" # 066 recipe ingredient units
|
||||||
|
|
||||||
MODULE_VERSIONS = {
|
MODULE_VERSIONS = {
|
||||||
"auth": "1.2.0",
|
"auth": "1.2.0",
|
||||||
|
|
@ -20,8 +20,8 @@ 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.2.9", # Zuordnen: Lebensmittel oder Rezept anlegen
|
"nutrition": "1.3.2", # UI: Liste/Kombination; Tandoor-Gerichte später
|
||||||
"bls": "1.0.4",
|
"bls": "1.0.5", # GET /bls/units
|
||||||
"photos": "1.0.0",
|
"photos": "1.0.0",
|
||||||
"insights": "1.3.0",
|
"insights": "1.3.0",
|
||||||
"prompts": "1.1.0",
|
"prompts": "1.1.0",
|
||||||
|
|
@ -55,6 +55,9 @@ CHANGELOG = [
|
||||||
"Katalogsuche: Fettgehalt (9,5 / 10 %) bleibt erhalten, Joghurt 10% vor >3,5%",
|
"Katalogsuche: Fettgehalt (9,5 / 10 %) bleibt erhalten, Joghurt 10% vor >3,5%",
|
||||||
"Eigenes Lebensmittel: beliebige Katalog-Stoffe (EPA/DHA/…) plus Portionsumrechnung auf 100 g",
|
"Eigenes Lebensmittel: beliebige Katalog-Stoffe (EPA/DHA/…) plus Portionsumrechnung auf 100 g",
|
||||||
"Zuordnen: Mapping-Export robust; Rezepte anlegen/bearbeiten; Admin-Mapping direkt ändern",
|
"Zuordnen: Mapping-Export robust; Rezepte anlegen/bearbeiten; Admin-Mapping direkt ändern",
|
||||||
|
"Rezept-Dialog: Zutaten aus Zuordnungen und Katalog suchen; Tandoor später",
|
||||||
|
"Rezeptzutaten: Menge + Einheit (EL/TL/Prise/Stück); Umrechnung auf Gramm",
|
||||||
|
"Zuordnen: Mitai = Liste/Kombination; gekochte Rezepte Tandoor + Ausbeute später",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,10 @@ Verlässliche Lebensmittel-Stammdaten (BLS 4.0 + manuelle Erweiterung), lernende
|
||||||
- Fasten-/Lücken-Marken unabhängig vom Import
|
- Fasten-/Lücken-Marken unabhängig vom Import
|
||||||
- Einzelerfassung nur Makros unverändert
|
- Einzelerfassung nur Makros unverändert
|
||||||
- Zuordnen über **Namenssuche im Popup** (kein BLS-Code-Lookup durch den Nutzer)
|
- Zuordnen über **Namenssuche im Popup** (kein BLS-Code-Lookup durch den Nutzer)
|
||||||
- FDDB-Listen-CSV importieren; Tagebuch-Rezeptzeilen in Zutaten auflösen
|
- FDDB-Listen-CSV importieren; Tagebuch-Listenzeilen in Zutaten auflösen
|
||||||
- Zuordnungen und Listen als JSON zwischen Instanzen übertragen
|
- Zuordnungen und Listen als JSON zwischen Instanzen übertragen
|
||||||
- Eigener Katalogeintrag im Suchpopup; Gramm pro Stück/EL/… am Mapping
|
- Eigener Katalogeintrag im Suchpopup; Gramm pro Stück/EL/… am Mapping
|
||||||
- Manueller Eintrag: beliebige `num_per_100g`-Stoffe (EPA/DHA/…) und Portionsumrechnung
|
- Manueller Eintrag: beliebige `num_per_100g`-Stoffe (EPA/DHA/…) und Portionsumrechnung
|
||||||
|
- Listen-Dialog mit Zutatensuche (Mappings + Katalog); Mitai = Roh-Kombination
|
||||||
|
- Listenzutaten: Menge + Einheit, Umrechnung auf Gramm (EL/TL/Prise/Stück)
|
||||||
|
- Folge: Tandoor-Gerichte + `cooked_yield_g` (Fertiggewicht) + Katalogeintrag pro 100 g — nicht Phase 1
|
||||||
|
|
|
||||||
477
frontend/src/components/FoodRecipeDialog.jsx
Normal file
477
frontend/src/components/FoodRecipeDialog.jsx
Normal file
|
|
@ -0,0 +1,477 @@
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { api } from '../utils/api'
|
||||||
|
|
||||||
|
const FALLBACK_UNITS = [
|
||||||
|
{ id: 'g', label: 'g', default_g: 1, needs_unit_map: false },
|
||||||
|
{ id: 'ml', label: 'ml', default_g: 1, needs_unit_map: false },
|
||||||
|
{ id: 'el', label: 'EL', default_g: 15, needs_unit_map: false },
|
||||||
|
{ id: 'tl', label: 'TL', default_g: 5, needs_unit_map: false },
|
||||||
|
{ id: 'prise', label: 'Prise', default_g: 0.3, needs_unit_map: false },
|
||||||
|
{ id: 'msp', label: 'Msp.', default_g: 1, needs_unit_map: false },
|
||||||
|
{ id: 'stück', label: 'Stück', default_g: null, needs_unit_map: true },
|
||||||
|
{ id: 'scheibe', label: 'Scheibe', default_g: null, needs_unit_map: true },
|
||||||
|
{ id: 'portion', label: 'Portion', default_g: null, needs_unit_map: true },
|
||||||
|
{ id: 'becher', label: 'Becher', default_g: null, needs_unit_map: true },
|
||||||
|
{ id: 'tasse', label: 'Tasse', default_g: null, needs_unit_map: true },
|
||||||
|
{ id: 'kg', label: 'kg', default_g: 1000, needs_unit_map: false },
|
||||||
|
{ id: 'l', label: 'l', default_g: 1000, needs_unit_map: false },
|
||||||
|
]
|
||||||
|
|
||||||
|
function emptyIng(overrides = {}) {
|
||||||
|
return {
|
||||||
|
source_name_raw: '',
|
||||||
|
amount: '',
|
||||||
|
unit: 'g',
|
||||||
|
gramsPerUnit: '',
|
||||||
|
foodId: null,
|
||||||
|
mappedLabel: '',
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function unitMeta(units, id) {
|
||||||
|
return (units || []).find((u) => u.id === id) || FALLBACK_UNITS.find((u) => u.id === id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function gramsHint(ing, units) {
|
||||||
|
const amount = parseFloat(String(ing.amount).replace(',', '.'))
|
||||||
|
if (!Number.isFinite(amount) || amount <= 0) return null
|
||||||
|
const meta = unitMeta(units, ing.unit)
|
||||||
|
const factor = parseFloat(String(ing.gramsPerUnit).replace(',', '.'))
|
||||||
|
const per = Number.isFinite(factor) && factor > 0 ? factor : meta?.default_g
|
||||||
|
if (!per) return null
|
||||||
|
const g = Math.round(amount * per * 1000) / 1000
|
||||||
|
return g
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromSavedIngredient(i, mapped) {
|
||||||
|
let amount = i.quantity_amount
|
||||||
|
let unit = i.source_unit || 'g'
|
||||||
|
if (amount == null && i.quantity_raw) {
|
||||||
|
const m = String(i.quantity_raw).trim().match(/^(\d+(?:[.,]\d+)?)\s*(.*)$/)
|
||||||
|
if (m) {
|
||||||
|
amount = m[1].replace(',', '.')
|
||||||
|
const label = (m[2] || '').trim().toLowerCase()
|
||||||
|
const hit = FALLBACK_UNITS.find((u) => u.id === label || u.label.toLowerCase() === label)
|
||||||
|
if (hit) unit = hit.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (amount == null && i.quantity_g != null) {
|
||||||
|
amount = i.quantity_g
|
||||||
|
unit = i.source_unit || 'g'
|
||||||
|
}
|
||||||
|
return emptyIng({
|
||||||
|
source_name_raw: i.source_name_raw || '',
|
||||||
|
amount: amount ?? '',
|
||||||
|
unit,
|
||||||
|
gramsPerUnit: mapped?.grams_per_unit ?? '',
|
||||||
|
foodId: mapped?.food_id || null,
|
||||||
|
mappedLabel: mapped?.food_name_de || '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function mappingFor(name, learned) {
|
||||||
|
const n = (name || '').trim().toLowerCase()
|
||||||
|
if (!n) return null
|
||||||
|
return (learned || []).find((m) => {
|
||||||
|
const raw = (m.source_name_raw || '').toLowerCase()
|
||||||
|
const norm = (m.source_name_normalized || '').toLowerCase()
|
||||||
|
return raw === n || norm === n
|
||||||
|
}) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterLearned(term, learned) {
|
||||||
|
const t = (term || '').trim().toLowerCase()
|
||||||
|
if (t.length < 2) return []
|
||||||
|
return (learned || []).filter((m) => {
|
||||||
|
const hay = `${m.source_name_raw || ''} ${m.food_name_de || ''} ${m.bls_code || ''}`.toLowerCase()
|
||||||
|
return hay.includes(t)
|
||||||
|
}).slice(0, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FoodRecipeDialog({
|
||||||
|
title,
|
||||||
|
defaultName,
|
||||||
|
defaultPortions,
|
||||||
|
defaultIngredients,
|
||||||
|
learned,
|
||||||
|
disabled,
|
||||||
|
submitLabel,
|
||||||
|
onSave,
|
||||||
|
onClose,
|
||||||
|
}) {
|
||||||
|
const [name, setName] = useState(defaultName || '')
|
||||||
|
const [portions, setPortions] = useState(defaultPortions != null ? String(defaultPortions) : '1')
|
||||||
|
const [ings, setIngs] = useState(
|
||||||
|
(defaultIngredients || []).length
|
||||||
|
? defaultIngredients.map((i) => fromSavedIngredient(i, mappingFor(i.source_name_raw, learned)))
|
||||||
|
: []
|
||||||
|
)
|
||||||
|
const [units, setUnits] = useState(FALLBACK_UNITS)
|
||||||
|
const [q, setQ] = useState('')
|
||||||
|
const [catalogHits, setCatalogHits] = useState([])
|
||||||
|
const [searching, setSearching] = useState(false)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [localLearned, setLocalLearned] = useState(learned || [])
|
||||||
|
const nameRef = useRef(null)
|
||||||
|
const qtyRefs = useRef({})
|
||||||
|
const timer = useRef(null)
|
||||||
|
const abortRef = useRef(null)
|
||||||
|
const seqRef = useRef(0)
|
||||||
|
|
||||||
|
useEffect(() => { setLocalLearned(learned || []) }, [learned])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.listFoodUnits().then((list) => {
|
||||||
|
if (Array.isArray(list) && list.length) setUnits(list)
|
||||||
|
}).catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
nameRef.current?.focus()
|
||||||
|
const onKey = (e) => { if (e.key === 'Escape' && !saving) onClose() }
|
||||||
|
window.addEventListener('keydown', onKey)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', onKey)
|
||||||
|
clearTimeout(timer.current)
|
||||||
|
abortRef.current?.abort()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
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 runCatalog = async (term) => {
|
||||||
|
const query = (term || '').trim()
|
||||||
|
abortRef.current?.abort()
|
||||||
|
if (query.length < 2) {
|
||||||
|
setCatalogHits([])
|
||||||
|
setSearching(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const seq = ++seqRef.current
|
||||||
|
const ac = new AbortController()
|
||||||
|
abortRef.current = ac
|
||||||
|
setSearching(true)
|
||||||
|
try {
|
||||||
|
const next = await api.searchBlsFoods(query, 12, ac.signal)
|
||||||
|
if (seq !== seqRef.current) return
|
||||||
|
setCatalogHits(Array.isArray(next) ? next : [])
|
||||||
|
} catch (e) {
|
||||||
|
if (e.name === 'AbortError') return
|
||||||
|
if (seq !== seqRef.current) return
|
||||||
|
setCatalogHits([])
|
||||||
|
} finally {
|
||||||
|
if (seq === seqRef.current) setSearching(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSearch = (value) => {
|
||||||
|
setQ(value)
|
||||||
|
clearTimeout(timer.current)
|
||||||
|
if (value.trim().length < 2) {
|
||||||
|
abortRef.current?.abort()
|
||||||
|
setCatalogHits([])
|
||||||
|
setSearching(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
timer.current = setTimeout(() => runCatalog(value), 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
const focusQty = (idx) => {
|
||||||
|
setTimeout(() => qtyRefs.current[idx]?.focus(), 30)
|
||||||
|
}
|
||||||
|
|
||||||
|
const addIngredient = (ing) => {
|
||||||
|
const raw = (ing.source_name_raw || '').trim()
|
||||||
|
if (!raw) return
|
||||||
|
setIngs((list) => {
|
||||||
|
const exists = list.findIndex((x) => (x.source_name_raw || '').trim().toLowerCase() === raw.toLowerCase())
|
||||||
|
if (exists >= 0) {
|
||||||
|
focusQty(exists)
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
const next = [...list, emptyIng({ ...ing, source_name_raw: raw })]
|
||||||
|
focusQty(next.length - 1)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
setQ('')
|
||||||
|
setCatalogHits([])
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const addMapped = (m) => addIngredient({
|
||||||
|
source_name_raw: m.source_name_raw,
|
||||||
|
foodId: m.food_id,
|
||||||
|
mappedLabel: m.food_name_de || '',
|
||||||
|
unit: m.source_unit || 'g',
|
||||||
|
gramsPerUnit: m.grams_per_unit ?? '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const addCatalog = (h) => addIngredient({
|
||||||
|
source_name_raw: h.name_de,
|
||||||
|
foodId: h.id,
|
||||||
|
mappedLabel: h.name_de,
|
||||||
|
})
|
||||||
|
|
||||||
|
const addFreeText = () => {
|
||||||
|
const raw = q.trim()
|
||||||
|
if (!raw) return
|
||||||
|
addIngredient({ source_name_raw: raw })
|
||||||
|
}
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
const ingredients = ings
|
||||||
|
.map((i) => {
|
||||||
|
const amount = i.amount === '' ? null : parseFloat(String(i.amount).replace(',', '.'))
|
||||||
|
const gpu = i.gramsPerUnit === '' ? null : parseFloat(String(i.gramsPerUnit).replace(',', '.'))
|
||||||
|
return {
|
||||||
|
source_name_raw: (i.source_name_raw || '').trim(),
|
||||||
|
quantity_amount: Number.isFinite(amount) ? amount : null,
|
||||||
|
source_unit: i.unit || 'g',
|
||||||
|
grams_per_unit: Number.isFinite(gpu) && gpu > 0 ? gpu : null,
|
||||||
|
foodId: i.foodId,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((i) => i.source_name_raw)
|
||||||
|
if (!name.trim()) {
|
||||||
|
setError('Listenname fehlt')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!ingredients.length) {
|
||||||
|
setError('Mindestens eine Zutat angeben')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (ingredients.some((i) => i.quantity_amount == null || i.quantity_amount <= 0)) {
|
||||||
|
setError('Jede Zutat braucht eine Menge')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const nextLearned = [...localLearned]
|
||||||
|
for (const ing of ingredients) {
|
||||||
|
if (!ing.foodId) continue
|
||||||
|
const already = nextLearned.some((m) => (
|
||||||
|
(m.source_name_raw || '').trim().toLowerCase() === ing.source_name_raw.toLowerCase()
|
||||||
|
))
|
||||||
|
if (already && !ing.grams_per_unit) continue
|
||||||
|
const res = await api.upsertMyFoodMapping({
|
||||||
|
source_name: ing.source_name_raw,
|
||||||
|
food_id: ing.foodId,
|
||||||
|
grams_per_unit: ing.grams_per_unit,
|
||||||
|
source_unit: ing.source_unit,
|
||||||
|
})
|
||||||
|
nextLearned.unshift({
|
||||||
|
id: res.mapping_id,
|
||||||
|
source_name_raw: ing.source_name_raw,
|
||||||
|
food_id: ing.foodId,
|
||||||
|
food_name_de: res.food_name_de || ing.source_name_raw,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
setLocalLearned(nextLearned)
|
||||||
|
await onSave({
|
||||||
|
name_raw: name.trim(),
|
||||||
|
portions: parseFloat(String(portions).replace(',', '.')) || 1,
|
||||||
|
ingredients: ingredients.map(({ source_name_raw, quantity_amount, source_unit, grams_per_unit }) => ({
|
||||||
|
source_name_raw,
|
||||||
|
quantity_amount,
|
||||||
|
source_unit,
|
||||||
|
grams_per_unit,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const showResults = q.trim().length >= 2
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 21000,
|
||||||
|
padding: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="recipe-dialog-title"
|
||||||
|
style={{
|
||||||
|
width: '100%', maxWidth: 560, maxHeight: 'min(92vh, 800px)',
|
||||||
|
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="recipe-dialog-title" className="card-title" style={{ margin: 0, fontSize: 16 }}>{title || 'Liste'}</h2>
|
||||||
|
<button type="button" className="btn btn-secondary" disabled={saving} onClick={onClose}>Schließen</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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.
|
||||||
|
</p>
|
||||||
|
{error && <div style={{ color: 'var(--danger)', fontSize: 13, marginBottom: 10 }}>{error}</div>}
|
||||||
|
|
||||||
|
<label className="form-label">Name der Liste</label>
|
||||||
|
<input
|
||||||
|
ref={nameRef}
|
||||||
|
className="form-input"
|
||||||
|
style={{ width: '100%', textAlign: 'left', marginBottom: 12 }}
|
||||||
|
placeholder="z. B. Overnight Oats"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
<label className="form-label">Portionen</label>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
type="number"
|
||||||
|
min="0.1"
|
||||||
|
step="0.1"
|
||||||
|
style={{ width: 96, textAlign: 'left', marginBottom: 16 }}
|
||||||
|
value={portions}
|
||||||
|
onChange={(e) => setPortions(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p style={{ fontSize: 13, fontWeight: 600, margin: '0 0 8px' }}>Zutat suchen</p>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
style={{ width: '100%', textAlign: 'left' }}
|
||||||
|
placeholder="Name, z. B. Haferflocken oder Joghurt 10%"
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => onSearch(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addFreeText() } }}
|
||||||
|
/>
|
||||||
|
{showResults && (
|
||||||
|
<div style={{ marginTop: 8, border: '1px solid var(--border)', borderRadius: 10, background: 'var(--surface2)' }}>
|
||||||
|
{mappedHits.length > 0 && (
|
||||||
|
<div style={{ padding: '8px 10px 4px', fontSize: 11, color: 'var(--text3)' }}>Bereits zugeordnet</div>
|
||||||
|
)}
|
||||||
|
{mappedHits.map((m) => (
|
||||||
|
<button
|
||||||
|
key={m.id || m.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={() => addMapped(m)}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<strong style={{ display: 'block' }}>{m.source_name_raw}</strong>
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text3)' }}>→ {m.food_name_de}{m.bls_code ? ` · ${m.bls_code}` : ''}</span>
|
||||||
|
</span>
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
{catalogOnly.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'}
|
||||||
|
{h.catalog_kind !== 'official_bls' ? ' · manuell' : ''}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button type="button" className="btn btn-secondary btn-full" style={{ margin: '0 8px 8px' }} onClick={addFreeText}>
|
||||||
|
„{q.trim()}“ als offene Zutat übernehmen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p style={{ fontSize: 13, fontWeight: 600, margin: '16px 0 8px' }}>Zutaten ({ings.length})</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 meta = unitMeta(units, ing.unit)
|
||||||
|
const approx = gramsHint(ing, units)
|
||||||
|
const needsFactor = Boolean(meta?.needs_unit_map) && !approx
|
||||||
|
const isMass = ['g', 'ml', 'kg', 'l'].includes(ing.unit)
|
||||||
|
return (
|
||||||
|
<div key={`${ing.source_name_raw}-${i}`} style={{ borderTop: '1px solid var(--border)', padding: '10px 0' }}>
|
||||||
|
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
style={{ textAlign: 'left', flex: 1 }}
|
||||||
|
value={ing.source_name_raw}
|
||||||
|
onChange={(e) => setIngs((list) => list.map((x, j) => j === i ? { ...x, source_name_raw: e.target.value, foodId: null, mappedLabel: '' } : x))}
|
||||||
|
/>
|
||||||
|
<button type="button" className="btn btn-secondary" style={{ padding: '6px 10px' }} onClick={() => setIngs((list) => list.filter((_, j) => j !== i))} aria-label="Zutat entfernen">×</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6, marginTop: 6 }}>
|
||||||
|
<input
|
||||||
|
ref={(el) => { qtyRefs.current[i] = el }}
|
||||||
|
className="form-input"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.1"
|
||||||
|
style={{ textAlign: 'left' }}
|
||||||
|
placeholder="Menge"
|
||||||
|
value={ing.amount}
|
||||||
|
onChange={(e) => setIngs((list) => list.map((x, j) => j === i ? { ...x, amount: e.target.value } : x))}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
className="form-input"
|
||||||
|
style={{ textAlign: 'left' }}
|
||||||
|
value={ing.unit}
|
||||||
|
onChange={(e) => setIngs((list) => list.map((x, j) => j === i ? { ...x, unit: e.target.value } : x))}
|
||||||
|
>
|
||||||
|
{units.map((u) => (
|
||||||
|
<option key={u.id} value={u.id}>{u.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{!isMass && (
|
||||||
|
<label style={{ display: 'block', fontSize: 12, color: 'var(--text2)', marginTop: 6 }}>
|
||||||
|
1 {meta?.label || ing.unit} ={' '}
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
type="number"
|
||||||
|
min="0.01"
|
||||||
|
step="0.1"
|
||||||
|
style={{ width: 80, display: 'inline-block', textAlign: 'left' }}
|
||||||
|
placeholder={meta?.default_g != null ? String(meta.default_g) : 'g'}
|
||||||
|
value={ing.gramsPerUnit}
|
||||||
|
onChange={(e) => setIngs((list) => list.map((x, j) => j === i ? { ...x, gramsPerUnit: e.target.value } : x))}
|
||||||
|
/>
|
||||||
|
{' '}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>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 8, padding: '12px 16px', borderTop: '1px solid var(--border)' }}>
|
||||||
|
<button type="button" className="btn btn-secondary" disabled={saving || disabled} onClick={onClose}>Abbrechen</button>
|
||||||
|
<button type="button" className="btn btn-primary" style={{ flex: 1 }} disabled={saving || disabled} onClick={save}>
|
||||||
|
{saving ? 'Speichere…' : (submitLabel || 'Liste speichern')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,118 +1,20 @@
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { api } from '../utils/api'
|
import { api } from '../utils/api'
|
||||||
|
import FoodRecipeDialog from './FoodRecipeDialog'
|
||||||
function emptyIng() {
|
|
||||||
return { source_name_raw: '', quantity_g: '' }
|
|
||||||
}
|
|
||||||
|
|
||||||
function mappingHint(name, learned) {
|
|
||||||
const n = (name || '').trim().toLowerCase()
|
|
||||||
if (!n) return null
|
|
||||||
return (learned || []).find((m) => {
|
|
||||||
const raw = (m.source_name_raw || '').toLowerCase()
|
|
||||||
const norm = (m.source_name_normalized || '').toLowerCase()
|
|
||||||
return raw === n || norm === n || raw.includes(n) || n.includes(norm)
|
|
||||||
}) || null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RecipeForm({ defaultName, defaultPortions, defaultIngredients, learned, disabled, submitLabel, onSave, onCancel }) {
|
|
||||||
const [name, setName] = useState(defaultName || '')
|
|
||||||
const [portions, setPortions] = useState(defaultPortions != null ? String(defaultPortions) : '1')
|
|
||||||
const [ings, setIngs] = useState(
|
|
||||||
(defaultIngredients || []).length
|
|
||||||
? defaultIngredients.map((i) => ({ source_name_raw: i.source_name_raw || '', quantity_g: i.quantity_g ?? '' }))
|
|
||||||
: [emptyIng()]
|
|
||||||
)
|
|
||||||
const [error, setError] = useState(null)
|
|
||||||
const [saving, setSaving] = useState(false)
|
|
||||||
|
|
||||||
const save = async () => {
|
|
||||||
const ingredients = ings
|
|
||||||
.map((i) => ({
|
|
||||||
source_name_raw: (i.source_name_raw || '').trim(),
|
|
||||||
quantity_g: i.quantity_g === '' ? null : parseFloat(String(i.quantity_g).replace(',', '.')),
|
|
||||||
}))
|
|
||||||
.filter((i) => i.source_name_raw)
|
|
||||||
if (!name.trim()) {
|
|
||||||
setError('Rezeptname fehlt')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!ingredients.length) {
|
|
||||||
setError('Mindestens eine Zutat angeben')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setSaving(true)
|
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
await onSave({
|
|
||||||
name_raw: name.trim(),
|
|
||||||
portions: parseFloat(String(portions).replace(',', '.')) || 1,
|
|
||||||
ingredients,
|
|
||||||
})
|
|
||||||
} catch (e) {
|
|
||||||
setError(e.message)
|
|
||||||
} finally {
|
|
||||||
setSaving(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ marginTop: 8, padding: 10, background: 'var(--surface2)', borderRadius: 8 }}>
|
|
||||||
<p style={{ fontSize: 12, margin: '0 0 8px', color: 'var(--text2)' }}>
|
|
||||||
Zutaten, die schon zugeordnet sind, werden übernommen. Offene erscheinen danach in der Liste zum Zuordnen.
|
|
||||||
</p>
|
|
||||||
{error && <div style={{ color: 'var(--danger)', fontSize: 13, marginBottom: 8 }}>{error}</div>}
|
|
||||||
<input className="form-input" style={{ width: '100%', textAlign: 'left', marginBottom: 8 }} placeholder="Rezeptname" value={name} onChange={(e) => setName(e.target.value)} />
|
|
||||||
<label style={{ fontSize: 12, color: 'var(--text2)' }}>
|
|
||||||
Portionen
|
|
||||||
<input className="form-input" type="number" min="0.1" step="0.1" style={{ width: 80, textAlign: 'left', marginLeft: 8 }} value={portions} onChange={(e) => setPortions(e.target.value)} />
|
|
||||||
</label>
|
|
||||||
<p style={{ fontSize: 12, margin: '10px 0 6px', color: 'var(--text2)' }}>Zutaten (Name + Gramm)</p>
|
|
||||||
{ings.map((ing, i) => {
|
|
||||||
const mapped = mappingHint(ing.source_name_raw, learned)
|
|
||||||
return (
|
|
||||||
<div key={i} style={{ marginBottom: 8 }}>
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 80px', gap: 6 }}>
|
|
||||||
<input className="form-input" style={{ textAlign: 'left' }} placeholder="z. B. Haferflocken" value={ing.source_name_raw} onChange={(e) => setIngs((list) => list.map((x, j) => j === i ? { ...x, source_name_raw: e.target.value } : x))} />
|
|
||||||
<input className="form-input" type="number" min="0" step="0.1" style={{ textAlign: 'left' }} placeholder="g" value={ing.quantity_g} onChange={(e) => setIngs((list) => list.map((x, j) => j === i ? { ...x, quantity_g: e.target.value } : x))} />
|
|
||||||
</div>
|
|
||||||
{ing.source_name_raw.trim() && (
|
|
||||||
<div style={{ fontSize: 11, color: mapped ? 'var(--accent-dark)' : 'var(--text3)', marginTop: 2 }}>
|
|
||||||
{mapped ? `zugeordnet: ${mapped.food_name_de || mapped.source_name_raw}` : 'noch offen — nach dem Speichern in der Liste zuordnen'}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
<button type="button" className="btn btn-secondary" style={{ fontSize: 12 }} onClick={() => setIngs((list) => [...list, emptyIng()])}>Zutat hinzufügen</button>
|
|
||||||
<div style={{ display: 'flex', gap: 8, marginTop: 10 }}>
|
|
||||||
{onCancel && (
|
|
||||||
<button type="button" className="btn btn-secondary" disabled={saving} onClick={onCancel}>Zurück</button>
|
|
||||||
)}
|
|
||||||
<button type="button" className="btn btn-primary" style={{ flex: 1 }} disabled={disabled || saving} onClick={save}>
|
|
||||||
{saving ? 'Speichere…' : (submitLabel || 'Rezept speichern')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function FoodRecipeEditor({ recipes, learned, onChanged }) {
|
export default function FoodRecipeEditor({ recipes, learned, onChanged }) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [editing, setEditing] = useState(null)
|
const [editing, setEditing] = useState(null)
|
||||||
const [draft, setDraft] = useState(null)
|
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
const startNew = () => {
|
const startNew = () => {
|
||||||
setEditing('new')
|
setEditing('new')
|
||||||
setDraft({ name: '', portions: '1' })
|
|
||||||
setError(null)
|
setError(null)
|
||||||
setOpen(true)
|
setOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const startEdit = (r) => {
|
const startEdit = (r) => {
|
||||||
setEditing(r)
|
setEditing(r)
|
||||||
setDraft(r)
|
|
||||||
setError(null)
|
setError(null)
|
||||||
setOpen(true)
|
setOpen(true)
|
||||||
}
|
}
|
||||||
|
|
@ -121,12 +23,11 @@ export default function FoodRecipeEditor({ recipes, learned, onChanged }) {
|
||||||
if (editing === 'new') await api.createNutritionRecipe(body)
|
if (editing === 'new') await api.createNutritionRecipe(body)
|
||||||
else await api.updateNutritionRecipe(editing.id, body)
|
else await api.updateNutritionRecipe(editing.id, body)
|
||||||
setEditing(null)
|
setEditing(null)
|
||||||
setDraft(null)
|
|
||||||
onChanged?.()
|
onChanged?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
const remove = async (id) => {
|
const remove = async (id) => {
|
||||||
if (!confirm('Rezept wirklich löschen?')) return
|
if (!confirm('Liste wirklich löschen?')) return
|
||||||
try {
|
try {
|
||||||
await api.deleteNutritionRecipe(id)
|
await api.deleteNutritionRecipe(id)
|
||||||
if (editing && editing !== 'new' && editing.id === id) setEditing(null)
|
if (editing && editing !== 'new' && editing.id === id) setEditing(null)
|
||||||
|
|
@ -139,7 +40,7 @@ export default function FoodRecipeEditor({ recipes, learned, onChanged }) {
|
||||||
return (
|
return (
|
||||||
<div style={{ marginTop: 16 }}>
|
<div style={{ marginTop: 16 }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8 }}>
|
||||||
<h3 style={{ fontSize: 14, margin: 0 }}>Eigene Rezepte / Listen ({recipes.length})</h3>
|
<h3 style={{ fontSize: 14, margin: 0 }}>Eigene Listen / Kombinationen ({recipes.length})</h3>
|
||||||
<button type="button" className="btn btn-secondary" style={{ fontSize: 12 }} onClick={() => { setOpen((v) => !v); if (open) setEditing(null) }}>
|
<button type="button" className="btn btn-secondary" style={{ fontSize: 12 }} onClick={() => { setOpen((v) => !v); if (open) setEditing(null) }}>
|
||||||
{open ? 'Einklappen' : 'Anzeigen'}
|
{open ? 'Einklappen' : 'Anzeigen'}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -147,7 +48,7 @@ export default function FoodRecipeEditor({ recipes, learned, onChanged }) {
|
||||||
{open && (
|
{open && (
|
||||||
<div style={{ marginTop: 8 }}>
|
<div style={{ marginTop: 8 }}>
|
||||||
{error && <div style={{ color: 'var(--danger)', fontSize: 13, marginBottom: 8 }}>{error}</div>}
|
{error && <div style={{ color: 'var(--danger)', fontSize: 13, marginBottom: 8 }}>{error}</div>}
|
||||||
<button type="button" className="btn btn-secondary btn-full" onClick={startNew}>Rezept anlegen</button>
|
<button type="button" className="btn btn-secondary btn-full" onClick={startNew}>Liste anlegen</button>
|
||||||
{recipes.map((r) => (
|
{recipes.map((r) => (
|
||||||
<div key={r.id} style={{ borderTop: '1px solid var(--border)', padding: '8px 0', marginTop: 8 }}>
|
<div key={r.id} style={{ borderTop: '1px solid var(--border)', padding: '8px 0', marginTop: 8 }}>
|
||||||
<div style={{ fontWeight: 600 }}>{r.name_raw}</div>
|
<div style={{ fontWeight: 600 }}>{r.name_raw}</div>
|
||||||
|
|
@ -161,23 +62,30 @@ export default function FoodRecipeEditor({ recipes, learned, onChanged }) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{editing === 'new' && (
|
|
||||||
<RecipeForm learned={learned} submitLabel="Rezept anlegen" onSave={save} onCancel={() => setEditing(null)} />
|
|
||||||
)}
|
|
||||||
{editing && editing !== 'new' && (
|
|
||||||
<RecipeForm
|
|
||||||
key={editing.id}
|
|
||||||
defaultName={draft?.name_raw}
|
|
||||||
defaultPortions={draft?.portions}
|
|
||||||
defaultIngredients={draft?.ingredients}
|
|
||||||
learned={learned}
|
|
||||||
submitLabel="Rezept speichern"
|
|
||||||
onSave={save}
|
|
||||||
onCancel={() => setEditing(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{editing === 'new' && (
|
||||||
|
<FoodRecipeDialog
|
||||||
|
title="Liste anlegen"
|
||||||
|
learned={learned}
|
||||||
|
submitLabel="Liste anlegen"
|
||||||
|
onSave={save}
|
||||||
|
onClose={() => setEditing(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{editing && editing !== 'new' && (
|
||||||
|
<FoodRecipeDialog
|
||||||
|
key={editing.id}
|
||||||
|
title="Liste bearbeiten"
|
||||||
|
defaultName={editing.name_raw}
|
||||||
|
defaultPortions={editing.portions}
|
||||||
|
defaultIngredients={editing.ingredients}
|
||||||
|
learned={learned}
|
||||||
|
submitLabel="Liste speichern"
|
||||||
|
onSave={save}
|
||||||
|
onClose={() => setEditing(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +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 FoodNutrientFields, { foodCreatePayload } from './FoodNutrientFields'
|
import FoodNutrientFields, { foodCreatePayload } from './FoodNutrientFields'
|
||||||
import { RecipeForm } from './FoodRecipeEditor'
|
|
||||||
|
|
||||||
const COUNT_UNITS = new Set(['stück', 'scheibe', 'portion', 'becher', 'tasse'])
|
const COUNT_UNITS = new Set(['stück', 'scheibe', 'portion', 'becher', 'tasse'])
|
||||||
const UNIT_LABEL = {
|
const UNIT_LABEL = {
|
||||||
|
|
@ -26,7 +25,7 @@ function detectUnit(...texts) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FoodSearchModal({ title, initialQuery, quantityHint, allowRecipe, learned, onSelect, onCreateRecipe, onClose }) {
|
export default function FoodSearchModal({ title, initialQuery, quantityHint, allowRecipe, onSelect, onWantRecipe, onClose }) {
|
||||||
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)
|
||||||
|
|
@ -210,30 +209,21 @@ export default function FoodSearchModal({ title, initialQuery, quantityHint, all
|
||||||
{!showCreate ? (
|
{!showCreate ? (
|
||||||
<button type="button" className="btn btn-secondary btn-full" onClick={() => {
|
<button type="button" className="btn btn-secondary btn-full" onClick={() => {
|
||||||
setShowCreate(true)
|
setShowCreate(true)
|
||||||
setCreateKind(allowRecipe && onCreateRecipe ? null : 'food')
|
setCreateKind(allowRecipe && onWantRecipe ? null : 'food')
|
||||||
setManual((s) => ({ ...s, name_de: s.name_de || q || initialQuery || '' }))
|
setManual((s) => ({ ...s, name_de: s.name_de || q || initialQuery || '' }))
|
||||||
}}>
|
}}>
|
||||||
{allowRecipe && onCreateRecipe ? 'Lebensmittel oder Rezept anlegen' : 'Eigenes Lebensmittel anlegen'}
|
{allowRecipe && onWantRecipe ? 'Lebensmittel oder Liste anlegen' : 'Eigenes Lebensmittel anlegen'}
|
||||||
</button>
|
</button>
|
||||||
) : !createKind ? (
|
) : !createKind ? (
|
||||||
<div>
|
<div>
|
||||||
<p style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>Was möchtest du anlegen?</p>
|
<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. Rezept = mehrere Zutaten; offene Zutaten erscheinen danach in der Liste.</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 }}>
|
<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-primary" onClick={() => setCreateKind('food')}>Lebensmittel</button>
|
||||||
<button type="button" className="btn btn-secondary" onClick={() => setCreateKind('recipe')}>Rezept</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>
|
<button type="button" className="btn btn-secondary" onClick={() => { setShowCreate(false); setCreateKind(null) }}>Zurück</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : createKind === 'recipe' ? (
|
|
||||||
<RecipeForm
|
|
||||||
defaultName={manual.name_de || q || initialQuery || ''}
|
|
||||||
learned={learned}
|
|
||||||
disabled={creating}
|
|
||||||
submitLabel="Rezept anlegen und zuordnen"
|
|
||||||
onCancel={() => setCreateKind(null)}
|
|
||||||
onSave={onCreateRecipe}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
<p style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>Eigener Eintrag</p>
|
<p style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>Eigener Eintrag</p>
|
||||||
|
|
@ -253,7 +243,7 @@ export default function FoodSearchModal({ title, initialQuery, quantityHint, all
|
||||||
setServingG={setServingG}
|
setServingG={setServingG}
|
||||||
/>
|
/>
|
||||||
<div style={{ display: 'flex', gap: 8, marginTop: 10 }}>
|
<div style={{ display: 'flex', gap: 8, marginTop: 10 }}>
|
||||||
{allowRecipe && onCreateRecipe && (
|
{allowRecipe && onWantRecipe && (
|
||||||
<button type="button" className="btn btn-secondary" disabled={creating} onClick={() => setCreateKind(null)}>Zurück</button>
|
<button type="button" className="btn btn-secondary" disabled={creating} onClick={() => setCreateKind(null)}>Zurück</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@ import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { api } from '../utils/api'
|
import { api } from '../utils/api'
|
||||||
import FoodSearchModal from './FoodSearchModal'
|
import FoodSearchModal from './FoodSearchModal'
|
||||||
import FoodNutrientFields, { foodCreatePayload } from './FoodNutrientFields'
|
import FoodNutrientFields, { foodCreatePayload } from './FoodNutrientFields'
|
||||||
import FoodRecipeEditor, { RecipeForm } from './FoodRecipeEditor'
|
import FoodRecipeEditor from './FoodRecipeEditor'
|
||||||
|
import FoodRecipeDialog from './FoodRecipeDialog'
|
||||||
|
|
||||||
const PERIODS = [
|
const PERIODS = [
|
||||||
{ days: 14, label: '14 Tage' },
|
{ days: 14, label: '14 Tage' },
|
||||||
|
|
@ -52,14 +53,14 @@ function RecipePickModal({ recipes, sourceName, onPick, onClose }) {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '14px 16px', borderBottom: '1px solid var(--border)' }}>
|
<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>
|
<h2 className="card-title" style={{ margin: 0, fontSize: 16 }}>Eigene Liste wählen</h2>
|
||||||
<button type="button" className="btn btn-secondary" onClick={onClose}>Schließen</button>
|
<button type="button" className="btn btn-secondary" onClick={onClose}>Schließen</button>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ padding: '12px 16px' }}>
|
<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)} />
|
<input className="form-input" style={{ width: '100%', textAlign: 'left' }} autoFocus placeholder="Listenname filtern" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ overflowY: 'auto', padding: '0 16px 16px', flex: 1 }}>
|
<div style={{ overflowY: 'auto', padding: '0 16px 16px', flex: 1 }}>
|
||||||
{filtered.length === 0 && <p style={{ fontSize: 13, color: 'var(--text3)' }}>Kein passendes Rezept.</p>}
|
{filtered.length === 0 && <p style={{ fontSize: 13, color: 'var(--text3)' }}>Keine passende Liste.</p>}
|
||||||
{filtered.map((r) => (
|
{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)}>
|
<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>
|
<span>
|
||||||
|
|
@ -74,7 +75,7 @@ function RecipePickModal({ recipes, sourceName, onPick, onClose }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function InlineCreate({ defaultName, disabled, learned, allowRecipe, onCreateFood, onCreateRecipe }) {
|
function InlineCreate({ defaultName, disabled, allowRecipe, onCreateFood, onWantRecipe }) {
|
||||||
const [kind, setKind] = useState(allowRecipe ? null : 'food')
|
const [kind, setKind] = useState(allowRecipe ? null : 'food')
|
||||||
const [name, setName] = useState(defaultName || '')
|
const [name, setName] = useState(defaultName || '')
|
||||||
const [macros, setMacros] = useState({ kcal: '0', protein_g: '0', fat_g: '0', carbs_g: '0' })
|
const [macros, setMacros] = useState({ kcal: '0', protein_g: '0', fat_g: '0', carbs_g: '0' })
|
||||||
|
|
@ -83,28 +84,16 @@ function InlineCreate({ defaultName, disabled, learned, allowRecipe, onCreateFoo
|
||||||
if (!kind) {
|
if (!kind) {
|
||||||
return (
|
return (
|
||||||
<div style={{ marginTop: 8, padding: 10, background: 'var(--surface2)', borderRadius: 8 }}>
|
<div style={{ marginTop: 8, padding: 10, background: 'var(--surface2)', borderRadius: 8 }}>
|
||||||
<p style={{ fontSize: 12, margin: '0 0 8px', color: 'var(--text2)' }}>Lebensmittel (ein Eintrag im Katalog) oder Rezept (mehrere Zutaten)?</p>
|
<p style={{ fontSize: 12, margin: '0 0 8px', color: 'var(--text2)' }}>Lebensmittel (ein Eintrag im Katalog) oder Liste (Roh-Kombination, z. B. Müsli)?</p>
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||||
<button type="button" className="btn btn-primary" disabled={disabled} onClick={() => setKind('food')}>Lebensmittel</button>
|
<button type="button" className="btn btn-primary" disabled={disabled} onClick={() => setKind('food')}>Lebensmittel</button>
|
||||||
{allowRecipe && (
|
{allowRecipe && (
|
||||||
<button type="button" className="btn btn-secondary" disabled={disabled} onClick={() => setKind('recipe')}>Rezept</button>
|
<button type="button" className="btn btn-secondary" disabled={disabled} onClick={onWantRecipe}>Liste</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (kind === 'recipe') {
|
|
||||||
return (
|
|
||||||
<RecipeForm
|
|
||||||
defaultName={defaultName}
|
|
||||||
learned={learned}
|
|
||||||
disabled={disabled}
|
|
||||||
submitLabel="Rezept anlegen und zuordnen"
|
|
||||||
onCancel={() => setKind(null)}
|
|
||||||
onSave={onCreateRecipe}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<div style={{ marginTop: 8, padding: 10, background: 'var(--surface2)', borderRadius: 8 }}>
|
<div style={{ marginTop: 8, padding: 10, background: 'var(--surface2)', borderRadius: 8 }}>
|
||||||
<p style={{ fontSize: 12, margin: '0 0 8px', color: 'var(--text2)' }}>Neuer Katalogeintrag. Salz: Makros 0. Öl/Supplement: Stoffe wie EPA dazusuchen.</p>
|
<p style={{ fontSize: 12, margin: '0 0 8px', color: 'var(--text2)' }}>Neuer Katalogeintrag. Salz: Makros 0. Öl/Supplement: Stoffe wie EPA dazusuchen.</p>
|
||||||
|
|
@ -142,6 +131,7 @@ export default function NutritionFoodMap({ onChanged, onMapped }) {
|
||||||
const [saving, setSaving] = useState(null)
|
const [saving, setSaving] = useState(null)
|
||||||
const [searchFor, setSearchFor] = useState(null)
|
const [searchFor, setSearchFor] = useState(null)
|
||||||
const [recipeFor, setRecipeFor] = useState(null)
|
const [recipeFor, setRecipeFor] = useState(null)
|
||||||
|
const [recipeDraft, setRecipeDraft] = useState(null)
|
||||||
const [createFor, setCreateFor] = useState(null)
|
const [createFor, setCreateFor] = useState(null)
|
||||||
const [filter, setFilter] = useState('all')
|
const [filter, setFilter] = useState('all')
|
||||||
const [sinceDays, setSinceDays] = useState(28)
|
const [sinceDays, setSinceDays] = useState(28)
|
||||||
|
|
@ -273,7 +263,7 @@ export default function NutritionFoodMap({ onChanged, onMapped }) {
|
||||||
await api.applyNutritionRecipe(rec.id, row.source_name_raw)
|
await api.applyNutritionRecipe(rec.id, row.source_name_raw)
|
||||||
setCreateFor(null)
|
setCreateFor(null)
|
||||||
setSearchFor(null)
|
setSearchFor(null)
|
||||||
setNotice(`Rezept angelegt. Offene Zutaten erscheinen in der Liste.`)
|
setNotice('Liste angelegt. Offene Zutaten erscheinen unten zum Zuordnen.')
|
||||||
await load()
|
await load()
|
||||||
onMapped?.()
|
onMapped?.()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -290,7 +280,7 @@ export default function NutritionFoodMap({ onChanged, onMapped }) {
|
||||||
try {
|
try {
|
||||||
await api.applyNutritionRecipe(recipeId, sourceName)
|
await api.applyNutritionRecipe(recipeId, sourceName)
|
||||||
setRecipeFor(null)
|
setRecipeFor(null)
|
||||||
setNotice('Rezept verknüpft. Offene Zutaten erscheinen in der Liste.')
|
setNotice('Liste verknüpft. Offene Zutaten erscheinen unten zum Zuordnen.')
|
||||||
await load()
|
await load()
|
||||||
onMapped?.()
|
onMapped?.()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -371,7 +361,7 @@ export default function NutritionFoodMap({ onChanged, onMapped }) {
|
||||||
|
|
||||||
<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) }} />
|
<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()}>
|
<button type="button" className="btn btn-secondary btn-full" disabled={importing} onClick={() => listRef.current?.click()}>
|
||||||
{importing ? 'Importiere Listen…' : 'FDDB-Listen / Rezepte importieren'}
|
{importing ? 'Importiere Listen…' : 'FDDB-Listen importieren'}
|
||||||
</button>
|
</button>
|
||||||
<FoodRecipeEditor recipes={recipes} learned={learned} onChanged={() => { load(); onChanged?.() }} />
|
<FoodRecipeEditor recipes={recipes} learned={learned} onChanged={() => { load(); onChanged?.() }} />
|
||||||
<input ref={bundleRef} type="file" accept=".json,application/json" style={{ display: 'none' }} onChange={(e) => { const f = e.target.files?.[0]; e.target.value = ''; if (f) importBundle(f) }} />
|
<input ref={bundleRef} type="file" accept=".json,application/json" style={{ display: 'none' }} onChange={(e) => { const f = e.target.files?.[0]; e.target.value = ''; if (f) importBundle(f) }} />
|
||||||
|
|
@ -422,7 +412,7 @@ export default function NutritionFoodMap({ onChanged, onMapped }) {
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontWeight: 600 }}>{suggestQuery(u.source_name_raw) || u.source_name_raw}</div>
|
<div style={{ fontWeight: 600 }}>{suggestQuery(u.source_name_raw) || u.source_name_raw}</div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--text3)' }}>
|
<div style={{ fontSize: 12, color: 'var(--text3)' }}>
|
||||||
{u.kind === 'recipe_ingredient' ? 'Rezeptzutat' : `${u.count}×`}
|
{u.kind === 'recipe_ingredient' ? 'Listenzutat' : `${u.count}×`}
|
||||||
{u.variant_count > 1 ? ` · ${u.variant_count} Mengen` : ''}
|
{u.variant_count > 1 ? ` · ${u.variant_count} Mengen` : ''}
|
||||||
{u.last_date ? ` · zuletzt ${formatDay(u.last_date)}` : ''}
|
{u.last_date ? ` · zuletzt ${formatDay(u.last_date)}` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -455,24 +445,30 @@ export default function NutritionFoodMap({ onChanged, onMapped }) {
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 8 }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 8 }}>
|
||||||
<button type="button" className="btn btn-secondary" disabled={busyRow} onClick={() => setCreateFor(createFor === key ? null : key)}>
|
<button type="button" className="btn btn-secondary" disabled={busyRow} onClick={() => setCreateFor(createFor === key ? null : key)}>
|
||||||
{createFor === key ? 'Anlegen schließen' : (u.kind === 'recipe_ingredient' ? 'Neu anlegen' : 'Lebensmittel oder Rezept')}
|
{createFor === key ? 'Anlegen schließen' : (u.kind === 'recipe_ingredient' ? 'Neu anlegen' : 'Lebensmittel oder Liste')}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="btn btn-secondary" disabled={busyRow} onClick={() => setSearchFor(u)}>Mehr suchen</button>
|
<button type="button" className="btn btn-secondary" disabled={busyRow} onClick={() => setSearchFor(u)}>Mehr suchen</button>
|
||||||
{u.kind !== 'recipe_ingredient' && u.matching_recipe_id && (
|
{u.kind !== 'recipe_ingredient' && u.matching_recipe_id && (
|
||||||
<button type="button" className="btn btn-secondary" disabled={busyRow} onClick={() => applyRecipe(u.source_name_raw, u.matching_recipe_id)}>Als Rezept</button>
|
<button type="button" className="btn btn-secondary" disabled={busyRow} onClick={() => applyRecipe(u.source_name_raw, u.matching_recipe_id)}>Als Liste</button>
|
||||||
)}
|
)}
|
||||||
{u.kind !== 'recipe_ingredient' && recipes.length > 0 && (
|
{u.kind !== 'recipe_ingredient' && recipes.length > 0 && (
|
||||||
<button type="button" className="btn btn-secondary" disabled={busyRow} onClick={() => setRecipeFor(u)}>Rezept wählen</button>
|
<button type="button" className="btn btn-secondary" disabled={busyRow} onClick={() => setRecipeFor(u)}>Liste wählen</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{createFor === key && (
|
{createFor === key && (
|
||||||
<InlineCreate
|
<InlineCreate
|
||||||
defaultName={suggestQuery(u.source_name_raw) || u.source_name_raw}
|
defaultName={suggestQuery(u.source_name_raw) || u.source_name_raw}
|
||||||
disabled={busyRow}
|
disabled={busyRow}
|
||||||
learned={learned}
|
|
||||||
allowRecipe={u.kind !== 'recipe_ingredient'}
|
allowRecipe={u.kind !== 'recipe_ingredient'}
|
||||||
onCreateFood={(body) => createAndAssign(u, body)}
|
onCreateFood={(body) => createAndAssign(u, body)}
|
||||||
onCreateRecipe={(body) => createRecipeAndAssign(u, body)}
|
onWantRecipe={() => {
|
||||||
|
setCreateFor(null)
|
||||||
|
setSearchFor(null)
|
||||||
|
setRecipeDraft({
|
||||||
|
row: u,
|
||||||
|
defaultName: suggestQuery(u.source_name_raw) || u.source_name_raw,
|
||||||
|
})
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -509,10 +505,31 @@ export default function NutritionFoodMap({ onChanged, onMapped }) {
|
||||||
initialQuery={suggestQuery(searchFor.source_name_raw)}
|
initialQuery={suggestQuery(searchFor.source_name_raw)}
|
||||||
quantityHint={searchFor.sample_quantity_raw || searchFor.source_name_raw}
|
quantityHint={searchFor.sample_quantity_raw || searchFor.source_name_raw}
|
||||||
allowRecipe={searchFor.kind !== 'recipe_ingredient'}
|
allowRecipe={searchFor.kind !== 'recipe_ingredient'}
|
||||||
learned={learned}
|
|
||||||
onClose={() => setSearchFor(null)}
|
onClose={() => setSearchFor(null)}
|
||||||
onSelect={(food, extras) => assign(searchFor, food.id, extras, food)}
|
onSelect={(food, extras) => assign(searchFor, food.id, extras, food)}
|
||||||
onCreateRecipe={searchFor.kind === 'recipe_ingredient' ? undefined : (body) => createRecipeAndAssign(searchFor, body)}
|
onWantRecipe={searchFor.kind === 'recipe_ingredient' ? undefined : () => {
|
||||||
|
const row = searchFor
|
||||||
|
setSearchFor(null)
|
||||||
|
setCreateFor(null)
|
||||||
|
setRecipeDraft({
|
||||||
|
row,
|
||||||
|
defaultName: suggestQuery(row.source_name_raw) || row.source_name_raw,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{recipeDraft && (
|
||||||
|
<FoodRecipeDialog
|
||||||
|
title="Liste anlegen"
|
||||||
|
defaultName={recipeDraft.defaultName}
|
||||||
|
learned={learned}
|
||||||
|
disabled={saving === recipeDraft.row.source_name_raw}
|
||||||
|
submitLabel="Liste anlegen und zuordnen"
|
||||||
|
onClose={() => setRecipeDraft(null)}
|
||||||
|
onSave={async (body) => {
|
||||||
|
await createRecipeAndAssign(recipeDraft.row, body)
|
||||||
|
setRecipeDraft(null)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{recipeFor && (
|
{recipeFor && (
|
||||||
|
|
|
||||||
|
|
@ -279,6 +279,7 @@ export const api = {
|
||||||
putNutritionDayMark: (date, d) => req(`/nutrition/days/${date}/mark`, jput(d)),
|
putNutritionDayMark: (date, d) => req(`/nutrition/days/${date}/mark`, jput(d)),
|
||||||
deleteNutritionDayMark: (date) => req(`/nutrition/days/${date}/mark`, {method:'DELETE'}),
|
deleteNutritionDayMark: (date) => req(`/nutrition/days/${date}/mark`, {method:'DELETE'}),
|
||||||
resolveNutritionConflicts: (decisions) => req('/nutrition/import-conflicts/resolve', json({decisions})),
|
resolveNutritionConflicts: (decisions) => req('/nutrition/import-conflicts/resolve', json({decisions})),
|
||||||
|
listFoodUnits: () => req('/bls/units'),
|
||||||
searchBlsFoods: (q, limit=20, signal) => req(`/bls/foods?q=${encodeURIComponent(q||'')}&limit=${limit}`, signal ? { signal } : {}),
|
searchBlsFoods: (q, limit=20, signal) => req(`/bls/foods?q=${encodeURIComponent(q||'')}&limit=${limit}`, signal ? { signal } : {}),
|
||||||
suggestFoodsBatch: (names, limit=3) => req('/bls/foods/suggest-batch', json({ names, limit })),
|
suggestFoodsBatch: (names, limit=3) => req('/bls/foods/suggest-batch', json({ names, limit })),
|
||||||
createUserFood: (d) => req('/bls/foods/manual', json(d)),
|
createUserFood: (d) => req('/bls/foods/manual', json(d)),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user