feat: Listen-Tab und sichtbare Zutatenlisten-Verwaltung
Listen anlegen, bearbeiten und importieren liegt unter Ernährung. Tagebuchzeilen werden auch mit Mengen-Prefix verknüpft. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
3b46630a37
commit
b37005be01
|
|
@ -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.
|
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.
|
||||||
|
|
||||||
## Listen / Kombinationen (Mitai) vs. Gerichte (Tandoor)
|
## Listen / Kombinationen (Mitai) vs. Gerichte (Tandoor)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ FDDB: Items persistieren; `nutrition_log` nur bei leerem Tag oder laut Policy /
|
||||||
- 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).
|
- 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}`; `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.
|
||||||
- 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):** Dialog mit Zutatensuche (Mappings + Katalog). Menge + Einheit; Umrechnung auf Gramm. Rohmischung ohne Kochschwund. **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). 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}`.
|
||||||
- **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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,28 @@ def normalize_food_name(raw: str | None) -> str:
|
||||||
return s.lower()
|
return s.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def item_name_keys(raw: str | None, stored_norm: str | None = None) -> set[str]:
|
||||||
|
"""Compare diary names to list names despite stored quantity prefixes."""
|
||||||
|
keys: set[str] = set()
|
||||||
|
for v in (raw, stored_norm):
|
||||||
|
if not v:
|
||||||
|
continue
|
||||||
|
text = str(v).strip()
|
||||||
|
if text:
|
||||||
|
keys.add(text.lower())
|
||||||
|
norm = normalize_food_name(text)
|
||||||
|
if norm:
|
||||||
|
keys.add(norm)
|
||||||
|
return keys
|
||||||
|
|
||||||
|
|
||||||
|
def names_match_list(raw: str | None, stored_norm: str | None, list_norm: str | None) -> bool:
|
||||||
|
target = normalize_food_name(list_norm) if list_norm else ""
|
||||||
|
if not target:
|
||||||
|
return False
|
||||||
|
return target in item_name_keys(raw, stored_norm)
|
||||||
|
|
||||||
|
|
||||||
_LIST_COMMA_RE = re.compile(r",(?!\s*\d)")
|
_LIST_COMMA_RE = re.compile(r",(?!\s*\d)")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -130,6 +152,29 @@ def fetch_unmapped_rows(cur, profile_id: str) -> list[dict[str, Any]]:
|
||||||
(profile_id,),
|
(profile_id,),
|
||||||
)
|
)
|
||||||
diary = [dict(r) | {"kind": "diary"} for r in cur.fetchall()]
|
diary = [dict(r) | {"kind": "diary"} for r in cur.fetchall()]
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, name_raw, name_normalized
|
||||||
|
FROM food_recipes
|
||||||
|
WHERE profile_id = %s
|
||||||
|
""",
|
||||||
|
(profile_id,),
|
||||||
|
)
|
||||||
|
lists = [dict(r) for r in cur.fetchall()]
|
||||||
|
for row in diary:
|
||||||
|
keys = item_name_keys(row.get("source_name_raw"), row.get("source_name_normalized"))
|
||||||
|
hit = next(
|
||||||
|
(
|
||||||
|
rec
|
||||||
|
for rec in lists
|
||||||
|
if rec.get("name_normalized") in keys
|
||||||
|
or normalize_food_name(rec.get("name_raw")) in keys
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if hit:
|
||||||
|
row["matching_recipe_id"] = str(hit["id"])
|
||||||
|
row["matching_recipe_name"] = hit["name_raw"]
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT i.source_name_raw, i.source_name_normalized,
|
SELECT i.source_name_raw, i.source_name_normalized,
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ from typing import Any
|
||||||
|
|
||||||
from data_layer.food_mapping import (
|
from data_layer.food_mapping import (
|
||||||
get_food_mapping_with_cursor,
|
get_food_mapping_with_cursor,
|
||||||
|
names_match_list,
|
||||||
normalize_food_name,
|
normalize_food_name,
|
||||||
resolve_quantity,
|
resolve_quantity,
|
||||||
)
|
)
|
||||||
|
|
@ -100,29 +101,49 @@ def upsert_recipes(cur, profile_id: str, recipes: list[dict[str, Any]]) -> dict[
|
||||||
|
|
||||||
|
|
||||||
def link_recipes_to_items(cur, profile_id: str) -> tuple[int, list[str]]:
|
def link_recipes_to_items(cur, profile_id: str) -> tuple[int, list[str]]:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id, name_raw, name_normalized FROM food_recipes WHERE profile_id = %s",
|
||||||
|
(profile_id,),
|
||||||
|
)
|
||||||
|
recipes = [dict(r) for r in cur.fetchall()]
|
||||||
|
if not recipes:
|
||||||
|
return 0, []
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT DISTINCT i.date::text AS date
|
SELECT id, date::text AS date, source_name_raw, source_name_normalized
|
||||||
FROM nutrition_items i
|
FROM nutrition_items
|
||||||
JOIN food_recipes r ON r.profile_id = i.profile_id
|
WHERE profile_id = %s AND food_id IS NULL AND recipe_id IS NULL
|
||||||
AND i.source_name_normalized = r.name_normalized
|
|
||||||
WHERE i.profile_id = %s AND i.food_id IS NULL
|
|
||||||
""",
|
""",
|
||||||
(profile_id,),
|
(profile_id,),
|
||||||
)
|
)
|
||||||
dates = [r["date"] for r in cur.fetchall()]
|
by_rid: dict[str, list[str]] = {}
|
||||||
cur.execute(
|
dates: set[str] = set()
|
||||||
"""
|
for item in cur.fetchall():
|
||||||
UPDATE nutrition_items i
|
rid = next(
|
||||||
SET recipe_id = r.id, updated_at = NOW()
|
(
|
||||||
FROM food_recipes r
|
str(rec["id"])
|
||||||
WHERE i.profile_id = %s AND r.profile_id = %s
|
for rec in recipes
|
||||||
AND i.source_name_normalized = r.name_normalized
|
if names_match_list(item.get("source_name_raw"), item.get("source_name_normalized"), rec.get("name_normalized"))
|
||||||
AND i.food_id IS NULL
|
),
|
||||||
""",
|
None,
|
||||||
(profile_id, profile_id),
|
)
|
||||||
)
|
if not rid:
|
||||||
return cur.rowcount or 0, dates
|
continue
|
||||||
|
by_rid.setdefault(rid, []).append(str(item["id"]))
|
||||||
|
if item.get("date"):
|
||||||
|
dates.add(str(item["date"]))
|
||||||
|
linked = 0
|
||||||
|
for rid, ids in by_rid.items():
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE nutrition_items
|
||||||
|
SET recipe_id = %s, updated_at = NOW()
|
||||||
|
WHERE id = ANY(%s::uuid[])
|
||||||
|
""",
|
||||||
|
(rid, ids),
|
||||||
|
)
|
||||||
|
linked += cur.rowcount or 0
|
||||||
|
return linked, list(dates)
|
||||||
|
|
||||||
|
|
||||||
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]]:
|
||||||
|
|
@ -292,13 +313,29 @@ def delete_recipe(cur, profile_id: str, recipe_id: str) -> None:
|
||||||
|
|
||||||
|
|
||||||
def apply_recipe_to_items(cur, profile_id: str, source_name_normalized: str, recipe_id: str) -> int:
|
def apply_recipe_to_items(cur, profile_id: str, source_name_normalized: str, recipe_id: str) -> int:
|
||||||
|
target = normalize_food_name(source_name_normalized) or (source_name_normalized or "")
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, source_name_raw, source_name_normalized
|
||||||
|
FROM nutrition_items
|
||||||
|
WHERE profile_id = %s AND food_id IS NULL
|
||||||
|
""",
|
||||||
|
(profile_id,),
|
||||||
|
)
|
||||||
|
ids = [
|
||||||
|
str(row["id"])
|
||||||
|
for row in cur.fetchall()
|
||||||
|
if names_match_list(row.get("source_name_raw"), row.get("source_name_normalized"), target)
|
||||||
|
]
|
||||||
|
if not ids:
|
||||||
|
return 0
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE nutrition_items
|
UPDATE nutrition_items
|
||||||
SET recipe_id = %s, food_id = NULL, mapping_id = NULL, value_origin = 'fddb', updated_at = NOW()
|
SET recipe_id = %s, food_id = NULL, mapping_id = NULL, value_origin = 'fddb', updated_at = NOW()
|
||||||
WHERE profile_id = %s AND source_name_normalized = %s
|
WHERE id = ANY(%s::uuid[])
|
||||||
""",
|
""",
|
||||||
(recipe_id, profile_id, source_name_normalized),
|
(recipe_id, ids),
|
||||||
)
|
)
|
||||||
return cur.rowcount or 0
|
return cur.rowcount or 0
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -504,7 +504,18 @@ def apply_recipe_name(
|
||||||
if not cur.fetchone():
|
if not cur.fetchone():
|
||||||
raise HTTPException(404, "Rezept nicht gefunden")
|
raise HTTPException(404, "Rezept nicht gefunden")
|
||||||
n = apply_recipe_to_items(cur, pid, norm, recipe_id)
|
n = apply_recipe_to_items(cur, pid, norm, recipe_id)
|
||||||
for d in dates_for_normalized_name(cur, pid, norm):
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT DISTINCT date::text AS date
|
||||||
|
FROM nutrition_items
|
||||||
|
WHERE profile_id = %s AND recipe_id = %s
|
||||||
|
""",
|
||||||
|
(pid, recipe_id),
|
||||||
|
)
|
||||||
|
days = [r["date"] for r in cur.fetchall()]
|
||||||
|
if not days:
|
||||||
|
days = dates_for_normalized_name(cur, pid, norm)
|
||||||
|
for d in days:
|
||||||
rebuild_daily_nutrients(cur, pid, d)
|
rebuild_daily_nutrients(cur, pid, d)
|
||||||
return {"ok": True, "items_updated": n}
|
return {"ok": True, "items_updated": n}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ from data_layer.food_mapping import (
|
||||||
list_quantity_units,
|
list_quantity_units,
|
||||||
list_unmapped_payload,
|
list_unmapped_payload,
|
||||||
merge_unmapped_rows,
|
merge_unmapped_rows,
|
||||||
|
names_match_list,
|
||||||
normalize_food_name,
|
normalize_food_name,
|
||||||
parse_quantity,
|
parse_quantity,
|
||||||
parse_quantity_g,
|
parse_quantity_g,
|
||||||
|
|
@ -104,6 +105,7 @@ def test_fetch_unmapped_keeps_ingredient_dates_and_skips_mapped():
|
||||||
cur = _FakeCur([
|
cur = _FakeCur([
|
||||||
[{"source_name_normalized": "haferflocken"}],
|
[{"source_name_normalized": "haferflocken"}],
|
||||||
[],
|
[],
|
||||||
|
[],
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"source_name_raw": "Salz",
|
"source_name_raw": "Salz",
|
||||||
|
|
@ -129,8 +131,15 @@ def test_fetch_unmapped_keeps_ingredient_dates_and_skips_mapped():
|
||||||
assert rows[0]["kind"] == "recipe_ingredient"
|
assert rows[0]["kind"] == "recipe_ingredient"
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_name_matches_despite_quantity_prefix():
|
||||||
|
assert names_match_list("50 g Müsli Mix", "50 g müsli mix", "müsli mix")
|
||||||
|
assert names_match_list("Müsli Mix", "müsli mix", "müsli mix")
|
||||||
|
assert not names_match_list("Haferflocken", "haferflocken", "müsli mix")
|
||||||
|
|
||||||
|
|
||||||
def test_unmapped_meta_includes_recent_ingredients():
|
def test_unmapped_meta_includes_recent_ingredients():
|
||||||
batches = [
|
batches = [
|
||||||
|
[],
|
||||||
[],
|
[],
|
||||||
[],
|
[],
|
||||||
[{
|
[{
|
||||||
|
|
|
||||||
|
|
@ -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.3", # Unmapped ohne Kreuzjoin; Listen-Import Rebuild async
|
"nutrition": "1.3.4", # Listen-Tab; Verknüpfen trotz Mengen-Prefix; Liste wählen ohne Vorfilter
|
||||||
"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",
|
||||||
|
|
|
||||||
|
|
@ -24,3 +24,4 @@ Verlässliche Lebensmittel-Stammdaten (BLS 4.0 + manuelle Erweiterung), lernende
|
||||||
- Tandoor: persönlicher URL+Token in Einstellungen, Verbindungstest (kein Import)
|
- Tandoor: persönlicher URL+Token in Einstellungen, Verbindungstest (kein Import)
|
||||||
- Folge: Tandoor-Gerichte live lesen + BLS-Mapping + `cooked_yield_g` — kein Zutaten-Vollsync
|
- Folge: Tandoor-Gerichte live lesen + BLS-Mapping + `cooked_yield_g` — kein Zutaten-Vollsync
|
||||||
- Fix 2026-09-12: Offene Liste war leer, weil der 28-Tage-Filter Zutaten ohne `last_date` verwarf und die Unmapped-Query Listen×Tagebuch kreuzte (Minuten). Import wartete auf synchrone Tages-Rebuilds.
|
- Fix 2026-09-12: Offene Liste war leer, weil der 28-Tage-Filter Zutaten ohne `last_date` verwarf und die Unmapped-Query Listen×Tagebuch kreuzte (Minuten). Import wartete auf synchrone Tages-Rebuilds.
|
||||||
|
- Fix 2026-09-13: Listen-Tab (CRUD + FDDB-Import); „Liste wählen“ ohne Vorfilter auf den Eintragsnamen; Verknüpfen trotz Mengen-Prefix; Kennzeichnung „Liste: …“.
|
||||||
|
|
|
||||||
|
|
@ -1,76 +1,170 @@
|
||||||
import { useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { api } from '../utils/api'
|
import { api } from '../utils/api'
|
||||||
import FoodRecipeDialog from './FoodRecipeDialog'
|
import FoodRecipeDialog from './FoodRecipeDialog'
|
||||||
|
|
||||||
export default function FoodRecipeEditor({ recipes, learned, onChanged }) {
|
export default function FoodRecipeEditor({ learned: learnedProp, highlightId, onChanged }) {
|
||||||
const [open, setOpen] = useState(false)
|
const [recipes, setRecipes] = useState([])
|
||||||
|
const [learned, setLearned] = useState(learnedProp || [])
|
||||||
|
const [q, setQ] = useState('')
|
||||||
|
const [visible, setVisible] = useState(40)
|
||||||
const [editing, setEditing] = useState(null)
|
const [editing, setEditing] = useState(null)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
|
const [notice, setNotice] = useState(null)
|
||||||
|
const [importing, setImporting] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const listRef = useRef(null)
|
||||||
|
const highlightRef = useRef(null)
|
||||||
|
const openedHighlight = useRef(null)
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const [r, m] = await Promise.all([
|
||||||
|
api.listNutritionRecipes(true),
|
||||||
|
learnedProp ? Promise.resolve(learnedProp) : api.listMyFoodMappings().catch(() => []),
|
||||||
|
])
|
||||||
|
setRecipes(Array.isArray(r) ? r : [])
|
||||||
|
setLearned(Array.isArray(m) ? m : (learnedProp || []))
|
||||||
|
setError(null)
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { load() }, [])
|
||||||
|
useEffect(() => { if (learnedProp) setLearned(learnedProp) }, [learnedProp])
|
||||||
|
|
||||||
const startNew = () => {
|
const startNew = () => {
|
||||||
setEditing('new')
|
setEditing('new')
|
||||||
setError(null)
|
setError(null)
|
||||||
setOpen(true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const startEdit = async (r) => {
|
const startEdit = async (r) => {
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const full = (r.ingredients && r.ingredients.length) || r.ingredient_count === 0
|
const full = (r.ingredients && r.ingredients.length)
|
||||||
? r
|
? r
|
||||||
: await api.getNutritionRecipe(r.id)
|
: await api.getNutritionRecipe(r.id)
|
||||||
setEditing(full)
|
setEditing(full)
|
||||||
setOpen(true)
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e.message)
|
setError(e.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!highlightId || !recipes.length) return
|
||||||
|
highlightRef.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||||
|
if (openedHighlight.current === highlightId) return
|
||||||
|
const r = recipes.find((x) => x.id === highlightId)
|
||||||
|
if (!r) return
|
||||||
|
openedHighlight.current = highlightId
|
||||||
|
startEdit(r)
|
||||||
|
}, [highlightId, recipes])
|
||||||
|
|
||||||
|
const filtered = recipes.filter((r) => {
|
||||||
|
const term = q.trim().toLowerCase()
|
||||||
|
if (!term) return true
|
||||||
|
return `${r.name_raw || ''} ${r.name_normalized || ''}`.toLowerCase().includes(term)
|
||||||
|
})
|
||||||
|
const shown = filtered.slice(0, visible)
|
||||||
|
|
||||||
const save = async (body) => {
|
const save = async (body) => {
|
||||||
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)
|
||||||
|
await load()
|
||||||
onChanged?.()
|
onChanged?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
const remove = async (id) => {
|
const remove = async (id) => {
|
||||||
if (!confirm('Liste wirklich löschen?')) return
|
if (!confirm('Liste wirklich löschen? Tagebuchzeilen fallen dann wieder unter Zuordnen.')) 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)
|
||||||
|
await load()
|
||||||
onChanged?.()
|
onChanged?.()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e.message)
|
setError(e.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const importLists = async (file) => {
|
||||||
|
if (!file) return
|
||||||
|
setImporting(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await api.importFddbLists(file)
|
||||||
|
await load()
|
||||||
|
onChanged?.()
|
||||||
|
const pending = res.rebuild_pending ? ' Tageswerte werden im Hintergrund nachgerechnet.' : ''
|
||||||
|
setNotice(`${res.recipes} Listen importiert, ${res.items_linked || 0} Tagebuchzeilen verknüpft.${pending}`)
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message)
|
||||||
|
} finally {
|
||||||
|
setImporting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ marginTop: 16 }}>
|
<div className="card section-gap">
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8 }}>
|
<div className="card-title">Zutatenlisten</div>
|
||||||
<h3 style={{ fontSize: 14, margin: 0 }}>Eigene Listen / Kombinationen ({recipes.length})</h3>
|
<p style={{ fontSize: 13, color: 'var(--text2)', lineHeight: 1.6, marginBottom: 12 }}>
|
||||||
<button type="button" className="btn btn-secondary" style={{ fontSize: 12 }} onClick={() => { setOpen((v) => !v); if (open) setEditing(null) }}>
|
Roh-Kombinationen (Müsli, Bowl, Smoothie) — nicht gekochte Gerichte.
|
||||||
{open ? 'Einklappen' : 'Anzeigen'}
|
Import aus FDDB oder selbst anlegen. Zutaten danach unter Zuordnen mappen.
|
||||||
|
</p>
|
||||||
|
{error && <div style={{ color: 'var(--danger)', fontSize: 13, marginBottom: 10 }}>{error}</div>}
|
||||||
|
{notice && <div style={{ fontSize: 13, color: 'var(--accent-dark)', marginBottom: 10 }}>{notice}</div>}
|
||||||
|
|
||||||
|
<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) }} />
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 12 }}>
|
||||||
|
<button type="button" className="btn btn-primary" onClick={startNew}>Liste anlegen</button>
|
||||||
|
<button type="button" className="btn btn-secondary" disabled={importing} onClick={() => listRef.current?.click()}>
|
||||||
|
{importing ? 'Importiere…' : 'FDDB-Listen importieren'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{open && (
|
|
||||||
<div style={{ marginTop: 8 }}>
|
<p style={{ fontSize: 13, margin: '0 0 8px' }}>{loading ? 'Lade Listen…' : `${recipes.length} Listen`}</p>
|
||||||
{error && <div style={{ color: 'var(--danger)', fontSize: 13, marginBottom: 8 }}>{error}</div>}
|
{recipes.length > 8 && (
|
||||||
<button type="button" className="btn btn-secondary btn-full" onClick={startNew}>Liste anlegen</button>
|
<input
|
||||||
{recipes.map((r) => (
|
className="form-input"
|
||||||
<div key={r.id} style={{ borderTop: '1px solid var(--border)', padding: '8px 0', marginTop: 8 }}>
|
style={{ width: '100%', textAlign: 'left', marginBottom: 8 }}
|
||||||
<div style={{ fontWeight: 600 }}>{r.name_raw}</div>
|
placeholder="Liste suchen…"
|
||||||
<div style={{ fontSize: 12, color: 'var(--text3)' }}>
|
value={q}
|
||||||
{r.ingredient_count ?? (r.ingredients || []).length} Zutaten · {r.portions || 1} Portionen
|
onChange={(e) => { setQ(e.target.value); setVisible(40) }}
|
||||||
{r.source === 'manual' ? ' · selbst angelegt' : ''}
|
/>
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: 8, marginTop: 6 }}>
|
|
||||||
<button type="button" className="btn btn-secondary" style={{ fontSize: 12 }} onClick={() => startEdit(r)}>Bearbeiten</button>
|
|
||||||
<button type="button" className="btn btn-secondary" style={{ fontSize: 12 }} onClick={() => remove(r.id)}>Löschen</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
{!loading && recipes.length === 0 && (
|
||||||
|
<p className="muted">Noch keine Listen. FDDB-Export (`lists_*.csv`) importieren oder eine neue anlegen.</p>
|
||||||
|
)}
|
||||||
|
{shown.map((r) => (
|
||||||
|
<div
|
||||||
|
key={r.id}
|
||||||
|
ref={r.id === highlightId ? highlightRef : undefined}
|
||||||
|
style={{
|
||||||
|
borderTop: '1px solid var(--border)',
|
||||||
|
padding: '10px 0',
|
||||||
|
background: r.id === highlightId ? 'var(--surface2)' : undefined,
|
||||||
|
borderRadius: r.id === highlightId ? 8 : 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 600 }}>{r.name_raw}</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text3)' }}>
|
||||||
|
{r.ingredient_count ?? (r.ingredients || []).length} Zutaten · {r.portions || 1} Portionen
|
||||||
|
{r.source === 'fddb_list' ? ' · FDDB' : r.source === 'manual' ? ' · selbst angelegt' : ''}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginTop: 6 }}>
|
||||||
|
<button type="button" className="btn btn-secondary" style={{ fontSize: 12 }} onClick={() => startEdit(r)}>Bearbeiten</button>
|
||||||
|
<button type="button" className="btn btn-secondary" style={{ fontSize: 12 }} onClick={() => remove(r.id)}>Löschen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{visible < filtered.length && (
|
||||||
|
<button type="button" className="btn btn-secondary btn-full" style={{ marginTop: 8 }} onClick={() => setVisible((n) => n + 40)}>
|
||||||
|
Weitere {Math.min(40, filtered.length - visible)} zeigen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{editing === 'new' && (
|
{editing === 'new' && (
|
||||||
<FoodRecipeDialog
|
<FoodRecipeDialog
|
||||||
title="Liste anlegen"
|
title="Liste anlegen"
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ 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 from './FoodRecipeEditor'
|
|
||||||
import FoodRecipeDialog from './FoodRecipeDialog'
|
import FoodRecipeDialog from './FoodRecipeDialog'
|
||||||
|
|
||||||
const PERIODS = [
|
const PERIODS = [
|
||||||
|
|
@ -28,7 +27,8 @@ function suggestQuery(raw) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function RecipePickModal({ recipes, sourceName, onPick, onClose }) {
|
function RecipePickModal({ recipes, sourceName, onPick, onClose }) {
|
||||||
const [q, setQ] = useState(suggestQuery(sourceName))
|
const [q, setQ] = useState('')
|
||||||
|
const hint = suggestQuery(sourceName)
|
||||||
const filtered = recipes.filter((r) => {
|
const filtered = recipes.filter((r) => {
|
||||||
const hay = `${r.name_raw || ''} ${r.name_normalized || ''}`.toLowerCase()
|
const hay = `${r.name_raw || ''} ${r.name_normalized || ''}`.toLowerCase()
|
||||||
return !q.trim() || hay.includes(q.trim().toLowerCase())
|
return !q.trim() || hay.includes(q.trim().toLowerCase())
|
||||||
|
|
@ -53,14 +53,19 @@ 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 }}>Eigene Liste wählen</h2>
|
<h2 className="card-title" style={{ margin: 0, fontSize: 16 }}>Liste verknüpfen</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="Listenname filtern" value={q} onChange={(e) => setQ(e.target.value)} />
|
<p style={{ fontSize: 13, color: 'var(--text2)', lineHeight: 1.5, margin: '0 0 10px' }}>
|
||||||
|
Diese Tagebuchzeile{hint ? ` („${hint}“)` : ''} einer bestehenden Zutatenliste zuordnen.
|
||||||
|
Der Eintrag verschwindet danach hier; die Zutaten der Liste ordnest du einzeln zu.
|
||||||
|
</p>
|
||||||
|
<input className="form-input" style={{ width: '100%', textAlign: 'left' }} autoFocus placeholder="Alle Listen durchsuchen…" 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)' }}>Keine passende Liste.</p>}
|
{recipes.length === 0 && <p style={{ fontSize: 13, color: 'var(--text3)' }}>Noch keine Listen. Unter „Listen“ anlegen oder FDDB importieren.</p>}
|
||||||
|
{recipes.length > 0 && filtered.length === 0 && <p style={{ fontSize: 13, color: 'var(--text3)' }}>Keine Liste passt zum Filter.</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>
|
||||||
|
|
@ -122,7 +127,7 @@ function InlineCreate({ defaultName, disabled, allowRecipe, onCreateFood, onWant
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function NutritionFoodMap({ onChanged, onMapped }) {
|
export default function NutritionFoodMap({ onChanged, onMapped, onManageLists, onCreatedList }) {
|
||||||
const [unmapped, setUnmapped] = useState([])
|
const [unmapped, setUnmapped] = useState([])
|
||||||
const [learned, setLearned] = useState([])
|
const [learned, setLearned] = useState([])
|
||||||
const [recipes, setRecipes] = useState([])
|
const [recipes, setRecipes] = useState([])
|
||||||
|
|
@ -263,8 +268,10 @@ 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('Liste angelegt. Offene Zutaten erscheinen unten zum Zuordnen.')
|
setUnmapped((list) => list.filter((x) => x.source_name_normalized !== row.source_name_normalized))
|
||||||
await load()
|
setNotice('Liste angelegt und mit der Tagebuchzeile verknüpft. Zutaten erscheinen unter Zuordnen, die Liste unter Listen.')
|
||||||
|
onCreatedList?.(rec)
|
||||||
|
if (!onCreatedList) await load()
|
||||||
onMapped?.()
|
onMapped?.()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e.message)
|
setError(e.message)
|
||||||
|
|
@ -280,7 +287,8 @@ export default function NutritionFoodMap({ onChanged, onMapped }) {
|
||||||
try {
|
try {
|
||||||
await api.applyNutritionRecipe(recipeId, sourceName)
|
await api.applyNutritionRecipe(recipeId, sourceName)
|
||||||
setRecipeFor(null)
|
setRecipeFor(null)
|
||||||
setNotice('Liste verknüpft. Offene Zutaten erscheinen unten zum Zuordnen.')
|
setUnmapped((list) => list.filter((x) => x.source_name_raw !== sourceName && x.source_name_normalized !== sourceName))
|
||||||
|
setNotice('Liste verknüpft. Offene Zutaten der Liste erscheinen hier zum Zuordnen.')
|
||||||
await load()
|
await load()
|
||||||
onMapped?.()
|
onMapped?.()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -361,10 +369,15 @@ export default function NutritionFoodMap({ onChanged, onMapped }) {
|
||||||
{notice && <div style={{ fontSize: 13, color: 'var(--accent-dark)', marginBottom: 10 }}>{notice}</div>}
|
{notice && <div style={{ fontSize: 13, color: 'var(--accent-dark)', marginBottom: 10 }}>{notice}</div>}
|
||||||
|
|
||||||
<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()}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 8 }}>
|
||||||
{importing ? 'Importiere Listen…' : 'FDDB-Listen importieren'}
|
<button type="button" className="btn btn-primary" onClick={() => onManageLists?.()}>Listen verwalten</button>
|
||||||
</button>
|
<button type="button" className="btn btn-secondary" disabled={importing} onClick={() => listRef.current?.click()}>
|
||||||
<FoodRecipeEditor recipes={recipes} learned={learned} onChanged={() => { load(); onChanged?.() }} />
|
{importing ? 'Importiere Listen…' : 'FDDB-Listen importieren'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--text3)', margin: '0 0 12px' }}>
|
||||||
|
{recipes.length ? `${recipes.length} Listen vorhanden.` : 'Noch keine Listen.'} Anlegen, bearbeiten und FDDB-Import unter Listen.
|
||||||
|
</p>
|
||||||
<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) }} />
|
||||||
<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={busy} onClick={exportBundle}>Zuordnungen & Listen exportieren</button>
|
<button type="button" className="btn btn-secondary" disabled={busy} onClick={exportBundle}>Zuordnungen & Listen exportieren</button>
|
||||||
|
|
@ -414,9 +427,15 @@ export default function NutritionFoodMap({ onChanged, onMapped }) {
|
||||||
<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' ? 'Listenzutat' : `${u.count}×`}
|
{u.kind === 'recipe_ingredient' ? 'Listenzutat' : `${u.count}×`}
|
||||||
|
{u.matching_recipe_id ? ` · Liste${u.matching_recipe_name ? `: ${u.matching_recipe_name}` : ''}` : ''}
|
||||||
{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>
|
||||||
|
{u.kind !== 'recipe_ingredient' && u.matching_recipe_id && (
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--accent-dark)', marginTop: 4 }}>
|
||||||
|
Entspricht einer Zutatenliste — verknüpfen, dann die Zutaten zuordnen.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{(u.ambiguous || suggestions.length > 1) && (
|
{(u.ambiguous || suggestions.length > 1) && (
|
||||||
<span style={{ fontSize: 11, padding: '2px 8px', borderRadius: 999, background: 'var(--surface2)', color: 'var(--danger)', whiteSpace: 'nowrap' }}>
|
<span style={{ fontSize: 11, padding: '2px 8px', borderRadius: 999, background: 'var(--surface2)', color: 'var(--danger)', whiteSpace: 'nowrap' }}>
|
||||||
|
|
@ -450,10 +469,13 @@ export default function NutritionFoodMap({ onChanged, onMapped }) {
|
||||||
</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 Liste</button>
|
<button type="button" className="btn btn-primary" disabled={busyRow} onClick={() => applyRecipe(u.source_name_raw, u.matching_recipe_id)}>Als Liste verknüpfen</button>
|
||||||
)}
|
)}
|
||||||
{u.kind !== 'recipe_ingredient' && recipes.length > 0 && (
|
{u.kind !== 'recipe_ingredient' && u.matching_recipe_id && (
|
||||||
<button type="button" className="btn btn-secondary" disabled={busyRow} onClick={() => setRecipeFor(u)}>Liste wählen</button>
|
<button type="button" className="btn btn-secondary" disabled={busyRow} onClick={() => onManageLists?.(u.matching_recipe_id)}>Liste öffnen</button>
|
||||||
|
)}
|
||||||
|
{u.kind !== 'recipe_ingredient' && (
|
||||||
|
<button type="button" className="btn btn-secondary" disabled={busyRow} onClick={() => setRecipeFor(u)}>Andere Liste wählen</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{createFor === key && (
|
{createFor === key && (
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react'
|
||||||
import { Upload, CheckCircle, TrendingUp, Info } from 'lucide-react'
|
import { Upload, CheckCircle, TrendingUp, Info } from 'lucide-react'
|
||||||
import UsageBadge from '../components/UsageBadge'
|
import UsageBadge from '../components/UsageBadge'
|
||||||
import NutritionFoodMap, { DayMarkButtons } from '../components/NutritionFoodMap'
|
import NutritionFoodMap, { DayMarkButtons } from '../components/NutritionFoodMap'
|
||||||
|
import FoodRecipeEditor from '../components/FoodRecipeEditor'
|
||||||
import {
|
import {
|
||||||
LineChart, Line, BarChart, Bar, XAxis, YAxis, Tooltip,
|
LineChart, Line, BarChart, Bar, XAxis, YAxis, Tooltip,
|
||||||
ResponsiveContainer, CartesianGrid, Legend, ReferenceLine, ScatterChart, Scatter
|
ResponsiveContainer, CartesianGrid, Legend, ReferenceLine, ScatterChart, Scatter
|
||||||
|
|
@ -900,6 +901,7 @@ export default function NutritionPage() {
|
||||||
const [nutritionUsage, setNutritionUsage] = useState(null)
|
const [nutritionUsage, setNutritionUsage] = useState(null)
|
||||||
const [unmappedCount, setUnmappedCount] = useState(0)
|
const [unmappedCount, setUnmappedCount] = useState(0)
|
||||||
const [unmappedTotal, setUnmappedTotal] = useState(0)
|
const [unmappedTotal, setUnmappedTotal] = useState(0)
|
||||||
|
const [focusListId, setFocusListId] = useState(null)
|
||||||
|
|
||||||
const loadUsage = () => {
|
const loadUsage = () => {
|
||||||
nutritionApi.getFeatureUsage().then(features => {
|
nutritionApi.getFeatureUsage().then(features => {
|
||||||
|
|
@ -965,6 +967,9 @@ export default function NutritionPage() {
|
||||||
<button className={'tab'+(inputTab==='map'?' active':'')} onClick={()=>setInputTab('map')}>
|
<button className={'tab'+(inputTab==='map'?' active':'')} onClick={()=>setInputTab('map')}>
|
||||||
Zuordnen{unmappedCount ? ` (${unmappedCount})` : ''}
|
Zuordnen{unmappedCount ? ` (${unmappedCount})` : ''}
|
||||||
</button>
|
</button>
|
||||||
|
<button className={'tab'+(inputTab==='lists'?' active':'')} onClick={()=>setInputTab('lists')}>
|
||||||
|
Listen
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Entry Form */}
|
{/* Entry Form */}
|
||||||
|
|
@ -985,12 +990,21 @@ export default function NutritionPage() {
|
||||||
<NutritionFoodMap
|
<NutritionFoodMap
|
||||||
onMapped={refreshUnmappedCount}
|
onMapped={refreshUnmappedCount}
|
||||||
onChanged={refreshUnmappedCount}
|
onChanged={refreshUnmappedCount}
|
||||||
|
onManageLists={(id) => { setFocusListId(id || null); setInputTab('lists') }}
|
||||||
|
onCreatedList={(rec) => { setFocusListId(rec?.id || null); setInputTab('lists') }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{loading && inputTab !== 'map' && <div className="empty-state"><div className="spinner"/></div>}
|
{inputTab==='lists' && (
|
||||||
|
<FoodRecipeEditor
|
||||||
|
highlightId={focusListId}
|
||||||
|
onChanged={refreshUnmappedCount}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{!loading && !hasData && inputTab !== 'map' && (
|
{loading && inputTab !== 'map' && inputTab !== 'lists' && <div className="empty-state"><div className="spinner"/></div>}
|
||||||
|
|
||||||
|
{!loading && !hasData && inputTab !== 'map' && inputTab !== 'lists' && (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<h3>Noch keine Ernährungsdaten</h3>
|
<h3>Noch keine Ernährungsdaten</h3>
|
||||||
<p>Erfasse Daten über Einzelerfassung oder importiere deinen FDDB-Export.</p>
|
<p>Erfasse Daten über Einzelerfassung oder importiere deinen FDDB-Export.</p>
|
||||||
|
|
@ -998,7 +1012,7 @@ export default function NutritionPage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Analysis Section */}
|
{/* Analysis Section */}
|
||||||
{!loading && hasData && inputTab !== 'map' && (
|
{!loading && hasData && inputTab !== 'map' && inputTab !== 'lists' && (
|
||||||
<>
|
<>
|
||||||
<OverviewCards data={corrData}/>
|
<OverviewCards data={corrData}/>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user