BLS Stoffgruppen, Lebensmittel verwalten, etc #108

Merged
Lars merged 6 commits from develop into main 2026-09-14 13:15:10 +02:00
30 changed files with 1195 additions and 120 deletions
Showing only changes of commit 8b1e1da5d8 - Show all commits

View File

@ -1,6 +1,6 @@
# BLS-Lebensmittelreferenz und FDDB-Mapping
**Stand:** 2026-09-12 · **Status:** Phase 1 (Umsetzung)
**Stand:** 2026-09-14 · **Status:** Phase 1 (Umsetzung)
## WAS
@ -38,14 +38,17 @@ Gelernte Zuordnungen und Listen lassen sich als **JSON sichern** und auf einer a
- BLS 4.0 (~7140 Lebensmittel) ist frei nutzbar (MRI / blsdb.de); Dateien nicht im Git.
- Mapping ohne KI: Normalisierung + exakter Lookup (User vor Global) + Bestätigung neuer Namen.
- Öl/Supplement mit Etikett (EPA/DHA, Vitamine) ist **ein Katalogeintrag**, keine Zutatenliste. Listen sind nur Rohmischungen aus Lebensmitteln — eine Liste kann keine andere Liste enthalten. Quarkspeise = Liste, Omega-3-Öl = Lebensmittel darin.
- Gelernte Zuordnungen bleiben dauerhaft, sind aber änder- und löschbar.
- Gelernte Zuordnungen bleiben dauerhaft, sind aber änder- und löschbar. Nutzer-Mapping vor globalem Mapping. Globales Mapping nur auf BLS oder zentrale Admin-Foods.
- Sichtbarkeit: persönlich (nur Owner) / zentral (alle) / BLS (alle). Vorschläge und Suche filtern danach; bei gleichem Namen gewinnt persönlich vor zentral vor BLS.
- Ungemappte / Fertiggerichte: FDDB-Makros, keine erfundenen Mikros.
- Fasten und „unvollständig“ sind explizite Marken, kein Auto-Schluss aus fehlendem Import.
- Import-Policy (Profil): nachfragen / Katalog überschreiben / FDDB überschreiben / Makros behalten.
## Manuelle Lebensmittel / Supplemente
Eigene Einträge (z. B. Norsan Omega-3 + EPA) speichern **dieselben Stoffwerte** wie BLS-Lebensmittel (`food_attribute_values`, immer pro 100 g). Beim Anlegen: Makros plus Suche nach weiteren Stoffen (EPA, DHA, Omega-3). Etikett „pro 8 ml“ → Portionsgröße in g angeben, Umrechnung auf 100 g erfolgt serverseitig. Einheit des Stoffs beachten (oft **g**, Etikett in mg: 1100 mg = 1,1 g). Fehlt ein Stoff im Katalog: Admin → Stoffe & Attribute.
Eigene Einträge des Nutzers sind **persönlich** (`manual_user`). Der Admin legt **zentrale** Einträge für alle an (`manual_admin`). Beide speichern **dieselben Stoffwerte** wie BLS-Lebensmittel (`food_attribute_values`, immer pro 100 g). Beim Anlegen: Makros plus Suche nach weiteren Stoffen (EPA, DHA, Omega-3). Etikett „pro 8 ml“ → Portionsgröße in g angeben, Umrechnung auf 100 g erfolgt serverseitig. Einheit des Stoffs beachten (oft **g**, Etikett in mg: 1100 mg = 1,1 g). Fehlt ein Stoff im Katalog: Nutzer kann ihn selbst anlegen oder Admin → Stoffe & Attribute.
**Nachträglich ändern:** Unter Ernährung → Tab **Lebensmittel** (persönliche Liste + zentrale Admin-Einträge, Suche). Persönliche Einträge anlegen/bearbeiten; zentrale nur lesen. Beim Zuordnen gilt die Reihenfolge persönlich → zentral → BLS. Globale Namens-Mappings (Admin) gelten für alle, dürfen aber nicht auf ein privates Nutzer-Lebensmittel zeigen. Offizielle BLS-Einträge bleiben fest. Beim Speichern gelten die Felder als Werte je 100 g, außer die Portionsgröße wird bewusst auf die Etikett-Portion gesetzt. Betroffene Tagebuch-Tage werden im Hintergrund neu berechnet. Admin → Lebensmittelkatalog → Reiter **Manuell** → Zentral anlegen.
Weitere Quellen (USDA, Schweizer Nährwertdatenbank) kommen später als zusätzliche Katalogherkunft, nicht als zweite Wertetabelle.

View File

@ -1,6 +1,6 @@
# BLS Food Reference – technische Spec
**Stand:** 2026-09-12 · Migration **062**–**068**
**Stand:** 2026-09-14 · Migration **062**–**069**
## Tabellen
@ -29,6 +29,9 @@ FDDB: Items persistieren; `nutrition_log` nur bei leerem Tag oder laut Policy /
## Router
- `/api/bls/*` — Suche, Attribute-Suche, eigene Foods (`attributes` + `serving_g`), eigene Mappings; `POST /bls/attributes` legt Extension-Stoff an (Name + Einheit), `GET /bls/attributes?keys=EPA,DHA`
- `GET /bls/foods/mine` — eigene `manual_user`-Einträge; `GET /bls/foods/shared` — zentrale `manual_admin`; `GET /bls/foods/{id}` — Editor-Payload inkl. `visibility`; `PUT /bls/foods/manual/{id}` — nur Owner. Suche `?scope=global` blendet persönliche Foods aus (Admin-Mappings).
- Sichtbarkeit: `data_layer/food_visibility.py`. Lookup-Mapping: User-Zeile nur wenn Food sichtbar; globale Mapping-Zeile nur `owner_profile_id IS NULL`. `upsert_food_mapping` erzwingt dasselbe. Vorschläge sortieren persönlich → zentral → BLS.
- `GET /admin/bls/foods?kind=manual` — `manual_user`/`manual_admin`. `POST/PUT /admin/bls/foods/manual` — zentrale Foods (`attributes` + `serving_g`). Migration **069**: Owner-Check (persönlich hat Owner, BLS/Admin nicht).
- Migration **065** — Extension-Keys EPA/DHA/DPA/ALA/OMEGA3/OMEGA6 falls BLS sie nicht unter diesem Key hat
- `/api/admin/bls/*` — Import, Katalog, Attribute
- `/api/admin/food-mappings` — Admin-CRUD
@ -36,7 +39,7 @@ FDDB: Items persistieren; `nutrition_log` nur bei leerem Tag oder laut Policy /
- `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
- 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). Listen-UI: `FoodRecipeDialog` (Suche gelernt + Katalog; Katalogpick upsertet Mapping). Offene Zutaten: `kind=recipe_ingredient` (API-Wert unverändert, UI: Listenzutat).
- 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). Eigene Foods: Tab **Lebensmittel** (`MyFoodsPanel` + `FoodEditDialog`). Listen-UI: `FoodRecipeDialog` (Suche gelernt + Katalog; Katalogpick upsertet Mapping). Offene Zutaten: `kind=recipe_ingredient` (API-Wert unverändert, UI: Listenzutat).
- Tandoor (Modul `tandoor`): Migration **067** `profile_tandoor_settings`; `GET/PUT /api/tandoor/settings` (Token nur `token_set` / `token_hint`); `POST /api/tandoor/test` (10/min, kein Redirect-Follow). Client: `data_layer/tandoor_client.py`. Import und `cooked_yield_g` später.
- 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)`.

View File

@ -125,7 +125,7 @@ frontend/src/
- **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. Unmapped ohne Kreuzjoin Liste×Tagebuch; Listenzutaten-Datum aus `recipe.updated_at` oder letzter `recipe_id`-Nutzung. Listen-API `?brief=true`; FDDB-Listenimport rechnet Tageswerte im Hintergrund. Migration **068**.
- **Zuordnen-Zeitraum:** Standard letzte 4 Wochen (`since_days`); ältere ungemappte Namen (z. B. Getreide nach Glutenverzicht) bleiben unter „Alle“.
- **Katalogsuche:** Fettgehalt mitsuchen (`Joghurt 10%` / `9,5`); Dezimal-Komma bleibt erhalten.
- **Manuelle Foods:** Stoffe über EAV (`GET /bls/attributes`, `attributes` + `serving_g` beim Anlegen). Supplemente wie Norsan: EPA/DHA aus Etikett, Portionsgramm → Speicherung /100 g. Nutzer kann fehlende Stoffe anlegen (`POST /bls/attributes`). Öl/Supplement = ein Lebensmittel, keine Liste (Listen nicht verschachtelbar).
- **Manuelle Foods:** Persönlich (`manual_user` + Owner, nur dieser Nutzer) oder zentral (`manual_admin`, alle Nutzer). Stoffe über EAV. Nutzer: Tab Ernährung → **Lebensmittel**. Admin: Katalog → Manuell → **Zentral anlegen**. Mapping: Nutzer-Mapping darf sichtbare Foods; globales Mapping nur BLS/zentral. Offizielle BLS-Zeilen nicht editierbar. Öl/Supplement = ein Lebensmittel, keine Liste.
- **Listen / Kombinationen (Mitai):** Tab **Listen** (anlegen, bearbeiten, FDDB-Import). Im Listendialog Zutaten direkt BLS-zuordnen (Status offen/zugeordnet); Suche zuerst Listen-Namen und eigene Foods, dann BLS. **Tandoor:** User-Einstellungen URL + Token, Verbindungstest (`/api/tandoor/*`). Gerichte bleiben in Tandoor; später Mapping + `cooked_yield_g`. Admin-Mappings: `PUT /admin/food-mappings/{id}`.
- **Gitea #106:** BLS-Stammdaten, FDDB-Mapping, Item-Tagebuch — http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/106
- **Doku:** `.claude/docs/functional/BLS_FOOD_REFERENCE.md`, `.claude/docs/technical/BLS_FOOD_REFERENCE.md`, `docs/issues/issue-bls-food-mapping.md`. Folge #75.

View File

