fix: zentrale Lebensmittel fuer Admins aenderbar
All checks were successful
Deploy Development / deploy (push) Successful in 1m7s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 23s

Persoenliche Liste bleibt beim Owner; zentrale Eintraege oeffnen den Admin-Editor. Admin-Katalog trennt beide Listen, persoenliche bleiben sichtbar.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-09-14 13:01:41 +02:00
parent 9a0767c8ac
commit e452d9a8fe
13 changed files with 134 additions and 45 deletions

View File

@ -48,7 +48,7 @@ Gelernte Zuordnungen und Listen lassen sich als **JSON sichern** und auf einer a
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. 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. **Nachträglich ändern:** Unter Ernährung → Tab **Lebensmittel** (persönliche Liste + zentrale Admin-Einträge, Suche). Persönliche Einträge legt und ändert der Owner. Zentrale Einträge liest jeder; **ändert nur ein Admin** (gleiche Liste, Zeile oder Bearbeiten). 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** zeigt **Zentral** und **Persönlich** getrennt; beide sind dort änderbar.
Weitere Quellen (USDA, Schweizer Nährwertdatenbank) kommen später als zusätzliche Katalogherkunft, nicht als zweite Wertetabelle. Weitere Quellen (USDA, Schweizer Nährwertdatenbank) kommen später als zusätzliche Katalogherkunft, nicht als zweite Wertetabelle.

View File

