fix: zentrale Lebensmittel fuer Admins aenderbar
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:
parent
9a0767c8ac
commit
e452d9a8fe
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user