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.
**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.

View File

@ -29,9 +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).
- `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.
- `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
- `/api/admin/bls/*` — Import, Katalog, Attribute
- `/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-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:** 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}`.
- **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

@ -62,7 +62,7 @@ def is_visible_to(food: dict[str, Any] | None, profile_id: str | None) -> bool:
owner = food.get("owner_profile_id")
if owner is None:
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:
@ -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)
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]:
row["visibility"] = visibility_of(row)
return row

View File

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

View File

@ -1,5 +1,6 @@
from data_layer.food_visibility import (
attach_visibility,
can_edit_food,
is_global_mapping_target,
is_visible_to,
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():
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
"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.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",
"insights": "1.3.0",

View File

@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'
import { api } from '../utils/api'
import { useAuth } from '../context/AuthContext'
import FoodNutrientFields, { foodCreatePayload } from './FoodNutrientFields'
function numStr(v) {
@ -9,17 +10,25 @@ function numStr(v) {
}
export default function FoodEditDialog({ foodId, onClose, onSaved, zIndex, mode }) {
const { isAdmin: sessionAdmin } = useAuth() || {}
const isCreate = !foodId
const isAdmin = mode === 'admin'
const createAsAdmin = 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 [catalogKind, setCatalogKind] = useState(null)
const [editable, setEditable] = useState(false)
const [loading, setLoading] = useState(!isCreate)
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
const saveAsAdmin = isCreate
? createAsAdmin
: Boolean(sessionAdmin && catalogKind && catalogKind !== 'official_bls' && (
catalogKind === 'manual_admin' || mode === 'admin'
))
useEffect(() => {
if (isCreate) {
setEditable(true)
@ -27,10 +36,13 @@ export default function FoodEditDialog({ foodId, onClose, onSaved, zIndex, mode
return
}
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) => {
if (!alive) return
setName(d.name_de || '')
setCatalogKind(d.catalog_kind || null)
const m = d.macros_per_100g || {}
setMacros({
kcal: numStr(m.kcal),
@ -38,13 +50,15 @@ export default function FoodEditDialog({ foodId, onClose, onSaved, zIndex, mode
fat_g: numStr(m.fat_g),
carbs_g: numStr(m.carbs_g),
})
setExtras((d.attributes || []).map((row) => ({
setExtras((d.attributes || []).filter((row) => row?.attr).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))
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)
setLoading(false)
}).catch((e) => {
@ -53,7 +67,7 @@ export default function FoodEditDialog({ foodId, onClose, onSaved, zIndex, mode
setLoading(false)
})
return () => { alive = false }
}, [foodId, isCreate, isAdmin])
}, [foodId, isCreate, sessionAdmin, mode])
const save = async () => {
if (!name.trim()) {
@ -65,7 +79,7 @@ export default function FoodEditDialog({ foodId, onClose, onSaved, zIndex, mode
try {
const body = foodCreatePayload(name, macros, extras, servingG)
let saved
if (isAdmin) {
if (saveAsAdmin) {
saved = isCreate ? await api.adminCreateManualFood(body) : await api.adminUpdateManualFood(foodId, body)
} else {
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 (
<div
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)' }}>
<h2 className="card-title" style={{ margin: 0, fontSize: 16 }}>
{isCreate
? (isAdmin ? 'Zentrales Lebensmittel anlegen' : 'Lebensmittel anlegen')
: (isAdmin ? 'Zentrales Lebensmittel bearbeiten' : 'Lebensmittel bearbeiten')}
</h2>
<h2 className="card-title" style={{ margin: 0, fontSize: 16 }}>{title}</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 }}>
@ -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>}
{!loading && !editable && (
<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>
)}
{!loading && editable && (

View File

@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react'
import { api } from '../utils/api'
import { useAuth } from '../context/AuthContext'
import FoodSearchModal from './FoodSearchModal'
import FoodEditDialog from './FoodEditDialog'
import { foodVisibilityLabel } from '../utils/foodVisibility'
@ -113,6 +114,7 @@ export default function FoodRecipeDialog({
onSave,
onClose,
}) {
const { isAdmin } = useAuth()
const [name, setName] = useState(defaultName || '')
const [portions, setPortions] = useState(defaultPortions != null ? String(defaultPortions) : '1')
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>
</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)}>
Bearbeiten
</button>

View File

@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react'
import { api } from '../utils/api'
import { useAuth } from '../context/AuthContext'
import FoodNutrientFields, { foodCreatePayload } from './FoodNutrientFields'
import FoodEditDialog from './FoodEditDialog'
import { foodVisibilitySuffix } from '../utils/foodVisibility'
@ -28,6 +29,7 @@ function detectUnit(...texts) {
}
export default function FoodSearchModal({ title, initialQuery, quantityHint, allowRecipe, onSelect, onWantRecipe, onClose, zIndex }) {
const { isAdmin } = useAuth()
const [q, setQ] = useState(initialQuery || '')
const [hits, setHits] = useState([])
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>
)}
{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 (
<div key={h.id} style={{ display: 'flex', gap: 8, marginTop: 8, alignItems: 'stretch' }}>
<button

View File

@ -56,7 +56,8 @@ export default function MyFoodsPanel() {
<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.
<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.
</p>
{error && <div style={{ color: 'var(--danger)', fontSize: 13, marginBottom: 10 }}>{error}</div>}
@ -109,10 +110,21 @@ export default function MyFoodsPanel() {
)}
{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>
<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>
{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 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>
)}
{isAdmin && (
<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 { api } from '../utils/api'
import { useAuth } from '../context/AuthContext'
import FoodSearchModal from './FoodSearchModal'
import FoodNutrientFields, { foodCreatePayload } from './FoodNutrientFields'
import FoodRecipeDialog from './FoodRecipeDialog'
@ -130,6 +131,7 @@ function InlineCreate({ defaultName, disabled, allowRecipe, onCreateFood, onWant
}
export default function NutritionFoodMap({ onChanged, onMapped, onManageLists, onCreatedList, onOpenMyFoods }) {
const { isAdmin } = useAuth()
const [unmapped, setUnmapped] = useState([])
const [learned, setLearned] = useState([])
const [recipes, setRecipes] = useState([])
@ -525,7 +527,7 @@ export default function NutritionFoodMap({ onChanged, onMapped, onManageLists, o
</div>
</div>
<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={() => 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 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() {
const [tab, setTab] = useState('search')
const [q, setQ] = useState('')
@ -49,6 +68,14 @@ export default function AdminBlsFoodsPage() {
}
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) => {
api.adminBlsFoodDetail(id).then(setDetail).catch((e) => setError(e.message))
@ -100,7 +127,7 @@ export default function AdminBlsFoodsPage() {
{tab === 'manual' && (
<>
<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>
<div style={{ marginBottom: 10 }}>
<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…"
/>
<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)' : ''}
</p>
</>
@ -126,22 +155,23 @@ export default function AdminBlsFoodsPage() {
<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={() => 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 === 'search' && list.map((r) => (
<FoodRow key={r.id} row={r} onEdit={openEdit} />
))}
{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 && (
<div style={{ marginTop: 16 }}>
<h3>{detail.bls_code ? `${detail.bls_code} · ` : ''}{detail.name_de}</h3>