feat: Stoffe nach BLS-Gruppen sortieren und aufklappbar listen
Energie und Makros zuerst, restliche Naehrstoffgruppen zuklappbar, damit die Liste bedienbar bleibt. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
94ddf84262
commit
44e3e216ab
|
|
@ -33,7 +33,7 @@ FDDB: Items persistieren; `nutrition_log` nur bei leerem Tag oder laut Policy /
|
||||||
- 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 /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).
|
- `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. `PUT /admin/bls/attributes/{id}` ändert Name/Einheit (nicht `attr_key`). `POST /admin/bls/attributes/fill-labels` füllt leere Namen aus BLS-4.0-Glossar (`bls/attribute_labels.py`). Lebensmittel-Import schreibt fehlende Namen aus Spaltenköpfen. Makro-Rollup: `catalog_macros_for_item` liest nur ENERCC/PROT625/FAT/CHO → `nutrition_log`; `rebuild_daily_nutrients` summiert alle `num_per_100g` nach `nutrition_daily_nutrients`.
|
- `/api/admin/bls/*` — Import, Katalog, Attribute. `GET /admin/bls/attributes` liefert `group_key`/`group_label` (BLS-Gruppen, sortiert). `PUT /admin/bls/attributes/{id}` ändert Name/Einheit/Gruppe (nicht `attr_key`). `POST /admin/bls/attributes/fill-labels` füllt leere Namen und fehlende Gruppen aus BLS-4.0-Glossar (`bls/attribute_labels.py`). Lebensmittel-Import schreibt fehlende Namen aus Spaltenköpfen. Makro-Rollup: `catalog_macros_for_item` liest nur ENERCC/PROT625/FAT/CHO → `nutrition_log`; `rebuild_daily_nutrients` summiert alle `num_per_100g` nach `nutrition_daily_nutrients`.
|
||||||
- `/api/admin/food-mappings` — Admin-CRUD
|
- `/api/admin/food-mappings` — Admin-CRUD
|
||||||
- `/api/nutrition/*` — Items, Unmapped, Bulk-Map, Marken, Konflikt-Resolve
|
- `/api/nutrition/*` — Items, Unmapped, Bulk-Map, Marken, Konflikt-Resolve
|
||||||
- `GET/POST /api/nutrition/recipes`, `PUT/DELETE /api/nutrition/recipes/{id}`, `POST …/import-fddb-lists`, `POST …/{id}/apply`
|
- `GET/POST /api/nutrition/recipes`, `PUT/DELETE /api/nutrition/recipes/{id}`, `POST …/import-fddb-lists`, `POST …/{id}/apply`
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,24 @@ from typing import Any
|
||||||
# Keys that feed nutrition_log kcal / protein_g / fat_g / carbs_g
|
# Keys that feed nutrition_log kcal / protein_g / fat_g / carbs_g
|
||||||
MACRO_KEYS = ("ENERCC", "PROT625", "FAT", "CHO")
|
MACRO_KEYS = ("ENERCC", "PROT625", "FAT", "CHO")
|
||||||
|
|
||||||
|
GROUP_META = (
|
||||||
|
("energy", "Energie"),
|
||||||
|
("macro", "Makronährstoffe"),
|
||||||
|
("carbs", "Kohlenhydrate"),
|
||||||
|
("fiber", "Ballaststoffe"),
|
||||||
|
("fatty_acids", "Fettsäuren"),
|
||||||
|
("amino_acids", "Aminosäuren"),
|
||||||
|
("vitamins", "Vitamine"),
|
||||||
|
("minerals", "Mineralstoffe"),
|
||||||
|
("organic_acids", "Organische Säuren"),
|
||||||
|
("polyols", "Zuckeralkohole"),
|
||||||
|
("other", "Sonstige"),
|
||||||
|
("extension", "Eigene Erweiterungen"),
|
||||||
|
("unknown", "Ohne Gruppe"),
|
||||||
|
)
|
||||||
|
GROUP_LABELS = {key: label for key, label in GROUP_META}
|
||||||
|
GROUP_RANK = {key: i for i, (key, _) in enumerate(GROUP_META)}
|
||||||
|
|
||||||
# Code → (name_de, unit, category) from BLS 4.0 Referenzhandbuch (Jan 2026)
|
# Code → (name_de, unit, category) from BLS 4.0 Referenzhandbuch (Jan 2026)
|
||||||
_OFFICIAL: dict[str, tuple[str, str, str]] = {
|
_OFFICIAL: dict[str, tuple[str, str, str]] = {
|
||||||
"ENERCJ": ("Energie (Kilojoule)", "kJ", "energy"),
|
"ENERCJ": ("Energie (Kilojoule)", "kJ", "energy"),
|
||||||
|
|
@ -162,6 +180,42 @@ def lookup_key(raw: str | None) -> str:
|
||||||
return str(raw or "").strip().upper()
|
return str(raw or "").strip().upper()
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_group(attr_key: str | None, category: str | None = None, origin: str | None = None) -> tuple[str, str]:
|
||||||
|
cat = (category or "").strip().lower()
|
||||||
|
if cat in GROUP_LABELS:
|
||||||
|
return cat, GROUP_LABELS[cat]
|
||||||
|
lab = official_label(attr_key)
|
||||||
|
if lab and lab["category"] in GROUP_LABELS:
|
||||||
|
return lab["category"], GROUP_LABELS[lab["category"]]
|
||||||
|
if (origin or "") == "extension":
|
||||||
|
return "extension", GROUP_LABELS["extension"]
|
||||||
|
return "unknown", GROUP_LABELS["unknown"]
|
||||||
|
|
||||||
|
|
||||||
|
def annotate_attribute(row: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
key = row.get("attr_key")
|
||||||
|
group_key, group_label = resolve_group(key, row.get("category"), row.get("origin"))
|
||||||
|
row["is_macro"] = key in MACRO_KEYS
|
||||||
|
row["needs_label"] = label_is_placeholder(row.get("name_de"), key)
|
||||||
|
row["group_key"] = group_key
|
||||||
|
row["group_label"] = group_label
|
||||||
|
row["group_rank"] = GROUP_RANK[group_key]
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def sort_attributes(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
def key_of(row: dict[str, Any]):
|
||||||
|
return (
|
||||||
|
row.get("group_rank", 99),
|
||||||
|
0 if row.get("is_macro") else 1,
|
||||||
|
row.get("sort_order") if row.get("sort_order") is not None else 0,
|
||||||
|
(row.get("name_de") or row.get("attr_key") or "").casefold(),
|
||||||
|
row.get("attr_key") or "",
|
||||||
|
)
|
||||||
|
|
||||||
|
return sorted(rows, key=key_of)
|
||||||
|
|
||||||
|
|
||||||
def official_label(attr_key: str | None) -> dict[str, str] | None:
|
def official_label(attr_key: str | None) -> dict[str, str] | None:
|
||||||
key = lookup_key(attr_key)
|
key = lookup_key(attr_key)
|
||||||
hit = _OFFICIAL.get(key)
|
hit = _OFFICIAL.get(key)
|
||||||
|
|
@ -211,7 +265,11 @@ def fill_placeholder_labels(cur) -> dict[str, int]:
|
||||||
for row in cur.fetchall():
|
for row in cur.fetchall():
|
||||||
rec = dict(row)
|
rec = dict(row)
|
||||||
lab = official_label(rec.get("attr_key"))
|
lab = official_label(rec.get("attr_key"))
|
||||||
if not lab or not label_is_placeholder(rec.get("name_de"), rec.get("attr_key")):
|
if not lab:
|
||||||
|
continue
|
||||||
|
need_name = label_is_placeholder(rec.get("name_de"), rec.get("attr_key"))
|
||||||
|
need_cat = not (rec.get("category") or "").strip()
|
||||||
|
if not need_name and not need_cat:
|
||||||
continue
|
continue
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
|
|
@ -222,7 +280,12 @@ def fill_placeholder_labels(cur) -> dict[str, int]:
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE id = %s
|
WHERE id = %s
|
||||||
""",
|
""",
|
||||||
(lab["name_de"], lab["unit"], lab["category"], rec["id"]),
|
(
|
||||||
|
lab["name_de"] if need_name else rec.get("name_de"),
|
||||||
|
lab["unit"],
|
||||||
|
lab["category"],
|
||||||
|
rec["id"],
|
||||||
|
),
|
||||||
)
|
)
|
||||||
updated += 1
|
updated += 1
|
||||||
return {"updated": updated}
|
return {"updated": updated}
|
||||||
|
|
|
||||||
|
|
@ -300,7 +300,7 @@ def admin_update_manual_food(food_id: str, body: ManualFoodCreate, session: dict
|
||||||
|
|
||||||
@router.get("/attributes")
|
@router.get("/attributes")
|
||||||
def admin_list_attributes(session: dict = Depends(require_admin)):
|
def admin_list_attributes(session: dict = Depends(require_admin)):
|
||||||
from bls.attribute_labels import MACRO_KEYS, label_is_placeholder
|
from bls.attribute_labels import annotate_attribute, sort_attributes
|
||||||
|
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
|
|
@ -309,17 +309,9 @@ def admin_list_attributes(session: dict = Depends(require_admin)):
|
||||||
SELECT id, attr_key, name_de, name_en, unit, category, data_type, origin, sort_order
|
SELECT id, attr_key, name_de, name_en, unit, category, data_type, origin, sort_order
|
||||||
FROM food_attributes
|
FROM food_attributes
|
||||||
WHERE is_active = true
|
WHERE is_active = true
|
||||||
ORDER BY origin, sort_order, attr_key
|
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
rows = []
|
return sort_attributes([annotate_attribute(r2d(r)) for r in cur.fetchall()])
|
||||||
for r in cur.fetchall():
|
|
||||||
row = r2d(r)
|
|
||||||
key = row.get("attr_key")
|
|
||||||
row["is_macro"] = key in MACRO_KEYS
|
|
||||||
row["needs_label"] = label_is_placeholder(row.get("name_de"), key)
|
|
||||||
rows.append(row)
|
|
||||||
return rows
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/attributes/fill-labels")
|
@router.post("/attributes/fill-labels")
|
||||||
|
|
@ -398,11 +390,8 @@ def admin_update_attribute(attr_id: int, body: AttributeUpdate, session: dict =
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(404, "Stoff nicht gefunden")
|
raise HTTPException(404, "Stoff nicht gefunden")
|
||||||
from bls.attribute_labels import MACRO_KEYS, label_is_placeholder
|
from bls.attribute_labels import annotate_attribute
|
||||||
out = r2d(row)
|
return annotate_attribute(r2d(row))
|
||||||
out["is_macro"] = out.get("attr_key") in MACRO_KEYS
|
|
||||||
out["needs_label"] = label_is_placeholder(out.get("name_de"), out.get("attr_key"))
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _write_manual_macros(cur, food_id: str, macros: dict | None) -> None:
|
def _write_manual_macros(cur, food_id: str, macros: dict | None) -> None:
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
from bls.attribute_labels import (
|
from bls.attribute_labels import (
|
||||||
|
annotate_attribute,
|
||||||
apply_official_name,
|
apply_official_name,
|
||||||
fill_placeholder_labels,
|
fill_placeholder_labels,
|
||||||
label_is_placeholder,
|
label_is_placeholder,
|
||||||
official_label,
|
official_label,
|
||||||
|
resolve_group,
|
||||||
|
sort_attributes,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -35,6 +38,20 @@ def test_fill_placeholder_labels_skips_custom_names():
|
||||||
|
|
||||||
cur = _Cur()
|
cur = _Cur()
|
||||||
stats = fill_placeholder_labels(cur)
|
stats = fill_placeholder_labels(cur)
|
||||||
assert stats["updated"] == 1
|
assert stats["updated"] == 2
|
||||||
assert cur.updated[0][0] == "Fett"
|
assert cur.updated[0][0] == "Fett"
|
||||||
assert cur.updated[0][3] == 1
|
assert cur.updated[0][3] == 1
|
||||||
|
assert cur.updated[1][0] == "Mein EPA"
|
||||||
|
assert cur.updated[1][2] == "fatty_acids"
|
||||||
|
|
||||||
|
|
||||||
|
def test_group_and_sort_macros_first():
|
||||||
|
assert resolve_group("ENERCC")[0] == "energy"
|
||||||
|
assert resolve_group("NA", None, None)[1] == "Mineralstoffe"
|
||||||
|
assert resolve_group("XYZ", None, "extension")[0] == "extension"
|
||||||
|
rows = sort_attributes([
|
||||||
|
annotate_attribute({"attr_key": "NA", "name_de": "Natrium", "sort_order": 2, "origin": "official_bls"}),
|
||||||
|
annotate_attribute({"attr_key": "FAT", "name_de": "Fett", "sort_order": 9, "origin": "official_bls"}),
|
||||||
|
annotate_attribute({"attr_key": "ENERCC", "name_de": "Energie", "sort_order": 1, "origin": "official_bls"}),
|
||||||
|
])
|
||||||
|
assert [r["attr_key"] for r in rows] == ["ENERCC", "FAT", "NA"]
|
||||||
|
|
|
||||||
|
|
@ -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.12", # Stoff-Bezeichnungen editierbar; BLS-Glossar; Makro-Pfad dokumentiert
|
"bls": "1.0.13", # Stoffe gruppiert und sortiert (BLS-Nährstoffgruppen)
|
||||||
|
|
||||||
"photos": "1.0.0",
|
"photos": "1.0.0",
|
||||||
"insights": "1.3.0",
|
"insights": "1.3.0",
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,55 @@
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { api } from '../utils/api'
|
import { api } from '../utils/api'
|
||||||
|
|
||||||
const FILTERS = [
|
const GROUP_OPTIONS = [
|
||||||
{ id: 'all', label: 'Alle' },
|
{ id: 'energy', label: 'Energie' },
|
||||||
{ id: 'macro', label: 'Makros' },
|
{ id: 'macro', label: 'Makronährstoffe' },
|
||||||
{ id: 'needs', label: 'Ohne Bezeichnung' },
|
{ id: 'carbs', label: 'Kohlenhydrate' },
|
||||||
{ id: 'official', label: 'BLS' },
|
{ id: 'fiber', label: 'Ballaststoffe' },
|
||||||
{ id: 'extension', label: 'Erweiterung' },
|
{ id: 'fatty_acids', label: 'Fettsäuren' },
|
||||||
|
{ id: 'amino_acids', label: 'Aminosäuren' },
|
||||||
|
{ id: 'vitamins', label: 'Vitamine' },
|
||||||
|
{ id: 'minerals', label: 'Mineralstoffe' },
|
||||||
|
{ id: 'organic_acids', label: 'Organische Säuren' },
|
||||||
|
{ id: 'polyols', label: 'Zuckeralkohole' },
|
||||||
|
{ id: 'other', label: 'Sonstige' },
|
||||||
|
{ id: 'extension', label: 'Eigene Erweiterungen' },
|
||||||
|
{ id: 'unknown', label: 'Ohne Gruppe' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const DEFAULT_OPEN = new Set(['energy', 'macro'])
|
||||||
|
|
||||||
function matches(row, term, filter) {
|
function matches(row, term, filter) {
|
||||||
if (filter === 'macro' && !row.is_macro) return false
|
|
||||||
if (filter === 'needs' && !row.needs_label) return false
|
if (filter === 'needs' && !row.needs_label) return false
|
||||||
if (filter === 'official' && row.origin !== 'official_bls') return false
|
if (filter !== 'all' && filter !== 'needs' && row.group_key !== filter) return false
|
||||||
if (filter === 'extension' && row.origin === 'official_bls') return false
|
|
||||||
if (!term) return true
|
if (!term) return true
|
||||||
const hay = `${row.attr_key} ${row.name_de || ''} ${row.name_en || ''} ${row.unit || ''}`.toLowerCase()
|
const hay = `${row.attr_key} ${row.name_de || ''} ${row.name_en || ''} ${row.unit || ''} ${row.group_label || ''}`.toLowerCase()
|
||||||
return hay.includes(term)
|
return hay.includes(term)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function groupRows(rows) {
|
||||||
|
const byKey = new Map()
|
||||||
|
for (const row of rows) {
|
||||||
|
const key = row.group_key || 'unknown'
|
||||||
|
if (!byKey.has(key)) {
|
||||||
|
byKey.set(key, {
|
||||||
|
key,
|
||||||
|
label: row.group_label || 'Ohne Gruppe',
|
||||||
|
rank: row.group_rank ?? 99,
|
||||||
|
items: [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
byKey.get(key).items.push(row)
|
||||||
|
}
|
||||||
|
return [...byKey.values()].sort((a, b) => a.rank - b.rank)
|
||||||
|
}
|
||||||
|
|
||||||
export default function AdminFoodAttributesPage() {
|
export default function AdminFoodAttributesPage() {
|
||||||
const [rows, setRows] = useState([])
|
const [rows, setRows] = useState([])
|
||||||
const [form, setForm] = useState({ attr_key: '', name_de: '', unit: '', data_type: 'num_per_100g' })
|
const [form, setForm] = useState({ attr_key: '', name_de: '', unit: '', data_type: 'num_per_100g' })
|
||||||
const [q, setQ] = useState('')
|
const [q, setQ] = useState('')
|
||||||
const [filter, setFilter] = useState('all')
|
const [filter, setFilter] = useState('all')
|
||||||
|
const [open, setOpen] = useState(() => new Set(DEFAULT_OPEN))
|
||||||
const [editId, setEditId] = useState(null)
|
const [editId, setEditId] = useState(null)
|
||||||
const [draft, setDraft] = useState({ name_de: '', unit: '', category: '' })
|
const [draft, setDraft] = useState({ name_de: '', unit: '', category: '' })
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
|
|
@ -35,13 +61,37 @@ export default function AdminFoodAttributesPage() {
|
||||||
|
|
||||||
const term = q.trim().toLowerCase()
|
const term = q.trim().toLowerCase()
|
||||||
const shown = useMemo(() => rows.filter((r) => matches(r, term, filter)), [rows, term, filter])
|
const shown = useMemo(() => rows.filter((r) => matches(r, term, filter)), [rows, term, filter])
|
||||||
|
const groups = useMemo(() => groupRows(shown), [shown])
|
||||||
const missing = rows.filter((r) => r.needs_label).length
|
const missing = rows.filter((r) => r.needs_label).length
|
||||||
const macros = rows.filter((r) => r.is_macro)
|
const groupCounts = useMemo(() => {
|
||||||
|
const counts = {}
|
||||||
|
for (const r of rows) {
|
||||||
|
const key = r.group_key || 'unknown'
|
||||||
|
counts[key] = (counts[key] || 0) + 1
|
||||||
|
}
|
||||||
|
return counts
|
||||||
|
}, [rows])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (term || filter !== 'all') {
|
||||||
|
setOpen(new Set(groups.map((g) => g.key)))
|
||||||
|
}
|
||||||
|
}, [term, filter, groups])
|
||||||
|
|
||||||
|
const toggle = (key) => {
|
||||||
|
setOpen((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(key)) next.delete(key)
|
||||||
|
else next.add(key)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const startEdit = (r) => {
|
const startEdit = (r) => {
|
||||||
setEditId(r.id)
|
setEditId(r.id)
|
||||||
setDraft({ name_de: r.name_de || '', unit: r.unit || '', category: r.category || '' })
|
setDraft({ name_de: r.name_de || '', unit: r.unit || '', category: r.group_key || r.category || '' })
|
||||||
setError(null)
|
setError(null)
|
||||||
|
setOpen((prev) => new Set(prev).add(r.group_key || 'unknown'))
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveEdit = async (id) => {
|
const saveEdit = async (id) => {
|
||||||
|
|
@ -64,7 +114,7 @@ export default function AdminFoodAttributesPage() {
|
||||||
try {
|
try {
|
||||||
const res = await api.adminFillBlsAttributeLabels()
|
const res = await api.adminFillBlsAttributeLabels()
|
||||||
await load()
|
await load()
|
||||||
setNotice(`${res.updated ?? 0} Bezeichnungen aus dem BLS-4.0-Glossar ergänzt.`)
|
setNotice(`${res.updated ?? 0} Bezeichnungen/Gruppen aus dem BLS-4.0-Glossar ergänzt.`)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e.message)
|
setError(e.message)
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -76,26 +126,9 @@ export default function AdminFoodAttributesPage() {
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h1 className="page-title">Stoffe & Attribute</h1>
|
<h1 className="page-title">Stoffe & Attribute</h1>
|
||||||
<p style={{ fontSize: 13, color: 'var(--text2)', lineHeight: 1.55, margin: '0 0 12px' }}>
|
<p style={{ fontSize: 13, color: 'var(--text2)', lineHeight: 1.55, margin: '0 0 12px' }}>
|
||||||
Jeder Stoff hat einen festen <strong>Schlüssel</strong> (BLS/EuroFIR, z. B. ENERCC).
|
Gruppiert nach BLS-Nährstoffgruppen. Innerhalb der Gruppe: zuerst die vier Makros für die Haupttabelle, dann Name.
|
||||||
Die deutsche Bezeichnung ist nur Anzeige — sie darf hier geändert werden, der Schlüssel nicht.
|
Schlüssel bleibt fest, Bezeichnung und Gruppe sind änderbar.
|
||||||
</p>
|
</p>
|
||||||
<p style={{ fontSize: 13, color: 'var(--text2)', lineHeight: 1.55, margin: '0 0 12px' }}>
|
|
||||||
<strong>Weg in die Makro-Haupttabelle:</strong> Am Lebensmittel liegen Werte je 100 g in
|
|
||||||
{' '}<code>food_attribute_values</code>. Beim Gegessen-Eintrag zählt die Menge in Gramm.
|
|
||||||
Nur vier Schlüssel werden nach <code>nutrition_log</code> geschrieben:
|
|
||||||
Energie <code>ENERCC</code> → kcal, Protein <code>PROT625</code> → protein_g,
|
|
||||||
Fett <code>FAT</code> → fat_g, Kohlenhydrate <code>CHO</code> → carbs_g
|
|
||||||
(Wert × Menge / 100). Alle anderen Stoffe (EPA, Ballaststoffe, Vitamine, …) landen in
|
|
||||||
{' '}<code>nutrition_daily_nutrients</code>, nicht in den vier Makro-Spalten.
|
|
||||||
Fehlt einer der vier Werte am zugeordneten Lebensmittel, gilt die Zeile als ungemappt
|
|
||||||
und es bleiben die FDDB-Makros des Tages.
|
|
||||||
</p>
|
|
||||||
{macros.length > 0 && (
|
|
||||||
<p style={{ fontSize: 13, margin: '0 0 12px' }}>
|
|
||||||
Makros in dieser Liste:{' '}
|
|
||||||
{macros.map((m) => `${m.attr_key} (${m.name_de || 'ohne Name'})`).join(' · ')}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{error && <p style={{ color: 'var(--danger)' }}>{error}</p>}
|
{error && <p style={{ color: 'var(--danger)' }}>{error}</p>}
|
||||||
{notice && <p style={{ color: 'var(--accent-dark)', fontSize: 13 }}>{notice}</p>}
|
{notice && <p style={{ color: 'var(--accent-dark)', fontSize: 13 }}>{notice}</p>}
|
||||||
|
|
||||||
|
|
@ -126,65 +159,98 @@ export default function AdminFoodAttributesPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 10 }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 10 }}>
|
||||||
{FILTERS.map((f) => (
|
<button type="button" className={'btn ' + (filter === 'all' ? 'btn-primary' : 'btn-secondary')} style={{ fontSize: 12 }} onClick={() => { setFilter('all'); setOpen(new Set(DEFAULT_OPEN)) }}>
|
||||||
|
Alle ({rows.length})
|
||||||
|
</button>
|
||||||
|
{GROUP_OPTIONS.filter((g) => groupCounts[g.id]).map((g) => (
|
||||||
<button
|
<button
|
||||||
key={f.id}
|
key={g.id}
|
||||||
type="button"
|
type="button"
|
||||||
className={'btn ' + (filter === f.id ? 'btn-primary' : 'btn-secondary')}
|
className={'btn ' + (filter === g.id ? 'btn-primary' : 'btn-secondary')}
|
||||||
style={{ fontSize: 12 }}
|
style={{ fontSize: 12 }}
|
||||||
onClick={() => setFilter(f.id)}
|
onClick={() => setFilter(g.id)}
|
||||||
>
|
>
|
||||||
{f.label}{f.id === 'needs' && missing ? ` (${missing})` : ''}
|
{g.label} ({groupCounts[g.id]})
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<button type="button" className="btn btn-secondary" style={{ fontSize: 12 }} disabled={busy || missing === 0} onClick={fillLabels}>
|
<button type="button" className={'btn ' + (filter === 'needs' ? 'btn-primary' : 'btn-secondary')} style={{ fontSize: 12 }} onClick={() => setFilter('needs')}>
|
||||||
Leere Namen aus BLS-Glossar füllen
|
Ohne Bezeichnung{missing ? ` (${missing})` : ''}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-secondary" style={{ fontSize: 12 }} onClick={() => setOpen(new Set(groups.map((g) => g.key)))}>
|
||||||
|
Alle aufklappen
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-secondary" style={{ fontSize: 12 }} onClick={() => setOpen(new Set())}>
|
||||||
|
Alle zuklappen
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-secondary" style={{ fontSize: 12 }} disabled={busy} onClick={fillLabels}>
|
||||||
|
Namen/Gruppen aus Glossar füllen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
className="form-input"
|
className="form-input"
|
||||||
style={{ width: '100%', textAlign: 'left', marginBottom: 10 }}
|
style={{ width: '100%', textAlign: 'left', marginBottom: 10 }}
|
||||||
placeholder="Stoffe suchen (Name oder Schlüssel)…"
|
placeholder="In allen Gruppen suchen…"
|
||||||
value={q}
|
value={q}
|
||||||
onChange={(e) => setQ(e.target.value)}
|
onChange={(e) => setQ(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<p style={{ fontSize: 13, margin: '0 0 8px' }}>{shown.length} von {rows.length} Stoffen</p>
|
<p style={{ fontSize: 13, margin: '0 0 8px' }}>{shown.length} von {rows.length} Stoffen · {groups.length} Gruppen</p>
|
||||||
|
|
||||||
{shown.map((r) => (
|
{groups.map((g) => {
|
||||||
<div key={r.id} style={{ padding: '8px 0', borderTop: '1px solid var(--border)' }}>
|
const opened = open.has(g.key)
|
||||||
{editId === r.id ? (
|
return (
|
||||||
<div>
|
<section key={g.key} style={{ marginBottom: 8, border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden' }}>
|
||||||
<div style={{ fontSize: 12, color: 'var(--text3)', marginBottom: 6 }}>
|
<button
|
||||||
Schlüssel {r.attr_key} · {r.origin}{r.is_macro ? ' · Makro → nutrition_log' : ''}
|
type="button"
|
||||||
|
onClick={() => toggle(g.key)}
|
||||||
|
style={{
|
||||||
|
width: '100%', textAlign: 'left', display: 'flex', justifyContent: 'space-between',
|
||||||
|
gap: 8, padding: '10px 12px', background: 'var(--surface2)', border: 0,
|
||||||
|
cursor: 'pointer', fontFamily: 'inherit', color: 'inherit',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontWeight: 600, fontSize: 14 }}>{g.label}</span>
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text3)' }}>{g.items.length} · {opened ? 'zuklappen' : 'aufklappen'}</span>
|
||||||
|
</button>
|
||||||
|
{opened && g.items.map((r) => (
|
||||||
|
<div key={r.id} style={{ padding: '8px 12px', borderTop: '1px solid var(--border)' }}>
|
||||||
|
{editId === r.id ? (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text3)', marginBottom: 6 }}>
|
||||||
|
Schlüssel {r.attr_key} · {r.origin}{r.is_macro ? ' · Makro → nutrition_log' : ''}
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<input className="form-input" value={draft.name_de} onChange={(e) => setDraft({ ...draft, name_de: e.target.value })} placeholder="Name DE" />
|
||||||
|
<input className="form-input" value={draft.unit} onChange={(e) => setDraft({ ...draft, unit: e.target.value })} placeholder="Einheit" />
|
||||||
|
<select className="form-input" value={draft.category} onChange={(e) => setDraft({ ...draft, category: e.target.value })}>
|
||||||
|
{GROUP_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.id} value={opt.id}>{opt.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button type="button" className="btn btn-primary" disabled={busy} onClick={() => saveEdit(r.id)}>Speichern</button>
|
||||||
|
<button type="button" className="btn btn-secondary" disabled={busy} onClick={() => setEditId(null)}>Abbrechen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, alignItems: 'center' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 600 }}>
|
||||||
|
{r.needs_label ? r.attr_key : (r.name_de || r.attr_key)}
|
||||||
|
{r.is_macro ? <span style={{ marginLeft: 8, fontSize: 11, color: 'var(--accent-dark)' }}>Makro</span> : null}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text3)' }}>
|
||||||
|
{r.attr_key}
|
||||||
|
{r.needs_label ? ' · Bezeichnung fehlt' : ''}
|
||||||
|
{r.unit ? ` · ${r.unit}` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn btn-primary" style={{ fontSize: 12 }} onClick={() => startEdit(r)}>Bearbeiten</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="form-row">
|
))}
|
||||||
<input className="form-input" value={draft.name_de} onChange={(e) => setDraft({ ...draft, name_de: e.target.value })} placeholder="Name DE" />
|
</section>
|
||||||
<input className="form-input" value={draft.unit} onChange={(e) => setDraft({ ...draft, unit: e.target.value })} placeholder="Einheit" />
|
)
|
||||||
<input className="form-input" value={draft.category} onChange={(e) => setDraft({ ...draft, category: e.target.value })} placeholder="Gruppe" />
|
})}
|
||||||
<button type="button" className="btn btn-primary" disabled={busy} onClick={() => saveEdit(r.id)}>Speichern</button>
|
|
||||||
<button type="button" className="btn btn-secondary" disabled={busy} onClick={() => setEditId(null)}>Abbrechen</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, alignItems: 'center' }}>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontWeight: 600 }}>
|
|
||||||
{r.needs_label ? r.attr_key : (r.name_de || r.attr_key)}
|
|
||||||
{r.is_macro ? <span style={{ marginLeft: 8, fontSize: 11, color: 'var(--accent-dark)' }}>Makro</span> : null}
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--text3)' }}>
|
|
||||||
{r.attr_key}
|
|
||||||
{r.needs_label ? ' · Bezeichnung fehlt (nur Schlüssel)' : ''}
|
|
||||||
{r.unit ? ` · ${r.unit}` : ''}
|
|
||||||
{` · ${r.data_type}`}
|
|
||||||
{` · ${r.origin}`}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button type="button" className="btn btn-primary" style={{ fontSize: 12 }} onClick={() => startEdit(r)}>Bearbeiten</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user