@ -29,9 +29,9 @@ FDDB: Items persistieren; `nutrition_log` nur bei leerem Tag oder laut Policy /
## Router ## 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` - `/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). - `GET /bls/foods/mine` — eigene `manual_user`-Einträge; `GET /bls/foods/shared` — zentrale `manual_admin`; `GET /bls/foods/{id}` — Editor-Payload inkl. `visibility` und `editable` (Owner persönlich; Admin auch zentral); `PUT /bls/foods/manual/{id}` — nur Owner. Zentrale Updates: `PUT /admin/bls/foods/manual/{id}` (Admin). 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. - 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). - `GET /admin/bls/foods?kind=manual` — `manual_user`/`manual_admin`. `POST /admin/bls/foods/manual` — zentrale Foods. `PUT /admin/bls/foods/manual/{id}` — zentral **und** persönlich (`attributes` + `serving_g`). `GET /admin/bls/foods/{id}?editor=true` — Editor-Payload. 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 - 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/bls/*` — Import, Katalog, Attribute
- `/api/admin/food-mappings` — Admin-CRUD - `/api/admin/food-mappings` — Admin-CRUD

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-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“. - **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:** 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. - **Manuelle Foods:** Persönlich (`manual_user` + Owner, nur dieser Nutzer) oder zentral (`manual_admin`, alle Nutzer). Stoffe über EAV. Nutzer: Tab Ernährung → **Lebensmittel** (persönlich ändern; zentral nur Admin). Admin: Katalog → Manuell — Listen **Zentral** und **Persönlich**, beide änderbar. 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}`. - **Listen / Kombinationen (Mitai):** Tab **Listen** (anlegen, bearbeiten, FDDB-Import). Im Listendialog Zutaten direkt BLS-zuordnen (Status offen/zugeordnet); Suche zuerst Listen-Namen und eigene Foods, dann BLS. **Tandoor:** User-Einstellungen URL + Token, Verbindungstest (`/api/tandoor/*`). Gerichte bleiben in Tandoor; später Mapping + `cooked_yield_g`. Admin-Mappings: `PUT /admin/food-mappings/{id}`.
- **Gitea #106:** BLS-Stammdaten, FDDB-Mapping, Item-Tagebuch — http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/106 - **Gitea #106:** BLS-Stammdaten, FDDB-Mapping, Item-Tagebuch — http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/106
- **Doku:** `.claude/docs/functional/BLS_FOOD_REFERENCE.md`, `.claude/docs/technical/BLS_FOOD_REFERENCE.md`, `docs/issues/issue-bls-food-mapping.md`. Folge #75. - **Doku:** `.claude/docs/functional/BLS_FOOD_REFERENCE.md`, `.claude/docs/technical/BLS_FOOD_REFERENCE.md`, `docs/issues/issue-bls-food-mapping.md`. Folge #75.

View File

@ -62,7 +62,7 @@ def is_visible_to(food: dict[str, Any] | None, profile_id: str | None) -> bool:
owner = food.get("owner_profile_id") owner = food.get("owner_profile_id")
if owner is None: if owner is None:
return True return True
return profile_id is not None and str(owner) == str(profile_id) return profile_id is not None and same_id(owner, profile_id)
def is_global_mapping_target(food: dict[str, Any] | None) -> bool: def is_global_mapping_target(food: dict[str, Any] | None) -> bool:
@ -104,6 +104,18 @@ def require_mapping_target(cur, food_id: str, mapping_profile_id: str | None) ->
return require_global_mapping_target(cur, food_id) return require_global_mapping_target(cur, food_id)
def can_edit_food(food: dict[str, Any] | None, profile_id: str | None, *, is_admin: bool = False) -> bool:
"""Owner ändert persönlich; Admin ändert zentral (und im Admin-Katalog auch fremde Manuals)."""
if not is_active(food):
return False
kind = food.get("catalog_kind")
if kind == "manual_user":
return same_id(food.get("owner_profile_id"), profile_id) or bool(is_admin)
if kind == "manual_admin":
return bool(is_admin)
return False
def attach_visibility(row: dict[str, Any]) -> dict[str, Any]: def attach_visibility(row: dict[str, Any]) -> dict[str, Any]:
row["visibility"] = visibility_of(row) row["visibility"] = visibility_of(row)
return row return row

View File

@ -238,8 +238,8 @@ def get_food_detail(food_id: str, session: dict = Depends(require_auth)):
if not food: if not food:
raise HTTPException(404, "Lebensmittel nicht gefunden") raise HTTPException(404, "Lebensmittel nicht gefunden")
food = dict(food) food = dict(food)
from data_layer.food_visibility import attach_visibility, same_id from data_layer.food_visibility import attach_visibility, can_edit_food
editable = food.get("catalog_kind") == "manual_user" and same_id(food.get("owner_profile_id"), pid) editable = can_edit_food(food, pid, is_admin=session.get("role") == "admin")
return attach_visibility(editor_payload_for_food(cur, food, editable=editable)) return attach_visibility(editor_payload_for_food(cur, food, editable=editable))

View File

@ -1,5 +1,6 @@
from data_layer.food_visibility import ( from data_layer.food_visibility import (
attach_visibility, attach_visibility,
can_edit_food,
is_global_mapping_target, is_global_mapping_target,
is_visible_to, is_visible_to,
require_mapping_target, require_mapping_target,
@ -9,6 +10,18 @@ from data_layer.food_visibility import (
) )
def test_can_edit_central_only_as_admin():
central = {"catalog_kind": "manual_admin", "owner_profile_id": None, "is_active": True}
personal = {"catalog_kind": "manual_user", "owner_profile_id": "alice", "is_active": True}
bls = {"catalog_kind": "official_bls", "owner_profile_id": None, "is_active": True}
assert can_edit_food(central, "alice", is_admin=False) is False
assert can_edit_food(central, "alice", is_admin=True) is True
assert can_edit_food(personal, "alice", is_admin=False) is True
assert can_edit_food(personal, "bob", is_admin=False) is False
assert can_edit_food(personal, "bob", is_admin=True) is True
assert can_edit_food(bls, "alice", is_admin=True) is False
def test_same_id_ignores_dashes_and_case(): def test_same_id_ignores_dashes_and_case():
assert same_id("AaBbCcDd-0000-0000-0000-000000000001", "aabbccdd000000000000000000000001") assert same_id("AaBbCcDd-0000-0000-0000-000000000001", "aabbccdd000000000000000000000001")

View File

@ -22,7 +22,7 @@ MODULE_VERSIONS = {
"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.7", # Sichtbarkeit persönlich vs. zentral im Mapping "nutrition": "1.3.7", # Sichtbarkeit persönlich vs. zentral im Mapping
"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.10", # Manuelle Liste: Zeile öffnet Editor; Admin darf alle Manuals ändern "bls": "1.0.11", # Zentrale Manuals: Admin-Editor inkl. Nutzerliste; Admin-Katalog getrennt
"photos": "1.0.0", "photos": "1.0.0",
"insights": "1.3.0", "insights": "1.3.0",

View File

@ -1,5 +1,6 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { api } from '../utils/api' import { api } from '../utils/api'
import { useAuth } from '../context/AuthContext'
import FoodNutrientFields, { foodCreatePayload } from './FoodNutrientFields' import FoodNutrientFields, { foodCreatePayload } from './FoodNutrientFields'
function numStr(v) { function numStr(v) {
@ -9,17 +10,25 @@ function numStr(v) {
} }
export default function FoodEditDialog({ foodId, onClose, onSaved, zIndex, mode }) { export default function FoodEditDialog({ foodId, onClose, onSaved, zIndex, mode }) {
const { isAdmin: sessionAdmin } = useAuth() || {}
const isCreate = !foodId const isCreate = !foodId
const isAdmin = mode === 'admin' const createAsAdmin = mode === 'admin'
const [name, setName] = useState('') const [name, setName] = useState('')
const [macros, setMacros] = useState({ kcal: '0', protein_g: '0', fat_g: '0', carbs_g: '0' }) const [macros, setMacros] = useState({ kcal: '0', protein_g: '0', fat_g: '0', carbs_g: '0' })
const [extras, setExtras] = useState([]) const [extras, setExtras] = useState([])
const [servingG, setServingG] = useState('100') const [servingG, setServingG] = useState('100')
const [catalogKind, setCatalogKind] = useState(null)
const [editable, setEditable] = useState(false) const [editable, setEditable] = useState(false)
const [loading, setLoading] = useState(!isCreate) const [loading, setLoading] = useState(!isCreate)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [error, setError] = useState(null) const [error, setError] = useState(null)
const saveAsAdmin = isCreate
? createAsAdmin
: Boolean(sessionAdmin && catalogKind && catalogKind !== 'official_bls' && (
catalogKind === 'manual_admin' || mode === 'admin'
))
useEffect(() => { useEffect(() => {
if (isCreate) { if (isCreate) {
setEditable(true) setEditable(true)
@ -27,10 +36,13 @@ export default function FoodEditDialog({ foodId, onClose, onSaved, zIndex, mode
return return
} }
let alive = true let alive = true
const load = isAdmin ? api.adminBlsFoodDetail(foodId, true) : api.getBlsFood(foodId) const load = (sessionAdmin || mode === 'admin')
? api.adminBlsFoodDetail(foodId, true).catch(() => api.getBlsFood(foodId))
: api.getBlsFood(foodId)
load.then((d) => { load.then((d) => {
if (!alive) return if (!alive) return
setName(d.name_de || '') setName(d.name_de || '')
setCatalogKind(d.catalog_kind || null)
const m = d.macros_per_100g || {} const m = d.macros_per_100g || {}
setMacros({ setMacros({
kcal: numStr(m.kcal), kcal: numStr(m.kcal),
@ -38,13 +50,15 @@ export default function FoodEditDialog({ foodId, onClose, onSaved, zIndex, mode
fat_g: numStr(m.fat_g), fat_g: numStr(m.fat_g),
carbs_g: numStr(m.carbs_g), carbs_g: numStr(m.carbs_g),
}) })
setExtras((d.attributes || []).map((row) => ({ setExtras((d.attributes || []).filter((row) => row?.attr).map((row) => ({
attr: row.attr, attr: row.attr,
value: numStr(row.value), value: numStr(row.value),
inputUnit: row.attr?.unit || 'g', inputUnit: row.attr?.unit || 'g',
}))) })))
setServingG(numStr(d.serving_g || 100) === '0' ? '100' : numStr(d.serving_g || 100)) setServingG(numStr(d.serving_g || 100) === '0' ? '100' : numStr(d.serving_g || 100))
const canEdit = Boolean(d.editable) || (isAdmin && d.catalog_kind && d.catalog_kind !== 'official_bls') const canEdit = Boolean(d.editable) || (
sessionAdmin && d.catalog_kind && d.catalog_kind !== 'official_bls'
)
setEditable(canEdit) setEditable(canEdit)
setLoading(false) setLoading(false)
}).catch((e) => { }).catch((e) => {
@ -53,7 +67,7 @@ export default function FoodEditDialog({ foodId, onClose, onSaved, zIndex, mode
setLoading(false) setLoading(false)
}) })
return () => { alive = false } return () => { alive = false }
}, [foodId, isCreate, isAdmin]) }, [foodId, isCreate, sessionAdmin, mode])
const save = async () => { const save = async () => {
if (!name.trim()) { if (!name.trim()) {
@ -65,7 +79,7 @@ export default function FoodEditDialog({ foodId, onClose, onSaved, zIndex, mode
try { try {
const body = foodCreatePayload(name, macros, extras, servingG) const body = foodCreatePayload(name, macros, extras, servingG)
let saved let saved
if (isAdmin) { if (saveAsAdmin) {
saved = isCreate ? await api.adminCreateManualFood(body) : await api.adminUpdateManualFood(foodId, body) saved = isCreate ? await api.adminCreateManualFood(body) : await api.adminUpdateManualFood(foodId, body)
} else { } else {
saved = isCreate ? await api.createUserFood(body) : await api.updateUserFood(foodId, body) saved = isCreate ? await api.createUserFood(body) : await api.updateUserFood(foodId, body)
@ -79,6 +93,10 @@ export default function FoodEditDialog({ foodId, onClose, onSaved, zIndex, mode
} }
} }
const title = isCreate
? (createAsAdmin ? 'Zentrales Lebensmittel anlegen' : 'Lebensmittel anlegen')
: (catalogKind === 'manual_admin' ? 'Zentrales Lebensmittel bearbeiten' : 'Lebensmittel bearbeiten')
return ( return (
<div <div
style={{ style={{
@ -98,11 +116,7 @@ export default function FoodEditDialog({ foodId, onClose, onSaved, zIndex, mode
}} }}
> >
<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 }}> <h2 className="card-title" style={{ margin: 0, fontSize: 16 }}>{title}</h2>
{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> <button type="button" className="btn btn-secondary" disabled={saving} onClick={onClose}>Schließen</button>
</div> </div>
<div style={{ overflowY: 'auto', padding: '12px 16px 16px', flex: 1 }}> <div style={{ overflowY: 'auto', padding: '12px 16px 16px', flex: 1 }}>
@ -110,7 +124,9 @@ export default function FoodEditDialog({ foodId, onClose, onSaved, zIndex, mode
{error && <div style={{ color: 'var(--danger)', fontSize: 13, marginBottom: 10 }}>{error}</div>} {error && <div style={{ color: 'var(--danger)', fontSize: 13, marginBottom: 10 }}>{error}</div>}
{!loading && !editable && ( {!loading && !editable && (
<p style={{ fontSize: 13, color: 'var(--text2)' }}> <p style={{ fontSize: 13, color: 'var(--text2)' }}>
Offizielle BLS-Einträge sind fest. Nur selbst angelegte{isAdmin ? ' zentrale' : ''} Lebensmittel lassen sich ändern. {catalogKind === 'manual_admin'
? 'Zentrale Lebensmittel ändert nur ein Admin.'
: 'Offizielle BLS-Einträge sind fest. Nur selbst angelegte Lebensmittel lassen sich ändern.'}
</p> </p>
)} )}
{!loading && editable && ( {!loading && editable && (

View File

@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { api } from '../utils/api' import { api } from '../utils/api'
import { useAuth } from '../context/AuthContext'
import FoodSearchModal from './FoodSearchModal' import FoodSearchModal from './FoodSearchModal'
import FoodEditDialog from './FoodEditDialog' import FoodEditDialog from './FoodEditDialog'
import { foodVisibilityLabel } from '../utils/foodVisibility' import { foodVisibilityLabel } from '../utils/foodVisibility'
@ -113,6 +114,7 @@ export default function FoodRecipeDialog({
onSave, onSave,
onClose, onClose,
}) { }) {
const { isAdmin } = useAuth()
const [name, setName] = useState(defaultName || '') const [name, setName] = useState(defaultName || '')
const [portions, setPortions] = useState(defaultPortions != null ? String(defaultPortions) : '1') const [portions, setPortions] = useState(defaultPortions != null ? String(defaultPortions) : '1')
const [ings, setIngs] = useState( const [ings, setIngs] = useState(
@ -494,7 +496,7 @@ export default function FoodRecipeDialog({
<span style={{ fontSize: 12, color: 'var(--text3)' }}>{foodVisibilityLabel(h.catalog_kind, h.visibility)}</span> <span style={{ fontSize: 12, color: 'var(--text3)' }}>{foodVisibilityLabel(h.catalog_kind, h.visibility)}</span>
</span> </span>
</button> </button>
{h.catalog_kind === 'manual_user' && ( {(h.catalog_kind === 'manual_user' || (isAdmin && h.catalog_kind === 'manual_admin')) && (
<button type="button" className="btn btn-secondary" style={{ flexShrink: 0 }} onClick={() => setEditFoodId(h.id)}> <button type="button" className="btn btn-secondary" style={{ flexShrink: 0 }} onClick={() => setEditFoodId(h.id)}>
Bearbeiten Bearbeiten
</button> </button>

View File

@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { api } from '../utils/api' import { api } from '../utils/api'
import { useAuth } from '../context/AuthContext'
import FoodNutrientFields, { foodCreatePayload } from './FoodNutrientFields' import FoodNutrientFields, { foodCreatePayload } from './FoodNutrientFields'
import FoodEditDialog from './FoodEditDialog' import FoodEditDialog from './FoodEditDialog'
import { foodVisibilitySuffix } from '../utils/foodVisibility' import { foodVisibilitySuffix } from '../utils/foodVisibility'
@ -28,6 +29,7 @@ function detectUnit(...texts) {
} }
export default function FoodSearchModal({ title, initialQuery, quantityHint, allowRecipe, onSelect, onWantRecipe, onClose, zIndex }) { export default function FoodSearchModal({ title, initialQuery, quantityHint, allowRecipe, onSelect, onWantRecipe, onClose, zIndex }) {
const { isAdmin } = useAuth()
const [q, setQ] = useState(initialQuery || '') const [q, setQ] = useState(initialQuery || '')
const [hits, setHits] = useState([]) const [hits, setHits] = useState([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@ -191,7 +193,7 @@ export default function FoodSearchModal({ title, initialQuery, quantityHint, all
<p style={{ fontSize: 13, color: 'var(--text3)' }}>Kein Treffer im Katalog.</p> <p style={{ fontSize: 13, color: 'var(--text3)' }}>Kein Treffer im Katalog.</p>
)} )}
{hits.map((h) => { {hits.map((h) => {
const manual = h.catalog_kind && h.catalog_kind !== 'official_bls' && h.catalog_kind !== 'manual_admin' const manual = h.catalog_kind === 'manual_user' || (isAdmin && h.catalog_kind === 'manual_admin')
return ( return (
<div key={h.id} style={{ display: 'flex', gap: 8, marginTop: 8, alignItems: 'stretch' }}> <div key={h.id} style={{ display: 'flex', gap: 8, marginTop: 8, alignItems: 'stretch' }}>
<button <button

View File

@ -56,7 +56,8 @@ export default function MyFoodsPanel() {
<div className="card section-gap"> <div className="card section-gap">
<div className="card-title">Lebensmittel</div> <div className="card-title">Lebensmittel</div>
<p style={{ fontSize: 13, color: 'var(--text2)', lineHeight: 1.6, marginBottom: 12 }}> <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. <strong>Persönlich:</strong> nur für dich, hier änderbar. <strong>Zentral:</strong> für alle Nutzer
{isAdmin ? ' — als Admin hier ebenfalls änderbar' : ' (nur Admin ändert)'}.
BLS-Katalog suchst du beim Zuordnen. Ein Öl/Supplement bleibt ein Lebensmittel, keine Liste. BLS-Katalog suchst du beim Zuordnen. Ein Öl/Supplement bleibt ein Lebensmittel, keine Liste.
</p> </p>
{error && <div style={{ color: 'var(--danger)', fontSize: 13, marginBottom: 10 }}>{error}</div>} {error && <div style={{ color: 'var(--danger)', fontSize: 13, marginBottom: 10 }}>{error}</div>}
@ -109,10 +110,21 @@ export default function MyFoodsPanel() {
)} )}
{filteredShared.map((f) => ( {filteredShared.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 key={f.id} style={{ display: 'flex', justifyContent: 'space-between', gap: 8, padding: '10px 0', borderTop: '1px solid var(--border)', alignItems: 'center' }}>
{isAdmin ? (
<button
type="button"
onClick={() => { setEditMode('admin'); setEditId(f.id) }}
style={{ background: 'none', border: 0, padding: 0, textAlign: 'left', color: 'inherit', cursor: 'pointer' }}
>
<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>
</button>
) : (
<div> <div>
<div style={{ fontWeight: 600 }}>{f.name_de}</div> <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 style={{ fontSize: 12, color: 'var(--text3)' }}>für alle Nutzer{foodVisibilitySuffix(f.catalog_kind, f.visibility)}</div>
</div> </div>
)}
{isAdmin && ( {isAdmin && (
<button type="button" className="btn btn-primary" style={{ fontSize: 12 }} onClick={() => { setEditMode('admin'); setEditId(f.id) }}>Bearbeiten</button> <button type="button" className="btn btn-primary" style={{ fontSize: 12 }} onClick={() => { setEditMode('admin'); setEditId(f.id) }}>Bearbeiten</button>
)} )}

View File

@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { api } from '../utils/api' import { api } from '../utils/api'
import { useAuth } from '../context/AuthContext'
import FoodSearchModal from './FoodSearchModal' import FoodSearchModal from './FoodSearchModal'
import FoodNutrientFields, { foodCreatePayload } from './FoodNutrientFields' import FoodNutrientFields, { foodCreatePayload } from './FoodNutrientFields'
import FoodRecipeDialog from './FoodRecipeDialog' import FoodRecipeDialog from './FoodRecipeDialog'
@ -130,6 +131,7 @@ function InlineCreate({ defaultName, disabled, allowRecipe, onCreateFood, onWant
} }
export default function NutritionFoodMap({ onChanged, onMapped, onManageLists, onCreatedList, onOpenMyFoods }) { export default function NutritionFoodMap({ onChanged, onMapped, onManageLists, onCreatedList, onOpenMyFoods }) {
const { isAdmin } = useAuth()
const [unmapped, setUnmapped] = useState([]) const [unmapped, setUnmapped] = useState([])
const [learned, setLearned] = useState([]) const [learned, setLearned] = useState([])
const [recipes, setRecipes] = useState([]) const [recipes, setRecipes] = useState([])
@ -525,7 +527,7 @@ export default function NutritionFoodMap({ onChanged, onMapped, onManageLists, o
</div> </div>
</div> </div>
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}> <div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
{m.catalog_kind === 'manual_user' && m.food_id && ( {m.food_id && (m.catalog_kind === 'manual_user' || (isAdmin && m.catalog_kind === 'manual_admin')) && (
<button type="button" className="btn btn-secondary" onClick={() => setEditFoodId(m.food_id)}>Bearbeiten</button> <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> <button type="button" className="btn btn-secondary" onClick={() => remove(m.id)}>Löschen</button>

View File

@ -2,6 +2,25 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { api } from '../utils/api' import { api } from '../utils/api'
import FoodEditDialog from '../components/FoodEditDialog' import FoodEditDialog from '../components/FoodEditDialog'
function FoodRow({ row, onEdit }) {
return (
<div style={{ display: 'flex', gap: 8, marginTop: 6 }}>
<button
type="button"
className="btn btn-secondary"
style={{ flex: 1, textAlign: 'left' }}
onClick={() => onEdit(row)}
>
{row.bls_code ? `${row.bls_code} · ` : ''}{row.name_de}
{row.catalog_kind === 'manual_admin' ? ' · zentral (alle)' : row.catalog_kind === 'manual_user' ? ' · persönlich' : ''}
</button>
{row.catalog_kind && row.catalog_kind !== 'official_bls' && (
<button type="button" className="btn btn-primary" onClick={() => onEdit(row)}>Bearbeiten</button>
)}
</div>
)
}
export default function AdminBlsFoodsPage() { export default function AdminBlsFoodsPage() {
const [tab, setTab] = useState('search') const [tab, setTab] = useState('search')
const [q, setQ] = useState('') const [q, setQ] = useState('')
@ -49,6 +68,14 @@ export default function AdminBlsFoodsPage() {
} }
const shownManual = useMemo(() => manualRows, [manualRows]) const shownManual = useMemo(() => manualRows, [manualRows])
const centralRows = useMemo(
() => shownManual.filter((r) => r.catalog_kind === 'manual_admin'),
[shownManual],
)
const personalRows = useMemo(
() => shownManual.filter((r) => r.catalog_kind === 'manual_user'),
[shownManual],
)
const openDetail = (id) => { const openDetail = (id) => {
api.adminBlsFoodDetail(id).then(setDetail).catch((e) => setError(e.message)) api.adminBlsFoodDetail(id).then(setDetail).catch((e) => setError(e.message))
@ -100,7 +127,7 @@ export default function AdminBlsFoodsPage() {
{tab === 'manual' && ( {tab === 'manual' && (
<> <>
<p style={{ fontSize: 13, color: 'var(--text2)', margin: '0 0 10px' }}> <p style={{ fontSize: 13, color: 'var(--text2)', margin: '0 0 10px' }}>
Zentral (für alle Nutzer) und persönlich (Nutzer-eigene). Zeile oder Bearbeiten öffnet den Editor. Zwei Listen: zentral für alle Nutzer, darunter persönliche Einträge (sichtbar, weil du Admin bist). Beide sind hier änderbar.
</p> </p>
<div style={{ marginBottom: 10 }}> <div style={{ marginBottom: 10 }}>
<button type="button" className="btn btn-primary" onClick={() => setCreating(true)}>Zentral anlegen</button> <button type="button" className="btn btn-primary" onClick={() => setCreating(true)}>Zentral anlegen</button>
@ -113,7 +140,9 @@ export default function AdminBlsFoodsPage() {
placeholder="In manuellen Einträgen suchen…" placeholder="In manuellen Einträgen suchen…"
/> />
<p style={{ fontSize: 13, margin: '0 0 8px' }}> <p style={{ fontSize: 13, margin: '0 0 8px' }}>
{loadingManual ? 'Lade…' : `${shownManual.length} Einträge`} {loadingManual
? 'Lade…'
: `${centralRows.length} zentral, ${personalRows.length} persönlich`}
{!loadingManual && shownManual.length >= 200 ? ' (Anzeige begrenzt auf 200)' : ''} {!loadingManual && shownManual.length >= 200 ? ' (Anzeige begrenzt auf 200)' : ''}
</p> </p>
</> </>
@ -126,22 +155,23 @@ export default function AdminBlsFoodsPage() {
<p className="muted">Keine manuellen Lebensmittel{manualQ.trim() ? ` zu „${manualQ.trim()}“` : ''}.</p> <p className="muted">Keine manuellen Lebensmittel{manualQ.trim() ? ` zu „${manualQ.trim()}“` : ''}.</p>
)} )}
{list.map((r) => ( {tab === 'search' && list.map((r) => (
<div key={r.id} style={{ display: 'flex', gap: 8, marginTop: 6 }}> <FoodRow key={r.id} row={r} onEdit={openEdit} />
<button
type="button"
className="btn btn-secondary"
style={{ flex: 1, textAlign: 'left' }}
onClick={() => openEdit(r)}
>
{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 && r.catalog_kind !== 'official_bls' && (
<button type="button" className="btn btn-primary" onClick={() => openEdit(r)}>Bearbeiten</button>
)}
</div>
))} ))}
{tab === 'manual' && !loadingManual && shownManual.length > 0 && (
<>
<h3 style={{ fontSize: 14, margin: '16px 0 8px' }}>Zentral für alle ({centralRows.length})</h3>
{centralRows.length === 0 && <p className="muted">Noch keine zentralen Einträge.</p>}
{centralRows.map((r) => (
<FoodRow key={r.id} row={r} onEdit={openEdit} />
))}
<h3 style={{ fontSize: 14, margin: '20px 0 8px' }}>Persönlich ({personalRows.length})</h3>
{personalRows.length === 0 && <p className="muted">Keine persönlichen Einträge in dieser Auswahl.</p>}
{personalRows.map((r) => (
<FoodRow key={r.id} row={r} onEdit={openEdit} />
))}
</>
)}
{detail && ( {detail && (
<div style={{ marginTop: 16 }}> <div style={{ marginTop: 16 }}>
<h3>{detail.bls_code ? `${detail.bls_code} · ` : ''}{detail.name_de}</h3> <h3>{detail.bls_code ? `${detail.bls_code} · ` : ''}{detail.name_de}</h3>