@ -108,6 +108,57 @@ def write_numeric_attributes(cur, food_id: str, values: dict[str, Any] | None) -
return written
def replace_numeric_attributes(cur, food_id: str, values: dict[str, Any] | None) -> int:
cur.execute("DELETE FROM food_attribute_values WHERE food_id = %s", (food_id,))
return write_numeric_attributes(cur, food_id, values)
KEY_TO_MACRO = {v: k for k, v in MACRO_TO_KEY.items()}
def editor_payload_for_food(cur, food: dict[str, Any], *, editable: bool) -> dict[str, Any]:
cur.execute(
"""
SELECT a.attr_key, a.name_de, a.name_en, a.unit, a.category, a.origin, v.value_num
FROM food_attribute_values v
JOIN food_attributes a ON a.id = v.attribute_id
WHERE v.food_id = %s AND a.data_type = 'num_per_100g'
AND v.value_num IS NOT NULL AND v.is_trace = false
ORDER BY a.sort_order, a.attr_key
""",
(food["id"],),
)
macros = {"kcal": 0, "protein_g": 0, "fat_g": 0, "carbs_g": 0}
extras = []
for row in cur.fetchall():
rec = dict(row)
field = KEY_TO_MACRO.get(rec["attr_key"])
if field:
macros[field] = float(rec["value_num"])
else:
extras.append({
"attr": {
"attr_key": rec["attr_key"],
"name_de": rec["name_de"],
"name_en": rec["name_en"],
"unit": rec["unit"],
"category": rec["category"],
"origin": rec["origin"],
},
"value": float(rec["value_num"]),
})
return {
"id": str(food["id"]),
"name_de": food.get("name_de") or "",
"name_en": food.get("name_en"),
"catalog_kind": food.get("catalog_kind"),
"editable": editable,
"macros_per_100g": macros,
"attributes": extras,
"serving_g": 100,
}
def macros_and_attributes_to_values(
macros: dict[str, Any] | None,
attributes: dict[str, Any] | None,

View File

@ -408,14 +408,16 @@ def get_food_mapping_with_cursor(
"""
SELECT m.id AS mapping_id, m.food_id, m.profile_id, m.source,
m.grams_per_unit, m.source_unit,
f.bls_code, f.name_de, f.catalog_kind
f.bls_code, f.name_de, f.catalog_kind, f.owner_profile_id
FROM food_name_mappings m
JOIN food_catalog f ON f.id = m.food_id
WHERE m.source_system = %s AND m.source_name_normalized = %s
AND m.profile_id = %s
AND f.is_active = true
AND (f.owner_profile_id IS NULL OR f.owner_profile_id = %s)
LIMIT 1
""",
(source_system, norm, profile_id),
(source_system, norm, profile_id, profile_id),
)
row = cur.fetchone()
if row:
@ -424,11 +426,13 @@ def get_food_mapping_with_cursor(
"""
SELECT m.id AS mapping_id, m.food_id, m.profile_id, m.source,
m.grams_per_unit, m.source_unit,
f.bls_code, f.name_de, f.catalog_kind
f.bls_code, f.name_de, f.catalog_kind, f.owner_profile_id
FROM food_name_mappings m
JOIN food_catalog f ON f.id = m.food_id
WHERE m.source_system = %s AND m.source_name_normalized = %s
AND m.profile_id IS NULL
AND f.is_active = true
AND f.owner_profile_id IS NULL
LIMIT 1
""",
(source_system, norm),
@ -448,6 +452,9 @@ def upsert_food_mapping(
grams_per_unit: float | None = None,
source_unit: str | None = None,
) -> int:
from data_layer.food_visibility import require_mapping_target
require_mapping_target(cur, food_id, profile_id)
norm = normalize_food_name(source_name_raw)
if not norm:
raise ValueError("Leerer Lebensmittelname")
@ -622,7 +629,7 @@ def suggest_catalog_foods(cur, query: str, profile_id: str | None, limit: int =
extra_sql = " OR (" + " AND ".join(parts) + ")"
cur.execute(
f"""
SELECT id, bls_code, name_de, name_en, catalog_kind, food_group
SELECT id, bls_code, name_de, name_en, catalog_kind, food_group, owner_profile_id
FROM food_catalog
WHERE is_active = true
AND (
@ -642,6 +649,10 @@ def suggest_catalog_foods(cur, query: str, profile_id: str | None, limit: int =
WHEN name_de ILIKE %s THEN 1
WHEN COALESCE(bls_code, '') ILIKE %s THEN 2
ELSE 3 END,
CASE catalog_kind
WHEN 'manual_user' THEN 0
WHEN 'manual_admin' THEN 1
ELSE 2 END,
name_de
LIMIT %s
""",

View File

@ -131,6 +131,8 @@ def score_name_match(query: str, name_de: str, name_en: str | None = None) -> in
def _public(food: dict[str, Any], score: int) -> dict[str, Any]:
from data_layer.food_visibility import visibility_of
return {
"id": str(food["id"]),
"bls_code": food.get("bls_code"),
@ -138,6 +140,7 @@ def _public(food: dict[str, Any], score: int) -> dict[str, Any]:
"name_en": food.get("name_en"),
"catalog_kind": food.get("catalog_kind"),
"food_group": food.get("food_group"),
"visibility": visibility_of(food),
"score": score,
}
@ -163,7 +166,7 @@ def get_suggest_index(cur, profile_id: str | None) -> dict[str, Any]:
def load_suggest_index(cur, profile_id: str | None) -> dict[str, Any]:
cur.execute(
"""
SELECT id, bls_code, name_de, name_en, catalog_kind, food_group
SELECT id, bls_code, name_de, name_en, catalog_kind, food_group, owner_profile_id
FROM food_catalog
WHERE is_active = true
AND (owner_profile_id IS NULL OR owner_profile_id = %s)
@ -245,6 +248,11 @@ def _candidate_foods(index: dict[str, Any], query: str) -> list[dict[str, Any]]:
return out[:MAX_CANDIDATES] if not number_tokens(query) else out
def _vis_rank(food: dict[str, Any]) -> int:
from data_layer.food_visibility import visibility_rank
return visibility_rank(food)
def suggest_for_name(index: dict[str, Any], query: str, limit: int = 3) -> dict[str, Any]:
q = (query or "").strip()
scored: list[tuple[int, int, dict]] = []
@ -253,7 +261,7 @@ def suggest_for_name(index: dict[str, Any], query: str, limit: int = 3) -> dict[
if score < MIN_SCORE:
continue
scored.append((score, len(food.get("name_de") or ""), food))
scored.sort(key=lambda x: (-x[0], x[1], x[2].get("name_de") or ""))
scored.sort(key=lambda x: (-x[0], _vis_rank(x[2]), x[1], x[2].get("name_de") or ""))
top = [_public(food, score) for score, _nlen, food in scored[: max(limit, 3)]]
ambiguous = False
if len(top) >= 2 and top[0]["score"] - top[1]["score"] <= 8 and top[1]["score"] >= 60:
@ -296,7 +304,7 @@ def suggest_catalog_foods_ranked(cur, query: str, profile_id: str | None, limit:
if score < MIN_SCORE and not number_tokens(q):
continue
hits.append(_public(row, score or 40))
hits.sort(key=lambda h: (-int(h.get("score") or 0), h.get("name_de") or ""))
hits.sort(key=lambda h: (-int(h.get("score") or 0), _vis_rank(h), h.get("name_de") or ""))
return hits[:limit]

View File

@ -0,0 +1,105 @@
"""Who may see and map a catalog food.
Scopes:
- personal — ``manual_user`` with ``owner_profile_id`` (only that profile)
- global — ``manual_admin`` (all users; no owner)
- bls — official BLS (all users; no owner)
Name-mappings: a *user* mapping may target any food visible to that user.
A *global* mapping (``profile_id IS NULL``) may only target global/BLS foods,
never another user's private entry.
"""
from __future__ import annotations
from typing import Any
VIS_PERSONAL = "personal"
VIS_GLOBAL = "global"
VIS_BLS = "bls"
SQL_VISIBLE_TO_PROFILE = "(owner_profile_id IS NULL OR owner_profile_id = %s)"
SQL_GLOBAL_ONLY = "owner_profile_id IS NULL"
_KIND_VIS = {
"official_bls": VIS_BLS,
"manual_admin": VIS_GLOBAL,
"manual_user": VIS_PERSONAL,
}
_VIS_RANK = {VIS_PERSONAL: 0, VIS_GLOBAL: 1, VIS_BLS: 2}
def visibility_of(food: dict[str, Any] | None) -> str:
if not food:
return VIS_BLS
kind = food.get("catalog_kind")
if kind in _KIND_VIS:
return _KIND_VIS[kind]
if food.get("owner_profile_id"):
return VIS_PERSONAL
return VIS_GLOBAL
def visibility_rank(food: dict[str, Any] | None) -> int:
return _VIS_RANK.get(visibility_of(food), 3)
def is_active(food: dict[str, Any] | None) -> bool:
if not food:
return False
if "is_active" not in food:
return True
return bool(food.get("is_active"))
def is_visible_to(food: dict[str, Any] | None, profile_id: str | None) -> bool:
if not is_active(food):
return False
owner = food.get("owner_profile_id")
if owner is None:
return True
return profile_id is not None and str(owner) == str(profile_id)
def is_global_mapping_target(food: dict[str, Any] | None) -> bool:
return is_active(food) and food.get("owner_profile_id") is None
def load_catalog_food(cur, food_id: str) -> dict[str, Any] | None:
cur.execute(
"""
SELECT id, name_de, catalog_kind, owner_profile_id, is_active, bls_code
FROM food_catalog
WHERE id = %s
""",
(food_id,),
)
row = cur.fetchone()
return dict(row) if row else None
def require_visible_food(cur, food_id: str, profile_id: str) -> dict[str, Any]:
food = load_catalog_food(cur, food_id)
if not is_visible_to(food, profile_id):
raise LookupError("Lebensmittel nicht gefunden")
return food
def require_global_mapping_target(cur, food_id: str) -> dict[str, Any]:
food = load_catalog_food(cur, food_id)
if not food or not is_active(food):
raise LookupError("Lebensmittel nicht gefunden")
if not is_global_mapping_target(food):
raise PermissionError("Globales Mapping darf nur auf zentrale oder BLS-Lebensmittel zeigen")
return food
def require_mapping_target(cur, food_id: str, mapping_profile_id: str | None) -> dict[str, Any]:
if mapping_profile_id:
return require_visible_food(cur, food_id, mapping_profile_id)
return require_global_mapping_target(cur, food_id)
def attach_visibility(row: dict[str, Any]) -> dict[str, Any]:
row["visibility"] = visibility_of(row)
return row

View File

@ -487,3 +487,45 @@ def dates_for_normalized_name(cur, profile_id: str, source_name_normalized: str)
(profile_id, source_name_normalized, profile_id, source_name_normalized),
)
return [r["date"] for r in cur.fetchall()]
def dates_using_food(cur, profile_id: str, food_id: str) -> list[str]:
cur.execute(
"""
SELECT DISTINCT date::text AS date
FROM nutrition_items
WHERE profile_id = %s AND food_id = %s
UNION
SELECT DISTINCT i.date::text AS date
FROM nutrition_items i
JOIN food_recipe_ingredients ri ON ri.recipe_id = i.recipe_id
JOIN food_name_mappings m
ON m.profile_id = i.profile_id
AND m.source_name_normalized = ri.source_name_normalized
AND m.food_id = %s
WHERE i.profile_id = %s AND i.recipe_id IS NOT NULL
""",
(profile_id, food_id, food_id, profile_id),
)
return [r["date"] for r in cur.fetchall() if r.get("date")]
def profiles_dates_using_food(cur, food_id: str) -> list[tuple[str, str]]:
cur.execute(
"""
SELECT DISTINCT profile_id::text AS profile_id, date::text AS date
FROM nutrition_items
WHERE food_id = %s
UNION
SELECT DISTINCT i.profile_id::text AS profile_id, i.date::text AS date
FROM nutrition_items i
JOIN food_recipe_ingredients ri ON ri.recipe_id = i.recipe_id
JOIN food_name_mappings m
ON m.profile_id = i.profile_id
AND m.source_name_normalized = ri.source_name_normalized
AND m.food_id = %s
WHERE i.recipe_id IS NOT NULL
""",
(food_id, food_id),
)
return [(r["profile_id"], r["date"]) for r in cur.fetchall() if r.get("profile_id") and r.get("date")]

View File

@ -0,0 +1,18 @@
-- Visibility invariant: personal foods have an owner; BLS and central admin foods do not.
UPDATE food_catalog
SET owner_profile_id = NULL
WHERE catalog_kind IN ('official_bls', 'manual_admin')
AND owner_profile_id IS NOT NULL;
UPDATE food_catalog
SET is_active = false
WHERE catalog_kind = 'manual_user'
AND owner_profile_id IS NULL;
ALTER TABLE food_catalog DROP CONSTRAINT IF EXISTS chk_food_catalog_owner;
ALTER TABLE food_catalog ADD CONSTRAINT chk_food_catalog_owner CHECK (
(catalog_kind = 'manual_user' AND owner_profile_id IS NOT NULL)
OR (catalog_kind IN ('official_bls', 'manual_admin') AND owner_profile_id IS NULL)
);

View File

@ -32,6 +32,8 @@ class ManualFoodCreate(BaseModel):
name_de: str
name_en: Optional[str] = None
macros_per_100g: Optional[dict] = None
attributes: Optional[dict] = None
serving_g: Optional[float] = None
@router.get("/status")
@ -162,7 +164,9 @@ def admin_list_foods(
cur = get_cursor(conn)
conds = ["is_active = true"]
params: list = []
if kind:
if kind == "manual":
conds.append("catalog_kind IN ('manual_user', 'manual_admin')")
elif kind:
conds.append("catalog_kind = %s")
params.append(kind)
if q:
@ -172,7 +176,7 @@ def admin_list_foods(
params.append(limit)
cur.execute(
f"""
SELECT id, bls_code, name_de, name_en, catalog_kind, food_group, bls_version
SELECT id, bls_code, name_de, name_en, catalog_kind, food_group, bls_version, owner_profile_id
FROM food_catalog
WHERE {' AND '.join(conds)}
ORDER BY name_de
@ -199,11 +203,15 @@ def admin_food_detail(food_id: str, session: dict = Depends(require_admin)):
@router.post("/foods/manual")
def admin_create_manual_food(body: ManualFoodCreate, session: dict = Depends(require_admin)):
from data_layer.food_attributes import macros_and_attributes_to_values, write_numeric_attributes
from data_layer.food_mapping import normalize_food_name
from data_layer.food_visibility import attach_visibility
name = (body.name_de or "").strip()
if not name:
raise HTTPException(400, "Name fehlt")
serving = body.serving_g if body.serving_g and body.serving_g > 0 else None
values = macros_and_attributes_to_values(body.macros_per_100g, body.attributes, serving)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
@ -215,10 +223,64 @@ def admin_create_manual_food(body: ManualFoodCreate, session: dict = Depends(req
(name, body.name_en, f"man-admin-{normalize_food_name(name)[:40]}"),
)
food = r2d(cur.fetchone())
_write_manual_macros(cur, food["id"], body.macros_per_100g)
write_numeric_attributes(cur, food["id"], values)
from data_layer.food_suggest import invalidate_suggest_index
invalidate_suggest_index()
return food
return attach_visibility(food)
@router.put("/foods/manual/{food_id}")
def admin_update_manual_food(food_id: str, body: ManualFoodCreate, session: dict = Depends(require_admin)):
from collections import defaultdict
from data_layer.food_attributes import (
editor_payload_for_food,
macros_and_attributes_to_values,
replace_numeric_attributes,
)
from data_layer.food_visibility import attach_visibility
from data_layer.nutrition_items import profiles_dates_using_food, schedule_daily_nutrient_rebuild
name = (body.name_de or "").strip()
if not name:
raise HTTPException(400, "Name fehlt")
serving = body.serving_g if body.serving_g and body.serving_g > 0 else None
values = macros_and_attributes_to_values(body.macros_per_100g, body.attributes, serving)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT id, name_de, name_en, catalog_kind, owner_profile_id
FROM food_catalog
WHERE id = %s AND catalog_kind = 'manual_admin' AND is_active = true
""",
(food_id,),
)
food = cur.fetchone()
if not food:
raise HTTPException(404, "Zentrales Lebensmittel nicht gefunden")
cur.execute(
"""
UPDATE food_catalog
SET name_de = %s, name_en = %s, updated_at = NOW()
WHERE id = %s
RETURNING id, name_de, name_en, catalog_kind, owner_profile_id
""",
(name, body.name_en, food_id),
)
updated = dict(cur.fetchone())
replace_numeric_attributes(cur, food_id, values)
pairs = profiles_dates_using_food(cur, food_id)
payload = attach_visibility(editor_payload_for_food(cur, updated, editable=True))
from data_layer.food_suggest import invalidate_suggest_index
invalidate_suggest_index()
by_profile = defaultdict(list)
for pid, day in pairs:
by_profile[pid].append(day)
for pid, dates in by_profile.items():
schedule_daily_nutrient_rebuild(pid, dates, f"Zentrales Lebensmittel {name}")
payload["rebuild_pending"] = len(pairs)
return payload
@router.get("/attributes")

View File

@ -52,7 +52,8 @@ def list_food_mappings(
q += " WHERE " + " AND ".join(conds)
q += " ORDER BY m.source_name_normalized"
cur.execute(q, params)
return [r2d(r) for r in cur.fetchall()]
from data_layer.food_visibility import attach_visibility
return [attach_visibility(r2d(r)) for r in cur.fetchall()]
@router.get("/stats/coverage")
@ -77,9 +78,13 @@ def mapping_coverage(session: dict = Depends(require_admin)):
def create_food_mapping(body: FoodMappingCreate, session: dict = Depends(require_admin)):
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT id FROM food_catalog WHERE id = %s", (body.food_id,))
if not cur.fetchone():
raise HTTPException(404, "Lebensmittel nicht gefunden")
from data_layer.food_visibility import require_mapping_target
try:
require_mapping_target(cur, body.food_id, body.profile_id or None)
except LookupError as e:
raise HTTPException(404, str(e)) from e
except PermissionError as e:
raise HTTPException(400, str(e)) from e
mid = upsert_food_mapping(
cur,
source_name_raw=body.source_name,
@ -114,9 +119,17 @@ def update_food_mapping(mapping_id: int, body: FoodMappingUpdate, session: dict
row = cur.fetchone()
if not row:
raise HTTPException(404, "Mapping nicht gefunden")
cur.execute("SELECT id FROM food_catalog WHERE id = %s AND is_active = true", (body.food_id,))
if not cur.fetchone():
cur.execute("SELECT id, catalog_kind, owner_profile_id, is_active FROM food_catalog WHERE id = %s AND is_active = true", (body.food_id,))
food = cur.fetchone()
if not food:
raise HTTPException(404, "Lebensmittel nicht gefunden")
from data_layer.food_visibility import require_mapping_target
try:
require_mapping_target(cur, body.food_id, row.get("profile_id"))
except LookupError as e:
raise HTTPException(404, str(e)) from e
except PermissionError as e:
raise HTTPException(400, str(e)) from e
cur.execute(
"""
UPDATE food_name_mappings

View File

@ -95,12 +95,16 @@ def list_food_units(session: dict = Depends(require_auth)):
def search_foods(
q: str = "",
limit: int = 20,
scope: str = "visible",
session: dict = Depends(require_auth),
):
pid = session["profile_id"]
with get_db() as conn:
cur = get_cursor(conn)
return suggest_catalog_foods_ranked(cur, q, pid, limit=min(max(limit, 1), 50))
hits = suggest_catalog_foods_ranked(cur, q, pid, limit=min(max(limit, 1), 50))
if scope == "global":
hits = [h for h in hits if h.get("visibility") != "personal"]
return hits
@router.get("/attributes")
@ -172,6 +176,122 @@ def create_user_food(
return food
@router.get("/foods/mine")
def list_my_manual_foods(session: dict = Depends(require_auth)):
pid = session["profile_id"]
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT id, name_de, catalog_kind, updated_at
FROM food_catalog
WHERE owner_profile_id = %s AND catalog_kind = 'manual_user' AND is_active = true
ORDER BY name_de
""",
(pid,),
)
rows = [r2d(r) for r in cur.fetchall()]
from data_layer.food_visibility import attach_visibility
for row in rows:
row["id"] = str(row["id"])
attach_visibility(row)
return rows
@router.get("/foods/shared")
def list_shared_manual_foods(session: dict = Depends(require_auth)):
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT id, name_de, catalog_kind, updated_at
FROM food_catalog
WHERE catalog_kind = 'manual_admin' AND owner_profile_id IS NULL AND is_active = true
ORDER BY name_de
"""
)
from data_layer.food_visibility import attach_visibility
rows = [r2d(r) for r in cur.fetchall()]
for row in rows:
row["id"] = str(row["id"])
attach_visibility(row)
return rows
@router.get("/foods/{food_id}")
def get_food_detail(food_id: str, session: dict = Depends(require_auth)):
from data_layer.food_attributes import editor_payload_for_food
pid = session["profile_id"]
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT id, name_de, name_en, catalog_kind, owner_profile_id
FROM food_catalog
WHERE id = %s AND is_active = true
AND (owner_profile_id IS NULL OR owner_profile_id = %s)
""",
(food_id, pid),
)
food = cur.fetchone()
if not food:
raise HTTPException(404, "Lebensmittel nicht gefunden")
food = dict(food)
editable = food.get("catalog_kind") == "manual_user" and str(food.get("owner_profile_id") or "") == str(pid)
from data_layer.food_visibility import attach_visibility
return attach_visibility(editor_payload_for_food(cur, food, editable=editable))
@router.put("/foods/manual/{food_id}")
def update_user_food(food_id: str, body: UserFoodCreate, session: dict = Depends(require_auth)):
from data_layer.food_attributes import (
editor_payload_for_food,
macros_and_attributes_to_values,
replace_numeric_attributes,
)
from data_layer.nutrition_items import dates_using_food
pid = session["profile_id"]
name = (body.name_de or "").strip()
if not name:
raise HTTPException(400, "Name fehlt")
serving = body.serving_g if body.serving_g and body.serving_g > 0 else None
values = macros_and_attributes_to_values(body.macros_per_100g, body.attributes, serving)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT id, name_de, name_en, catalog_kind, owner_profile_id
FROM food_catalog
WHERE id = %s AND catalog_kind = 'manual_user' AND owner_profile_id = %s AND is_active = true
""",
(food_id, pid),
)
food = cur.fetchone()
if not food:
raise HTTPException(404, "Eigenes Lebensmittel nicht gefunden")
cur.execute(
"""
UPDATE food_catalog
SET name_de = %s, name_en = %s, updated_at = NOW()
WHERE id = %s
RETURNING id, name_de, name_en, catalog_kind, owner_profile_id
""",
(name, body.name_en, food_id),
)
updated = dict(cur.fetchone())
replace_numeric_attributes(cur, food_id, values)
dates = dates_using_food(cur, pid, food_id)
payload = editor_payload_for_food(cur, updated, editable=True)
from data_layer.food_visibility import attach_visibility
payload = attach_visibility(payload)
invalidate_suggest_index(pid)
_schedule_rebuild(pid, dates, f"Lebensmittel {name}")
payload["rebuild_pending"] = len(dates)
return payload
@router.get("/mappings")
def list_my_mappings(
x_profile_id: Optional[str] = Header(default=None),
@ -192,7 +312,8 @@ def list_my_mappings(
""",
(pid,),
)
return [r2d(r) for r in cur.fetchall()]
from data_layer.food_visibility import attach_visibility
return [attach_visibility(r2d(r)) for r in cur.fetchall()]
@router.post("/mappings")
@ -204,27 +325,26 @@ def upsert_my_mapping(
pid = _pid(session, x_profile_id)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT id, name_de, bls_code, catalog_kind FROM food_catalog
WHERE id = %s AND is_active = true
AND (owner_profile_id IS NULL OR owner_profile_id = %s)
""",
(body.food_id, pid),
)
food = cur.fetchone()
if not food:
raise HTTPException(404, "Lebensmittel nicht gefunden")
mid = upsert_food_mapping(
cur,
source_name_raw=body.source_name,
food_id=body.food_id,
profile_id=pid,
source="bulk",
source_system=body.source_system,
grams_per_unit=body.grams_per_unit,
source_unit=body.source_unit,
)
from data_layer.food_visibility import require_visible_food, visibility_of
try:
food = require_visible_food(cur, body.food_id, pid)
except LookupError as e:
raise HTTPException(404, str(e)) from e
try:
mid = upsert_food_mapping(
cur,
source_name_raw=body.source_name,
food_id=body.food_id,
profile_id=pid,
source="bulk",
source_system=body.source_system,
grams_per_unit=body.grams_per_unit,
source_unit=body.source_unit,
)
except PermissionError as e:
raise HTTPException(400, str(e)) from e
except LookupError as e:
raise HTTPException(404, str(e)) from e
norm = normalize_food_name(body.source_name)
n = apply_mapping_to_items(cur, pid, norm, body.food_id, mid)
apply_quantities_to_items(cur, pid, norm, body.grams_per_unit)
@ -238,6 +358,7 @@ def upsert_my_mapping(
"food_name_de": food["name_de"],
"bls_code": food.get("bls_code"),
"catalog_kind": food.get("catalog_kind"),
"visibility": visibility_of(food),
}

View File

@ -1,5 +1,6 @@
from data_layer.food_attributes import (
create_extension_attribute,
editor_payload_for_food,
macros_and_attributes_to_values,
normalize_attr_key,
normalize_attr_unit,
@ -45,3 +46,41 @@ def test_norsan_label_epa_converts():
assert abs(vals["FAT"] - 100) < 0.01
assert abs(vals["EPA"] - 13.75) < 0.01
assert abs(vals["ENERCC"] - 900) < 0.01
def test_edit_payload_does_not_rescale_at_100g():
vals = macros_and_attributes_to_values(
{"kcal": 900, "protein_g": 0, "fat_g": 100, "carbs_g": 0},
{"EPA": 13.75},
serving_g=100,
)
assert vals["FAT"] == 100
assert vals["EPA"] == 13.75
assert vals["ENERCC"] == 900
def test_editor_payload_splits_macros_and_extras():
class _Cur:
def execute(self, sql, params=None):
pass
def fetchall(self):
return [
{"attr_key": "ENERCC", "name_de": "Energie", "name_en": None, "unit": "kcal", "category": "energy", "origin": "bls", "value_num": 900},
{"attr_key": "FAT", "name_de": "Fett", "name_en": None, "unit": "g", "category": "macro", "origin": "bls", "value_num": 100},
{"attr_key": "PROT625", "name_de": "Protein", "name_en": None, "unit": "g", "category": "macro", "origin": "bls", "value_num": 0},
{"attr_key": "CHO", "name_de": "KH", "name_en": None, "unit": "g", "category": "macro", "origin": "bls", "value_num": 0},
{"attr_key": "EPA", "name_de": "EPA", "name_en": None, "unit": "g", "category": "extension", "origin": "extension", "value_num": 13.75},
]
payload = editor_payload_for_food(
_Cur(),
{"id": "abc", "name_de": "Omega-3", "name_en": None, "catalog_kind": "manual_user"},
editable=True,
)
assert payload["editable"] is True
assert payload["serving_g"] == 100
assert payload["macros_per_100g"]["kcal"] == 900
assert payload["macros_per_100g"]["fat_g"] == 100
assert payload["attributes"][0]["attr"]["attr_key"] == "EPA"
assert payload["attributes"][0]["value"] == 13.75

View File

@ -105,3 +105,11 @@ def test_joghurt_10_ranks_above_35():
packed = suggest_for_name(index, query, limit=3)
assert packed["suggestions"][0]["name_de"] == full["name_de"], query
assert packed["suggestions"][0]["score"] > score_name_match(query, light["name_de"])
def test_personal_food_ranks_above_bls_on_tie():
bls = {"id": "bls", "name_de": "Omega-3 Öl", "name_en": "", "bls_code": "X", "catalog_kind": "official_bls"}
mine = {"id": "me", "name_de": "Omega-3 Öl", "name_en": "", "bls_code": None, "catalog_kind": "manual_user", "owner_profile_id": "u1"}
packed = suggest_for_name(_index(bls, mine), "Omega-3 Öl", limit=2)
assert packed["suggestions"][0]["id"] == "me"
assert packed["suggestions"][0]["visibility"] == "personal"

View File

@ -0,0 +1,90 @@
from data_layer.food_visibility import (
attach_visibility,
is_global_mapping_target,
is_visible_to,
require_mapping_target,
visibility_of,
visibility_rank,
)
def test_visibility_scopes():
assert visibility_of({"catalog_kind": "official_bls"}) == "bls"
assert visibility_of({"catalog_kind": "manual_admin", "owner_profile_id": None}) == "global"
assert visibility_of({"catalog_kind": "manual_user", "owner_profile_id": "u1"}) == "personal"
assert visibility_rank({"catalog_kind": "manual_user"}) < visibility_rank({"catalog_kind": "manual_admin"})
assert visibility_rank({"catalog_kind": "manual_admin"}) < visibility_rank({"catalog_kind": "official_bls"})
def test_personal_food_not_visible_to_others():
food = {"catalog_kind": "manual_user", "owner_profile_id": "alice", "is_active": True}
assert is_visible_to(food, "alice") is True
assert is_visible_to(food, "bob") is False
assert is_global_mapping_target(food) is False
def test_admin_and_bls_visible_to_everyone():
admin = {"catalog_kind": "manual_admin", "owner_profile_id": None, "is_active": True}
bls = {"catalog_kind": "official_bls", "owner_profile_id": None, "is_active": True}
assert is_visible_to(admin, "bob") is True
assert is_visible_to(bls, None) is True
assert is_global_mapping_target(admin) is True
assert is_global_mapping_target(bls) is True
def test_attach_visibility():
row = attach_visibility({"catalog_kind": "manual_admin"})
assert row["visibility"] == "global"
def test_require_global_mapping_rejects_personal():
class _Cur:
def execute(self, sql, params=None):
self._row = {
"id": params[0],
"name_de": "Öl",
"catalog_kind": "manual_user",
"owner_profile_id": "alice",
"is_active": True,
"bls_code": None,
}
def fetchone(self):
return self._row
try:
require_mapping_target(_Cur(), "fid", None)
assert False, "expected PermissionError"
except PermissionError:
pass
def test_require_user_mapping_allows_own_and_global():
class _Cur:
def __init__(self, food):
self.food = food
def execute(self, sql, params=None):
pass
def fetchone(self):
return self.food
own = require_mapping_target(
_Cur({
"id": "1", "name_de": "X", "catalog_kind": "manual_user",
"owner_profile_id": "alice", "is_active": True, "bls_code": None,
}),
"1",
"alice",
)
assert own["catalog_kind"] == "manual_user"
shared = require_mapping_target(
_Cur({
"id": "2", "name_de": "Y", "catalog_kind": "manual_admin",
"owner_profile_id": None, "is_active": True, "bls_code": None,
}),
"2",
"alice",
)
assert shared["catalog_kind"] == "manual_admin"

View File

@ -8,8 +8,8 @@ Semantic Versioning: MAJOR.MINOR.PATCH
"""
APP_VERSION = "0.9v"
BUILD_DATE = "2026-09-12"
DB_SCHEMA_VERSION = "20260912e" # 068 nutrition unmapped/recipe indexes
BUILD_DATE = "2026-09-14"
DB_SCHEMA_VERSION = "20260914a" # 069 food catalog visibility owner check
MODULE_VERSIONS = {
"auth": "1.2.0",
@ -20,9 +20,10 @@ MODULE_VERSIONS = {
"circumference": "1.0.1",
"caliper": "1.0.1",
"activity": "1.2.1", # Legacy CSV import: activity_entries feature enforcement
"nutrition": "1.3.5", # Listen-Dialog: Mapping an der Zutat; Suche Listen/eigene vor BLS
"nutrition": "1.3.7", # Sichtbarkeit persönlich vs. zentral im Mapping
"tandoor": "1.0.1", # Router-Import ohne future-annotations (Backend startet)
"bls": "1.0.6", # Nutzer darf Extension-Stoffe anlegen; EPA-Chips; mg/µg am Etikett
"bls": "1.0.9", # Nutzer-Foods privat, Admin-Foods zentral; Mapping achtet Sichtbarkeit
"photos": "1.0.0",
"insights": "1.3.0",
"prompts": "1.1.0",
@ -55,6 +56,9 @@ CHANGELOG = [
"Zuordnen: Zeitraum 14 Tage / 4 Wochen / 90 Tage / Alle — zuerst aktuelle Tagebuchnamen",
"Katalogsuche: Fettgehalt (9,5 / 10 %) bleibt erhalten, Joghurt 10% vor >3,5%",
"Eigenes Lebensmittel: beliebige Katalog-Stoffe (EPA/DHA/…) plus Portionsumrechnung auf 100 g",
"Eigene Lebensmittel nachträglich bearbeiten (Name, Makros, Stoffe); BLS bleibt fest",
"Reiter Lebensmittel (Nutzer) und Admin-Katalog Manuell: Liste + Suche",
"Sichtbarkeit: persönliche Foods nur Owner, zentrale Admin-Foods für alle; Mapping trennt global vs. Nutzer",
"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",

View File

@ -19,6 +19,7 @@ Verlässliche Lebensmittel-Stammdaten (BLS 4.0 + manuelle Erweiterung), lernende
- Zuordnungen und Listen als JSON zwischen Instanzen übertragen
- Eigener Katalogeintrag im Suchpopup; Gramm pro Stück/EL/… am Mapping
- Manueller Eintrag: beliebige `num_per_100g`-Stoffe (EPA/DHA/…) und Portionsumrechnung
- Eigene Lebensmittel nachträglich bearbeiten (Tab Ernährung → Lebensmittel; persönlich vs. zentral; BLS unveränderlich)
- Listen-Dialog mit Zutatensuche (Mappings + Katalog); Mitai = Roh-Kombination
- Listenzutaten: Menge + Einheit, Umrechnung auf Gramm (EL/TL/Prise/Stück)
- Tandoor: persönlicher URL+Token in Einstellungen, Verbindungstest (kein Import)

View File

@ -191,7 +191,7 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we
/* Tabs */
.tabs { display: flex; gap: 4px; background: var(--surface2); border-radius: 10px; padding: 3px; margin-bottom: 16px; }
.tab { flex: 1; text-align: center; padding: 7px; border-radius: 8px; font-size: 13px; font-weight: 500; color: var(--text3); cursor: pointer; border: none; background: transparent; font-family: var(--font); }
.tab { flex: 1; text-align: center; padding: 7px; border-radius: 8px; font-size: 13px; font-weight: 500; color: var(--text3); cursor: pointer; border: none; background: transparent; font-family: var(--font); white-space: nowrap; }
.tab.active { background: var(--surface); color: var(--text1); box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
/* Section */

View File

@ -0,0 +1,146 @@
import { useEffect, useState } from 'react'
import { api } from '../utils/api'
import FoodNutrientFields, { foodCreatePayload } from './FoodNutrientFields'
function numStr(v) {
if (v == null || v === '') return '0'
const n = Number(v)
return Number.isFinite(n) ? String(n) : '0'
}
export default function FoodEditDialog({ foodId, onClose, onSaved, zIndex, mode }) {
const isCreate = !foodId
const isAdmin = mode === 'admin'
const [name, setName] = useState('')
const [macros, setMacros] = useState({ kcal: '0', protein_g: '0', fat_g: '0', carbs_g: '0' })
const [extras, setExtras] = useState([])
const [servingG, setServingG] = useState('100')
const [editable, setEditable] = useState(false)
const [loading, setLoading] = useState(!isCreate)
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
useEffect(() => {
if (isCreate) {
setEditable(true)
setLoading(false)
return
}
let alive = true
api.getBlsFood(foodId).then((d) => {
if (!alive) return
setName(d.name_de || '')
const m = d.macros_per_100g || {}
setMacros({
kcal: numStr(m.kcal),
protein_g: numStr(m.protein_g),
fat_g: numStr(m.fat_g),
carbs_g: numStr(m.carbs_g),
})
setExtras((d.attributes || []).map((row) => ({
attr: row.attr,
value: numStr(row.value),
inputUnit: row.attr?.unit || 'g',
})))
setServingG(numStr(d.serving_g || 100) === '0' ? '100' : numStr(d.serving_g || 100))
setEditable(isAdmin ? d.catalog_kind === 'manual_admin' : Boolean(d.editable))
setLoading(false)
}).catch((e) => {
if (!alive) return
setError(e.message)
setLoading(false)
})
return () => { alive = false }
}, [foodId, isCreate, isAdmin])
const save = async () => {
if (!name.trim()) {
setError('Name fehlt')
return
}
setSaving(true)
setError(null)
try {
const body = foodCreatePayload(name, macros, extras, servingG)
let saved
if (isAdmin) {
saved = isCreate ? await api.adminCreateManualFood(body) : await api.adminUpdateManualFood(foodId, body)
} else {
saved = isCreate ? await api.createUserFood(body) : await api.updateUserFood(foodId, body)
}
onSaved?.(saved)
onClose?.()
} catch (e) {
setError(e.message)
} finally {
setSaving(false)
}
}
return (
<div
style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)',
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: zIndex || 23000,
padding: 16,
}}
>
<div
role="dialog"
aria-modal="true"
style={{
width: '100%', maxWidth: 520, 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 className="card-title" style={{ margin: 0, fontSize: 16 }}>
{isCreate
? (isAdmin ? 'Zentrales Lebensmittel anlegen' : 'Lebensmittel anlegen')
: (isAdmin ? 'Zentrales Lebensmittel bearbeiten' : 'Lebensmittel bearbeiten')}
</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 }}>
{loading && <p style={{ fontSize: 13 }}>Lade…</p>}
{error && <div style={{ color: 'var(--danger)', fontSize: 13, marginBottom: 10 }}>{error}</div>}
{!loading && !editable && (
<p style={{ fontSize: 13, color: 'var(--text2)' }}>
Offizielle BLS-Einträge sind fest. Nur selbst angelegte{isAdmin ? ' zentrale' : ''} Lebensmittel lassen sich ändern.
</p>
)}
{!loading && editable && (
<>
<label className="form-label">Name</label>
<input
className="form-input"
style={{ width: '100%', textAlign: 'left', marginBottom: 12 }}
value={name}
onChange={(e) => setName(e.target.value)}
/>
<FoodNutrientFields
macros={macros}
setMacros={setMacros}
extras={extras}
setExtras={setExtras}
servingG={servingG}
setServingG={setServingG}
valuesArePer100g={!isCreate}
/>
</>
)}
</div>
{editable && (
<div style={{ display: 'flex', gap: 8, padding: '12px 16px', borderTop: '1px solid var(--border)' }}>
<button type="button" className="btn btn-secondary" disabled={saving} onClick={onClose}>Abbrechen</button>
<button type="button" className="btn btn-primary" style={{ flex: 1 }} disabled={saving || loading} onClick={save}>
{saving ? 'Speichere…' : (isCreate ? 'Anlegen' : 'Speichern')}
</button>
</div>
)}
</div>
</div>
)
}

View File

@ -45,7 +45,7 @@ export function foodCreatePayload(name, macros, extras, servingG) {
}
}
export default function FoodNutrientFields({ macros, setMacros, extras, setExtras, servingG, setServingG }) {
export default function FoodNutrientFields({ macros, setMacros, extras, setExtras, servingG, setServingG, valuesArePer100g }) {
const [q, setQ] = useState('')
const [hits, setHits] = useState([])
const [quick, setQuick] = useState([])
@ -124,8 +124,9 @@ export default function FoodNutrientFields({ macros, setMacros, extras, setExtra
return (
<div>
<p style={{ fontSize: 12, color: 'var(--text3)', margin: '0 0 8px' }}>
Etikettwerte so eintragen, wie sie dastehen. Portionsgröße setzen (5 ml Öl ≈ 5 g). Gespeichert wird immer pro 100 g.
Ein Öl/Supplement ist ein Lebensmittel, keine Liste — Listen können keine Listen enthalten.
{valuesArePer100g
? 'Geladen sind die gespeicherten Werte je 100 g. Portion nur ändern, wenn du vom Etikett neu einträgst (sonst 100 lassen).'
: 'Etikettwerte so eintragen, wie sie dastehen. Portionsgröße setzen (5 ml Öl ≈ 5 g). Gespeichert wird immer pro 100 g. Ein Öl/Supplement ist ein Lebensmittel, keine Liste — Listen können keine Listen enthalten.'}
</p>
<label style={{ display: 'block', fontSize: 12, color: 'var(--text2)', marginBottom: 8 }}>
Angabe gilt für{' '}
@ -241,6 +242,15 @@ export default function FoodNutrientFields({ macros, setMacros, extras, setExtra
) : (
<span style={{ alignSelf: 'center' }}>{target}</span>
)}
<button
type="button"
className="btn btn-secondary"
style={{ padding: '6px 10px' }}
onClick={() => setExtras((list) => list.filter((r) => r.attr.attr_key !== row.attr.attr_key))}
aria-label="Stoff entfernen"
>
×
</button>
</span>
</label>
)

View File

@ -1,6 +1,8 @@
import { useEffect, useRef, useState } from 'react'
import { api } from '../utils/api'
import FoodSearchModal from './FoodSearchModal'
import FoodEditDialog from './FoodEditDialog'
import { foodVisibilityLabel } from '../utils/foodVisibility'
const FALLBACK_UNITS = [
{ id: 'g', label: 'g', default_g: 1, needs_unit_map: false },
@ -128,6 +130,7 @@ export default function FoodRecipeDialog({
const [knownNames, setKnownNames] = useState([])
const [suggestions, setSuggestions] = useState({})
const [mapFor, setMapFor] = useState(null)
const [editFoodId, setEditFoodId] = useState(null)
const nameRef = useRef(null)
const qtyRefs = useRef({})
const timer = useRef(null)
@ -476,21 +479,27 @@ export default function FoodRecipeDialog({
))}
{searching && <p style={{ fontSize: 12, color: 'var(--text2)', padding: '6px 10px' }}>Katalog…</p>}
{userHits.length > 0 && (
<div style={{ padding: '8px 10px 4px', fontSize: 11, color: 'var(--text3)' }}>Eigene Lebensmittel</div>
<div style={{ padding: '8px 10px 4px', fontSize: 11, color: 'var(--text3)' }}>Persönlich und zentral</div>
)}
{userHits.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}` : 'manuell'}</span>
</span>
</button>
<div key={h.id} style={{ display: 'flex', gap: 8, margin: '0 8px 8px' }}>
<button
type="button"
className="btn btn-secondary"
style={{ flex: 1, 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)' }}>{foodVisibilityLabel(h.catalog_kind, h.visibility)}</span>
</span>
</button>
{h.catalog_kind === 'manual_user' && (
<button type="button" className="btn btn-secondary" style={{ flexShrink: 0 }} onClick={() => setEditFoodId(h.id)}>
Bearbeiten
</button>
)}
</div>
))}
{blsHits.length > 0 && (
<div style={{ padding: '8px 10px 4px', fontSize: 11, color: 'var(--text3)' }}>BLS-Katalog</div>
@ -623,6 +632,14 @@ export default function FoodRecipeDialog({
onSelect={(food, extras) => assignIngredient(mapFor.source_name_raw, food, extras).catch((e) => setError(e.message))}
/>
)}
{editFoodId && (
<FoodEditDialog
foodId={editFoodId}
zIndex={24000}
onClose={() => setEditFoodId(null)}
onSaved={() => runCatalog(q)}
/>
)}
</div>
)
}

View File

@ -1,6 +1,8 @@
import { useEffect, useRef, useState } from 'react'
import { api } from '../utils/api'
import FoodNutrientFields, { foodCreatePayload } from './FoodNutrientFields'
import FoodEditDialog from './FoodEditDialog'
import { foodVisibilitySuffix } from '../utils/foodVisibility'
const COUNT_UNITS = new Set(['stück', 'scheibe', 'portion', 'becher', 'tasse'])
const UNIT_LABEL = {
@ -33,6 +35,7 @@ export default function FoodSearchModal({ title, initialQuery, quantityHint, all
const [creating, setCreating] = useState(false)
const [showCreate, setShowCreate] = useState(false)
const [createKind, setCreateKind] = useState(null)
const [editId, setEditId] = useState(null)
const [manual, setManual] = useState({
name_de: initialQuery || '',
kcal: '', protein_g: '', fat_g: '', carbs_g: '',
@ -127,6 +130,7 @@ export default function FoodSearchModal({ title, initialQuery, quantityHint, all
const unitLabel = UNIT_LABEL[suggestedUnit] || suggestedUnit
return (
<>
<div
style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)',
@ -186,24 +190,33 @@ export default function FoodSearchModal({ title, initialQuery, quantityHint, all
{!loading && q.trim().length >= 2 && hits.length === 0 && (
<p style={{ fontSize: 13, color: 'var(--text3)' }}>Kein Treffer im Katalog.</p>
)}
{hits.map((h) => (
<button
key={h.id}
type="button"
className="btn btn-secondary btn-full"
style={{ marginTop: 8, justifyContent: 'flex-start', textAlign: 'left', height: 'auto', padding: '10px 12px' }}
onClick={() => onSelect(h, unitExtras())}
>
<span>
<strong style={{ display: 'block' }}>{h.name_de}</strong>
<span style={{ fontSize: 12, color: 'var(--text3)' }}>
{h.bls_code ? `BLS ${h.bls_code}` : 'ohne Code'}
{h.food_group ? ` · Gruppe ${h.food_group}` : ''}
{h.catalog_kind !== 'official_bls' ? ' · manuell' : ''}
</span>
</span>
</button>
))}
{hits.map((h) => {
const manual = h.catalog_kind && h.catalog_kind !== 'official_bls' && h.catalog_kind !== 'manual_admin'
return (
<div key={h.id} style={{ display: 'flex', gap: 8, marginTop: 8, alignItems: 'stretch' }}>
<button
type="button"
className="btn btn-secondary"
style={{ flex: 1, justifyContent: 'flex-start', textAlign: 'left', height: 'auto', padding: '10px 12px' }}
onClick={() => onSelect(h, unitExtras())}
>
<span>
<strong style={{ display: 'block' }}>{h.name_de}</strong>
<span style={{ fontSize: 12, color: 'var(--text3)' }}>
{h.bls_code ? `BLS ${h.bls_code}` : 'ohne Code'}
{h.food_group ? ` · Gruppe ${h.food_group}` : ''}
{foodVisibilitySuffix(h.catalog_kind, h.visibility)}
</span>
</span>
</button>
{manual && (
<button type="button" className="btn btn-secondary" style={{ flexShrink: 0 }} onClick={() => setEditId(h.id)}>
Bearbeiten
</button>
)}
</div>
)
})}
<div style={{ marginTop: 16, paddingTop: 12, borderTop: '1px solid var(--border)' }}>
{!showCreate ? (
@ -262,5 +275,14 @@ export default function FoodSearchModal({ title, initialQuery, quantityHint, all
</div>
</div>
</div>
{editId && (
<FoodEditDialog
foodId={editId}
zIndex={(zIndex || 20000) + 2000}
onClose={() => setEditId(null)}
onSaved={() => runSearch(q)}
/>
)}
</>
)
}

View File

@ -0,0 +1,123 @@
import { useEffect, useMemo, useState } from 'react'
import { api } from '../utils/api'
import FoodEditDialog from './FoodEditDialog'
import { foodVisibilitySuffix } from '../utils/foodVisibility'
function filterByName(list, term) {
if (!term) return list
return list.filter((f) => (f.name_de || '').toLowerCase().includes(term))
}
export default function MyFoodsPanel() {
const [foods, setFoods] = useState([])
const [shared, setShared] = useState([])
const [q, setQ] = useState('')
const [visible, setVisible] = useState(60)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [notice, setNotice] = useState(null)
const [editId, setEditId] = useState(null)
const [creating, setCreating] = useState(false)
const load = async () => {
try {
const [mine, central] = await Promise.all([
api.listMyFoods(),
api.listSharedFoods().catch(() => []),
])
setFoods(Array.isArray(mine) ? mine : [])
setShared(Array.isArray(central) ? central : [])
setError(null)
} catch (e) {
setError(e.message)
} finally {
setLoading(false)
}
}
useEffect(() => { load() }, [])
const term = q.trim().toLowerCase()
const filteredMine = useMemo(() => filterByName(foods, term), [foods, term])
const filteredShared = useMemo(() => filterByName(shared, term), [shared, term])
const shownMine = filteredMine.slice(0, visible)
const afterSave = (res) => {
load()
const n = res?.rebuild_pending
if (n) setNotice(`Gespeichert. Tageswerte für ${n} Tag${n === 1 ? '' : 'e'} werden nachgerechnet.`)
else setNotice('Lebensmittel gespeichert.')
}
return (
<div className="card section-gap">
<div className="card-title">Lebensmittel</div>
<p style={{ fontSize: 13, color: 'var(--text2)', lineHeight: 1.6, marginBottom: 12 }}>
<strong>Persönlich:</strong> nur für dich. <strong>Zentral:</strong> vom Admin für alle Nutzer.
BLS-Katalog suchst du beim Zuordnen. Ein Öl/Supplement bleibt ein Lebensmittel, keine Liste.
</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>}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 12 }}>
<button type="button" className="btn btn-primary" onClick={() => setCreating(true)}>Persönliches Lebensmittel anlegen</button>
</div>
<input
className="form-input"
style={{ width: '100%', textAlign: 'left', marginBottom: 8 }}
placeholder="Persönliche und zentrale Einträge suchen…"
value={q}
onChange={(e) => { setQ(e.target.value); setVisible(60) }}
/>
<h3 style={{ fontSize: 14, margin: '16px 0 8px' }}>Persönlich ({loading ? '…' : filteredMine.length})</h3>
{!loading && foods.length === 0 && (
<p className="muted">Noch keine eigenen Einträge. Hier anlegen oder beim Zuordnen über „Neu anlegen“.</p>
)}
{!loading && foods.length > 0 && filteredMine.length === 0 && (
<p className="muted">Kein persönlicher Treffer für „{q.trim()}“.</p>
)}
{shownMine.map((f) => (
<div key={f.id} style={{ display: 'flex', justifyContent: 'space-between', gap: 8, padding: '10px 0', borderTop: '1px solid var(--border)', alignItems: 'center' }}>
<div>
<div style={{ fontWeight: 600 }}>{f.name_de}</div>
<div style={{ fontSize: 12, color: 'var(--text3)' }}>nur du</div>
</div>
<button type="button" className="btn btn-secondary" style={{ fontSize: 12 }} onClick={() => setEditId(f.id)}>Bearbeiten</button>
</div>
))}
{visible < filteredMine.length && (
<button type="button" className="btn btn-secondary btn-full" style={{ marginTop: 8 }} onClick={() => setVisible((n) => n + 60)}>
Weitere {Math.min(60, filteredMine.length - visible)} zeigen
</button>
)}
<h3 style={{ fontSize: 14, margin: '20px 0 8px' }}>Zentral für alle ({loading ? '…' : filteredShared.length})</h3>
{!loading && shared.length === 0 && (
<p className="muted">Noch keine zentralen Einträge vom Admin.</p>
)}
{!loading && shared.length > 0 && filteredShared.length === 0 && (
<p className="muted">Kein zentraler Treffer für „{q.trim()}“.</p>
)}
{filteredShared.map((f) => (
<div key={f.id} style={{ padding: '10px 0', borderTop: '1px solid var(--border)' }}>
<div style={{ fontWeight: 600 }}>{f.name_de}</div>
<div style={{ fontSize: 12, color: 'var(--text3)' }}>für alle Nutzer{foodVisibilitySuffix(f.catalog_kind, f.visibility)}</div>
</div>
))}
{(creating || editId) && (
<FoodEditDialog
foodId={creating ? null : editId}
onClose={() => { setCreating(false); setEditId(null) }}
onSaved={(res) => {
setCreating(false)
setEditId(null)
afterSave(res)
}}
/>
)}
</div>
)
}

View File

@ -3,6 +3,8 @@ import { api } from '../utils/api'
import FoodSearchModal from './FoodSearchModal'
import FoodNutrientFields, { foodCreatePayload } from './FoodNutrientFields'
import FoodRecipeDialog from './FoodRecipeDialog'
import FoodEditDialog from './FoodEditDialog'
import { foodVisibilitySuffix } from '../utils/foodVisibility'
const PERIODS = [
{ days: 14, label: '14 Tage' },
@ -127,7 +129,7 @@ function InlineCreate({ defaultName, disabled, allowRecipe, onCreateFood, onWant
)
}
export default function NutritionFoodMap({ onChanged, onMapped, onManageLists, onCreatedList }) {
export default function NutritionFoodMap({ onChanged, onMapped, onManageLists, onCreatedList, onOpenMyFoods }) {
const [unmapped, setUnmapped] = useState([])
const [learned, setLearned] = useState([])
const [recipes, setRecipes] = useState([])
@ -144,6 +146,7 @@ export default function NutritionFoodMap({ onChanged, onMapped, onManageLists, o
const [q, setQ] = useState('')
const [visible, setVisible] = useState(80)
const [showLearned, setShowLearned] = useState(false)
const [editFoodId, setEditFoodId] = useState(null)
const [importing, setImporting] = useState(false)
const [busy, setBusy] = useState(false)
const listRef = useRef(null)
@ -371,6 +374,7 @@ export default function NutritionFoodMap({ onChanged, onMapped, onManageLists, o
<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: 8 }}>
<button type="button" className="btn btn-primary" onClick={() => onManageLists?.()}>Listen verwalten</button>
<button type="button" className="btn btn-secondary" onClick={() => onOpenMyFoods?.()}>Meine Lebensmittel</button>
<button type="button" className="btn btn-secondary" disabled={importing} onClick={() => listRef.current?.click()}>
{importing ? 'Importiere Listen…' : 'FDDB-Listen importieren'}
</button>
@ -448,7 +452,9 @@ export default function NutritionFoodMap({ onChanged, onMapped, onManageLists, o
<div style={{ marginTop: 8, display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
<span style={{ fontSize: 13 }}>
→ <strong>{best.name_de}</strong>
<span style={{ color: 'var(--text3)' }}>{best.bls_code ? ` · ${best.bls_code}` : ' · manuell'}</span>
<span style={{ color: 'var(--text3)' }}>
{best.bls_code ? ` · ${best.bls_code}` : foodVisibilitySuffix(best.catalog_kind, best.visibility)}
</span>
</span>
<button type="button" className="btn btn-primary" disabled={busyRow} onClick={() => assign(u, best.id, {}, best)}>
Bestätigen
@ -457,7 +463,7 @@ export default function NutritionFoodMap({ onChanged, onMapped, onManageLists, o
)}
{suggestions.slice(1).map((s) => (
<div key={s.id} style={{ marginTop: 6, display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
<span style={{ fontSize: 12, color: 'var(--text2)' }}>oder {s.name_de}{s.bls_code ? ` · ${s.bls_code}` : ''}</span>
<span style={{ fontSize: 12, color: 'var(--text2)' }}>oder {s.name_de}{s.bls_code ? ` · ${s.bls_code}` : foodVisibilitySuffix(s.catalog_kind, s.visibility)}</span>
<button type="button" className="btn btn-secondary" disabled={busyRow} onClick={() => assign(u, s.id, {}, s)}>Übernehmen</button>
</div>
))}
@ -510,15 +516,20 @@ export default function NutritionFoodMap({ onChanged, onMapped, onManageLists, o
</button>
</h3>
{showLearned && learned.map((m) => (
<div key={m.id} style={{ display: 'flex', justifyContent: 'space-between', gap: 8, padding: '8px 0', borderTop: '1px solid var(--border)' }}>
<div key={m.id} style={{ display: 'flex', justifyContent: 'space-between', gap: 8, padding: '8px 0', borderTop: '1px solid var(--border)', alignItems: 'center' }}>
<div>
<div style={{ fontWeight: 500 }}>{m.source_name_raw}</div>
<div style={{ fontSize: 12, color: 'var(--text3)' }}>
→ {m.food_name_de}{m.bls_code ? ` · ${m.bls_code}` : ''}
{m.catalog_kind !== 'official_bls' ? ' (manuell)' : ''}
{foodVisibilitySuffix(m.catalog_kind, m.visibility)}
</div>
</div>
<button type="button" className="btn btn-secondary" onClick={() => remove(m.id)}>Löschen</button>
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
{m.catalog_kind === 'manual_user' && m.food_id && (
<button type="button" className="btn btn-secondary" onClick={() => setEditFoodId(m.food_id)}>Bearbeiten</button>
)}
<button type="button" className="btn btn-secondary" onClick={() => remove(m.id)}>Löschen</button>
</div>
</div>
))}
@ -558,6 +569,19 @@ export default function NutritionFoodMap({ onChanged, onMapped, onManageLists, o
{recipeFor && (
<RecipePickModal recipes={recipes} sourceName={recipeFor.source_name_raw} onClose={() => setRecipeFor(null)} onPick={(id) => applyRecipe(recipeFor.source_name_raw, id)} />
)}
{editFoodId && (
<FoodEditDialog
foodId={editFoodId}
onClose={() => setEditFoodId(null)}
onSaved={(res) => {
load()
onChanged?.()
const n = res?.rebuild_pending
if (n) setNotice(`Lebensmittel gespeichert. Tageswerte für ${n} Tag${n === 1 ? '' : 'e'} werden nachgerechnet.`)
else setNotice('Lebensmittel gespeichert.')
}}
/>
)}
</div>
)
}

View File

@ -128,7 +128,7 @@ export const ADMIN_GROUPS = [
{
to: '/admin/bls-foods',
label: 'Lebensmittelkatalog',
description: 'Suche inkl. BLS-Code, manuelle Einträge.',
description: 'Suche (BLS) und Reiter Manuell für selbst angelegte Einträge.',
},
{
to: '/admin/food-attributes',

View File

@ -1,43 +1,142 @@
import { useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { api } from '../utils/api'
import FoodEditDialog from '../components/FoodEditDialog'
export default function AdminBlsFoodsPage() {
const [tab, setTab] = useState('search')
const [q, setQ] = useState('')
const [manualQ, setManualQ] = useState('')
const [rows, setRows] = useState([])
const [manualRows, setManualRows] = useState([])
const [detail, setDetail] = useState(null)
const [error, setError] = useState(null)
const [loadingManual, setLoadingManual] = useState(false)
const [creating, setCreating] = useState(false)
const [editId, setEditId] = useState(null)
const timer = useRef(null)
const search = async () => {
const search = async (term) => {
try {
setRows(await api.adminBlsFoods(q))
setError(null)
setRows(await api.adminBlsFoods(term, '', 50))
} catch (e) {
setError(e.message)
}
}
const loadManual = async (term) => {
setLoadingManual(true)
try {
setError(null)
setManualRows(await api.adminBlsFoods(term, 'manual', 200))
} catch (e) {
setError(e.message)
} finally {
setLoadingManual(false)
}
}
useEffect(() => {
if (tab === 'manual') loadManual(manualQ)
}, [tab])
useEffect(() => () => clearTimeout(timer.current), [])
const onManualQuery = (value) => {
setManualQ(value)
clearTimeout(timer.current)
timer.current = setTimeout(() => loadManual(value), 300)
}
const shownManual = useMemo(() => manualRows, [manualRows])
const openDetail = (id) => {
api.adminBlsFoodDetail(id).then(setDetail).catch((e) => setError(e.message))
}
const list = tab === 'manual' ? shownManual : rows
return (
<div className="card">
<h1 className="page-title">Lebensmittelkatalog</h1>
{error && <p style={{ color: 'var(--danger)' }}>{error}</p>}
<div className="form-row">
<input className="form-input" value={q} onChange={(e) => setQ(e.target.value)} placeholder="Name oder BLS-Code" />
<button type="button" className="btn btn-primary" onClick={search}>Suchen</button>
</div>
{rows.map((r) => (
<button
key={r.id}
type="button"
className="btn btn-secondary btn-full"
style={{ marginTop: 6, textAlign: 'left' }}
onClick={() => api.adminBlsFoodDetail(r.id).then(setDetail).catch((e) => setError(e.message))}
>
{r.bls_code ? `${r.bls_code} · ` : ''}{r.name_de}
{r.catalog_kind !== 'official_bls' ? ' · manuell' : ''}
<div className="tabs section-gap" style={{ marginBottom: 12 }}>
<button type="button" className={'tab' + (tab === 'search' ? ' active' : '')} onClick={() => { setTab('search'); setDetail(null) }}>
Suche
</button>
<button type="button" className={'tab' + (tab === 'manual' ? ' active' : '')} onClick={() => { setTab('manual'); setDetail(null) }}>
Manuell
</button>
</div>
{tab === 'search' && (
<>
<p style={{ fontSize: 13, color: 'var(--text2)', margin: '0 0 10px' }}>
BLS und manuelle Einträge nach Name oder Code. Die komplette BLS-Liste ist zu groß zum Durchblättern.
</p>
<div className="form-row">
<input
className="form-input"
value={q}
onChange={(e) => setQ(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') search(q) }}
placeholder="Name oder BLS-Code"
/>
<button type="button" className="btn btn-primary" onClick={() => search(q)}>Suchen</button>
</div>
</>
)}
{tab === 'manual' && (
<>
<p style={{ fontSize: 13, color: 'var(--text2)', margin: '0 0 10px' }}>
Zentral (für alle Nutzer) und persönlich (nur der jeweilige Nutzer). Zentrale Einträge hier anlegen;
persönliche bleiben beim Owner.
</p>
<div style={{ marginBottom: 10 }}>
<button type="button" className="btn btn-primary" onClick={() => setCreating(true)}>Zentral anlegen</button>
</div>
<input
className="form-input"
style={{ width: '100%', textAlign: 'left', marginBottom: 10 }}
value={manualQ}
onChange={(e) => onManualQuery(e.target.value)}
placeholder="In manuellen Einträgen suchen…"
/>
<p style={{ fontSize: 13, margin: '0 0 8px' }}>
{loadingManual ? 'Lade…' : `${shownManual.length} Einträge`}
{!loadingManual && shownManual.length >= 200 ? ' (Anzeige begrenzt auf 200)' : ''}
</p>
</>
)}
{tab === 'search' && rows.length === 0 && (
<p className="muted">Suchbegriff eingeben und Suchen.</p>
)}
{tab === 'manual' && !loadingManual && shownManual.length === 0 && (
<p className="muted">Keine manuellen Lebensmittel{manualQ.trim() ? ` zu „${manualQ.trim()}“` : ''}.</p>
)}
{list.map((r) => (
<div key={r.id} style={{ display: 'flex', gap: 8, marginTop: 6 }}>
<button
type="button"
className="btn btn-secondary"
style={{ flex: 1, textAlign: 'left' }}
onClick={() => openDetail(r.id)}
>
{r.bls_code ? `${r.bls_code} · ` : ''}{r.name_de}
{r.catalog_kind === 'manual_admin' ? ' · zentral (alle)' : r.catalog_kind === 'manual_user' ? ' · persönlich' : ''}
</button>
{r.catalog_kind === 'manual_admin' && (
<button type="button" className="btn btn-secondary" onClick={() => setEditId(r.id)}>Bearbeiten</button>
)}
</div>
))}
{detail && (
<div style={{ marginTop: 16 }}>
<h3>{detail.bls_code} {detail.name_de}</h3>
<h3>{detail.bls_code ? `${detail.bls_code} · ` : ''}{detail.name_de}</h3>
<p style={{ fontSize: 12, color: 'var(--text3)' }}>{detail.catalog_kind} · {detail.bls_version || '—'}</p>
<table style={{ width: '100%', fontSize: 12 }}>
<tbody>
@ -52,6 +151,18 @@ export default function AdminBlsFoodsPage() {
</table>
</div>
)}
{(creating || editId) && (
<FoodEditDialog
mode="admin"
foodId={creating ? null : editId}
onClose={() => { setCreating(false); setEditId(null) }}
onSaved={() => {
setCreating(false)
setEditId(null)
if (tab === 'manual') loadManual(manualQ)
}}
/>
)}
</div>
)
}

View File

@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react'
import { api } from '../utils/api'
import { foodVisibilityLabel } from '../utils/foodVisibility'
export default function AdminFoodMappingsPage() {
const [rows, setRows] = useState([])
@ -30,7 +31,7 @@ export default function AdminFoodMappingsPage() {
setter([])
return
}
api.searchBlsFoods(q, 20).then(setter).catch(() => setter([]))
api.searchBlsFoods(q, 20, undefined, 'global').then(setter).catch(() => setter([]))
}
const shown = rows.filter((r) => {
@ -59,6 +60,10 @@ export default function AdminFoodMappingsPage() {
<div className="card">
<h1 className="page-title">FDDB→BLS Mappings</h1>
{error && <p style={{ color: 'var(--danger)' }}>{error}</p>}
<p style={{ fontSize: 13, color: 'var(--text2)', margin: '0 0 10px' }}>
Globale Zuordnungen gelten für alle Nutzer und dürfen nur auf BLS oder zentrale Lebensmittel zeigen.
Persönliche Nutzer-Mappings bleiben beim jeweiligen Profil.
</p>
{coverage && (
<p style={{ fontSize: 13 }}>
{coverage.mapped_items}/{coverage.total_items} Zeilen gemappt ·
@ -77,7 +82,7 @@ export default function AdminFoodMappingsPage() {
</div>
{hits.map((h) => (
<button key={h.id} type="button" className="btn btn-secondary" style={{ margin: 4 }} onClick={() => { setFoodId(h.id); setFoodQ(h.name_de) }}>
{h.bls_code} {h.name_de}
{h.bls_code} {h.name_de}{h.catalog_kind === 'manual_admin' ? ' · zentral' : h.bls_code ? '' : ' · BLS'}
</button>
))}
<button
@ -100,7 +105,10 @@ export default function AdminFoodMappingsPage() {
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, alignItems: 'flex-start' }}>
<div>
{r.source_name_raw} → {r.bls_code} {r.food_name_de}
<div style={{ fontSize: 11, color: 'var(--text3)' }}>{r.profile_id ? 'user' : 'global'}</div>
<div style={{ fontSize: 11, color: 'var(--text3)' }}>
{r.profile_id ? 'Nutzer-Mapping' : 'globales Mapping'}
{r.catalog_kind ? ` · ${foodVisibilityLabel(r.catalog_kind, r.visibility)}` : ''}
</div>
</div>
<div style={{ display: 'flex', gap: 6 }}>
<button type="button" className="btn btn-secondary" onClick={() => { setEditId(editId === r.id ? null : r.id); setEditQ(''); setEditHits([]) }}>
@ -131,7 +139,7 @@ export default function AdminFoodMappingsPage() {
disabled={saving}
onClick={() => applyChange(r.id, h)}
>
{h.name_de}{h.bls_code ? ` · ${h.bls_code}` : ''}
{h.name_de}{h.bls_code ? ` · ${h.bls_code}` : ''} · {foodVisibilityLabel(h.catalog_kind, h.visibility)}
</button>
))}
</div>

View File

@ -3,6 +3,7 @@ import { Upload, CheckCircle, TrendingUp, Info } from 'lucide-react'
import UsageBadge from '../components/UsageBadge'
import NutritionFoodMap, { DayMarkButtons } from '../components/NutritionFoodMap'
import FoodRecipeEditor from '../components/FoodRecipeEditor'
import MyFoodsPanel from '../components/MyFoodsPanel'
import {
LineChart, Line, BarChart, Bar, XAxis, YAxis, Tooltip,
ResponsiveContainer, CartesianGrid, Legend, ReferenceLine, ScatterChart, Scatter
@ -957,7 +958,7 @@ export default function NutritionPage() {
)}
{/* Input Method Tabs */}
<div className="tabs section-gap" style={{marginBottom:0}}>
<div className="tabs section-gap" style={{marginBottom:0, overflowX:'auto', flexWrap:'nowrap'}}>
<button className={'tab'+(inputTab==='entry'?' active':'')} onClick={()=>setInputTab('entry')}>
✏️ Einzelerfassung
</button>
@ -967,6 +968,9 @@ export default function NutritionPage() {
<button className={'tab'+(inputTab==='map'?' active':'')} onClick={()=>setInputTab('map')}>
Zuordnen{unmappedCount ? ` (${unmappedCount})` : ''}
</button>
<button className={'tab'+(inputTab==='foods'?' active':'')} onClick={()=>setInputTab('foods')}>
Lebensmittel
</button>
<button className={'tab'+(inputTab==='lists'?' active':'')} onClick={()=>setInputTab('lists')}>
Listen
</button>
@ -992,9 +996,12 @@ export default function NutritionPage() {
onChanged={refreshUnmappedCount}
onManageLists={(id) => { setFocusListId(id || null); setInputTab('lists') }}
onCreatedList={(rec) => { setFocusListId(rec?.id || null); setInputTab('lists') }}
onOpenMyFoods={() => setInputTab('foods')}
/>
)}
{inputTab==='foods' && <MyFoodsPanel />}
{inputTab==='lists' && (
<FoodRecipeEditor
highlightId={focusListId}
@ -1002,9 +1009,9 @@ export default function NutritionPage() {
/>
)}
{loading && inputTab !== 'map' && inputTab !== 'lists' && <div className="empty-state"><div className="spinner"/></div>}
{loading && inputTab !== 'map' && inputTab !== 'lists' && inputTab !== 'foods' && <div className="empty-state"><div className="spinner"/></div>}
{!loading && !hasData && inputTab !== 'map' && inputTab !== 'lists' && (
{!loading && !hasData && inputTab !== 'map' && inputTab !== 'lists' && inputTab !== 'foods' && (
<div className="empty-state">
<h3>Noch keine Ernährungsdaten</h3>
<p>Erfasse Daten über Einzelerfassung oder importiere deinen FDDB-Export.</p>
@ -1012,7 +1019,7 @@ export default function NutritionPage() {
)}
{/* Analysis Section */}
{!loading && hasData && inputTab !== 'map' && inputTab !== 'lists' && (
{!loading && hasData && inputTab !== 'map' && inputTab !== 'lists' && inputTab !== 'foods' && (
<>
<OverviewCards data={corrData}/>

View File

@ -285,9 +285,13 @@ export const api = {
deleteNutritionDayMark: (date) => req(`/nutrition/days/${date}/mark`, {method:'DELETE'}),
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, scope) => req(`/bls/foods?q=${encodeURIComponent(q||'')}&limit=${limit}${scope?`&scope=${encodeURIComponent(scope)}`:''}`, signal ? { signal } : {}),
suggestFoodsBatch: (names, limit=3) => req('/bls/foods/suggest-batch', json({ names, limit })),
createUserFood: (d) => req('/bls/foods/manual', json(d)),
listMyFoods: () => req('/bls/foods/mine'),
listSharedFoods: () => req('/bls/foods/shared'),
getBlsFood: (id) => req(`/bls/foods/${encodeURIComponent(id)}`),
updateUserFood: (id, d) => req(`/bls/foods/manual/${encodeURIComponent(id)}`, jput(d)),
listFoodAttributes: (q='', limit=40, keys='') => req(`/bls/attributes?q=${encodeURIComponent(q||'')}&limit=${limit}${keys ? `&keys=${encodeURIComponent(keys)}` : ''}`),
createFoodAttribute: (d) => req('/bls/attributes', json(d)),
listMyFoodMappings: () => req('/bls/mappings'),
@ -301,9 +305,10 @@ export const api = {
},
adminBlsImportJob: (id) => req(`/admin/bls/import/jobs/${id}`),
adminBlsImportApply: (id) => req(`/admin/bls/import/jobs/${id}/apply`, {method:'POST'}),
adminBlsFoods: (q, kind) => req(`/admin/bls/foods?${q?('q='+encodeURIComponent(q)+'&'):''}${kind?('kind='+kind):''}`),
adminBlsFoods: (q, kind, limit) => req(`/admin/bls/foods?${q?('q='+encodeURIComponent(q)+'&'):''}${kind?('kind='+encodeURIComponent(kind)+'&'):''}${limit?('limit='+limit):''}`),
adminBlsFoodDetail: (id) => req(`/admin/bls/foods/${id}`),
adminCreateManualFood: (d) => req('/admin/bls/foods/manual', json(d)),
adminUpdateManualFood: (id, d) => req(`/admin/bls/foods/manual/${encodeURIComponent(id)}`, jput(d)),
adminBlsAttributes: () => req('/admin/bls/attributes'),
adminCreateBlsAttribute: (d) => req('/admin/bls/attributes', json(d)),
adminListFoodMappings: (globalOnly) => req(`/admin/food-mappings${globalOnly?'?global_only=true':''}`),

View File

@ -0,0 +1,23 @@
const LABELS = {
personal: 'persönlich',
global: 'zentral',
bls: 'BLS',
}
export function foodVisibility(kind, visibility) {
if (visibility && LABELS[visibility]) return visibility
if (kind === 'official_bls') return 'bls'
if (kind === 'manual_admin') return 'global'
if (kind === 'manual_user') return 'personal'
return ''
}
export function foodVisibilityLabel(kind, visibility) {
const key = foodVisibility(kind, visibility)
return LABELS[key] || ''
}
export function foodVisibilitySuffix(kind, visibility) {
const label = foodVisibilityLabel(kind, visibility)
return label ? ` · ${label}` : ''
}