feat: Eigene Stoffe am Lebensmittel anlegen
All checks were successful
Deploy Development / deploy (push) Successful in 1m8s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 22s

EPA-Chips und mg/µg vom Etikett; fehlende Parameter ohne Admin.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-09-13 15:47:49 +02:00
parent a5adff6196
commit d35087ad84
9 changed files with 246 additions and 29 deletions

View File

@ -37,6 +37,7 @@ Gelernte Zuordnungen und Listen lassen sich als **JSON sichern** und auf einer a
- BLS-Code (`bls_code`, Stoff-`attr_key`) bleibt die stabile Identität bei Reimports. - BLS-Code (`bls_code`, Stoff-`attr_key`) bleibt die stabile Identität bei Reimports.
- BLS 4.0 (~7140 Lebensmittel) ist frei nutzbar (MRI / blsdb.de); Dateien nicht im Git. - BLS 4.0 (~7140 Lebensmittel) ist frei nutzbar (MRI / blsdb.de); Dateien nicht im Git.
- Mapping ohne KI: Normalisierung + exakter Lookup (User vor Global) + Bestätigung neuer Namen. - Mapping ohne KI: Normalisierung + exakter Lookup (User vor Global) + Bestätigung neuer Namen.
- Öl/Supplement mit Etikett (EPA/DHA, Vitamine) ist **ein Katalogeintrag**, keine Zutatenliste. Listen sind nur Rohmischungen aus Lebensmitteln — eine Liste kann keine andere Liste enthalten. Quarkspeise = Liste, Omega-3-Öl = Lebensmittel darin.
- Gelernte Zuordnungen bleiben dauerhaft, sind aber änder- und löschbar. - Gelernte Zuordnungen bleiben dauerhaft, sind aber änder- und löschbar.
- Ungemappte / Fertiggerichte: FDDB-Makros, keine erfundenen Mikros. - Ungemappte / Fertiggerichte: FDDB-Makros, keine erfundenen Mikros.
- Fasten und „unvollständig“ sind explizite Marken, kein Auto-Schluss aus fehlendem Import. - Fasten und „unvollständig“ sind explizite Marken, kein Auto-Schluss aus fehlendem Import.

View File

@ -28,7 +28,7 @@ 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 - `/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`
- 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:** Stoffe über EAV (`GET /bls/attributes`, `attributes` + `serving_g` beim Anlegen). Supplemente wie Norsan: EPA/DHA aus Etikett, Portionsgramm → Speicherung /100 g. - **Manuelle Foods:** Stoffe über EAV (`GET /bls/attributes`, `attributes` + `serving_g` beim Anlegen). Supplemente wie Norsan: EPA/DHA aus Etikett, Portionsgramm → Speicherung /100 g. Nutzer kann fehlende Stoffe anlegen (`POST /bls/attributes`). Öl/Supplement = ein Lebensmittel, keine Liste (Listen nicht verschachtelbar).
- **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

@ -1,8 +1,12 @@
"""Catalog attribute definitions and numeric values (BLS EAV + extensions).""" """Catalog attribute definitions and numeric values (BLS EAV + extensions)."""
from __future__ import annotations from __future__ import annotations
import re
from typing import Any from typing import Any
_ATTR_KEY_RE = re.compile(r"[^A-Z0-9]+")
_ALLOWED_UNITS = {"g": "g", "mg": "mg", "µg": "µg", "ug": "µg", "mcg": "µg", "kcal": "kcal", "kj": "kJ"}
MACRO_TO_KEY = { MACRO_TO_KEY = {
"kcal": "ENERCC", "kcal": "ENERCC",
"protein_g": "PROT625", "protein_g": "PROT625",
@ -17,9 +21,24 @@ def scale_to_per_100g(value: float, serving_g: float | None) -> float:
return float(value) * (100.0 / float(serving_g)) return float(value) * (100.0 / float(serving_g))
def list_numeric_attributes(cur, query: str = "", limit: int = 40) -> list[dict[str, Any]]: def list_numeric_attributes(
q = (query or "").strip() cur, query: str = "", limit: int = 40, keys: list[str] | None = None
) -> list[dict[str, Any]]:
lim = min(max(int(limit or 40), 1), 80) lim = min(max(int(limit or 40), 1), 80)
wanted = [str(k).strip() for k in (keys or []) if str(k).strip()]
if wanted:
cur.execute(
"""
SELECT id, attr_key, name_de, name_en, unit, category, origin
FROM food_attributes
WHERE is_active = true AND data_type = 'num_per_100g'
AND attr_key = ANY(%s)
ORDER BY sort_order, attr_key
""",
(wanted,),
)
return [dict(r) for r in cur.fetchall()]
q = (query or "").strip()
if q: if q:
like = f"%{q}%" like = f"%{q}%"
cur.execute( cur.execute(
@ -126,3 +145,43 @@ def extra_attributes_for_food(cur, food_id: str) -> dict[str, float]:
(food_id, list(MACRO_TO_KEY.values())), (food_id, list(MACRO_TO_KEY.values())),
) )
return {r["attr_key"]: float(r["value_num"]) for r in cur.fetchall()} return {r["attr_key"]: float(r["value_num"]) for r in cur.fetchall()}
def normalize_attr_key(raw: str | None) -> str:
key = _ATTR_KEY_RE.sub("_", (raw or "").strip().upper()).strip("_")
return key[:64]
def normalize_attr_unit(raw: str | None) -> str:
return _ALLOWED_UNITS.get((raw or "g").strip().lower(), "g")
def create_extension_attribute(cur, name_de: str, unit: str = "g", attr_key: str | None = None) -> dict[str, Any]:
name = (name_de or "").strip()
if not name:
raise ValueError("Name fehlt")
key = normalize_attr_key(attr_key or name)
if not key:
raise ValueError("attr_key fehlt")
unit_n = normalize_attr_unit(unit)
cur.execute(
"""
SELECT id, attr_key, name_de, name_en, unit, category, origin
FROM food_attributes
WHERE attr_key = %s AND is_active = true
""",
(key,),
)
existing = cur.fetchone()
if existing:
return dict(existing)
cur.execute(
"""
INSERT INTO food_attributes
(attr_key, name_de, name_en, unit, category, data_type, origin, sort_order)
VALUES (%s, %s, %s, %s, 'extension', 'num_per_100g', 'extension', 9100)
RETURNING id, attr_key, name_de, name_en, unit, category, origin
""",
(key, name, None, unit_n),
)
return dict(cur.fetchone())

View File

@ -52,6 +52,12 @@ class SuggestBatchBody(BaseModel):
limit: int = 3 limit: int = 3
class UserAttributeCreate(BaseModel):
name_de: str
unit: str = "g"
attr_key: Optional[str] = None
def _pid(session: dict, x_profile_id: Optional[str] = None) -> str: def _pid(session: dict, x_profile_id: Optional[str] = None) -> str:
return x_profile_id or session["profile_id"] return x_profile_id or session["profile_id"]
@ -100,12 +106,26 @@ def search_foods(
@router.get("/attributes") @router.get("/attributes")
def list_food_attributes( def list_food_attributes(
q: str = "", q: str = "",
keys: str = "",
limit: int = 40, limit: int = 40,
session: dict = Depends(require_auth), session: dict = Depends(require_auth),
): ):
from data_layer.food_attributes import list_numeric_attributes from data_layer.food_attributes import list_numeric_attributes
key_list = [p.strip() for p in (keys or "").split(",") if p.strip()]
with get_db() as conn: with get_db() as conn:
return list_numeric_attributes(get_cursor(conn), q, limit=limit) return list_numeric_attributes(get_cursor(conn), q, limit=limit, keys=key_list or None)
@router.post("/attributes")
def create_food_attribute(body: UserAttributeCreate, session: dict = Depends(require_auth)):
from data_layer.food_attributes import create_extension_attribute
try:
with get_db() as conn:
return create_extension_attribute(
get_cursor(conn), body.name_de, body.unit, body.attr_key
)
except ValueError as e:
raise HTTPException(400, str(e)) from e
@router.post("/foods/suggest-batch") @router.post("/foods/suggest-batch")

View File

@ -1,4 +1,10 @@
from data_layer.food_attributes import macros_and_attributes_to_values, scale_to_per_100g from data_layer.food_attributes import (
create_extension_attribute,
macros_and_attributes_to_values,
normalize_attr_key,
normalize_attr_unit,
scale_to_per_100g,
)
def test_scale_serving_to_per_100g(): def test_scale_serving_to_per_100g():
@ -7,6 +13,28 @@ def test_scale_serving_to_per_100g():
assert scale_to_per_100g(10, None) == 10 assert scale_to_per_100g(10, None) == 10
def test_attr_key_and_unit_normalize():
assert normalize_attr_key("Vitamin D3") == "VITAMIN_D3"
assert normalize_attr_unit("ug") == "µg"
assert normalize_attr_unit("mcg") == "µg"
def test_create_extension_returns_existing():
class _Cur:
def __init__(self):
self.calls = 0
def execute(self, sql, params=None):
self.calls += 1
self._row = {"id": 1, "attr_key": "VITAMIN_D3", "name_de": "Vitamin D3", "name_en": None, "unit": "µg", "category": "extension", "origin": "extension"} if self.calls == 1 else None
def fetchone(self):
return self._row
row = create_extension_attribute(_Cur(), "Vitamin D3", "µg")
assert row["attr_key"] == "VITAMIN_D3"
def test_norsan_label_epa_converts(): def test_norsan_label_epa_converts():
"""Etikett: 1100 mg EPA = 1.1 g in 8 g Portion → g/100 g.""" """Etikett: 1100 mg EPA = 1.1 g in 8 g Portion → g/100 g."""
vals = macros_and_attributes_to_values( vals = macros_and_attributes_to_values(

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.5", # Listen-Dialog: Mapping an der Zutat; Suche Listen/eigene vor BLS "nutrition": "1.3.5", # Listen-Dialog: Mapping an der Zutat; Suche Listen/eigene vor BLS
"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.5", # GET /bls/units "bls": "1.0.6", # Nutzer darf Extension-Stoffe anlegen; EPA-Chips; mg/µg am Etikett
"photos": "1.0.0", "photos": "1.0.0",
"insights": "1.3.0", "insights": "1.3.0",
"prompts": "1.1.0", "prompts": "1.1.0",

View File

@ -1,16 +1,35 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { api } from '../utils/api' import { api } from '../utils/api'
const MASS = { g: 1, mg: 0.001, 'µg': 0.000001, ug: 0.000001, mcg: 0.000001 }
const QUICK_KEYS = ['EPA', 'DHA', 'DPA', 'OMEGA3']
function parseNum(raw) { function parseNum(raw) {
const n = parseFloat(String(raw ?? '').replace(',', '.')) const n = parseFloat(String(raw ?? '').replace(',', '.'))
return Number.isFinite(n) ? n : 0 return Number.isFinite(n) ? n : 0
} }
function attrUnit(attr) {
const u = (attr?.unit || 'g').trim()
if (u === 'ug' || u === 'mcg') return 'µg'
return u
}
export function convertMass(value, fromUnit, toUnit) {
const from = MASS[(fromUnit || 'g').toLowerCase()]
const to = MASS[(toUnit || 'g').toLowerCase()]
if (!from || !to) return value
return value * (from / to)
}
export function foodCreatePayload(name, macros, extras, servingG) { export function foodCreatePayload(name, macros, extras, servingG) {
const attributes = {} const attributes = {}
for (const row of extras || []) { for (const row of extras || []) {
if (!row?.attr?.attr_key || row.value === '' || row.value == null) continue if (!row?.attr?.attr_key || row.value === '' || row.value == null) continue
attributes[row.attr.attr_key] = parseNum(row.value) const n = parseNum(row.value)
const target = attrUnit(row.attr)
const entered = row.inputUnit || target
attributes[row.attr.attr_key] = convertMass(n, entered, target)
} }
const serving = parseNum(servingG) const serving = parseNum(servingG)
return { return {
@ -29,10 +48,20 @@ export function foodCreatePayload(name, macros, extras, servingG) {
export default function FoodNutrientFields({ macros, setMacros, extras, setExtras, servingG, setServingG }) { export default function FoodNutrientFields({ macros, setMacros, extras, setExtras, servingG, setServingG }) {
const [q, setQ] = useState('') const [q, setQ] = useState('')
const [hits, setHits] = useState([]) const [hits, setHits] = useState([])
const [quick, setQuick] = useState([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [newName, setNewName] = useState('')
const [newUnit, setNewUnit] = useState('g')
const [creating, setCreating] = useState(false)
const [createErr, setCreateErr] = useState(null)
const timer = useRef(null) const timer = useRef(null)
useEffect(() => () => clearTimeout(timer.current), []) useEffect(() => () => clearTimeout(timer.current), [])
useEffect(() => {
api.listFoodAttributes('', 20, QUICK_KEYS.join(',')).then((rows) => {
setQuick(Array.isArray(rows) ? rows : [])
}).catch(() => {})
}, [])
const search = (term) => { const search = (term) => {
clearTimeout(timer.current) clearTimeout(timer.current)
@ -55,7 +84,9 @@ export default function FoodNutrientFields({ macros, setMacros, extras, setExtra
const add = (attr) => { const add = (attr) => {
setExtras((list) => ( setExtras((list) => (
list.some((r) => r.attr.attr_key === attr.attr_key) ? list : [...list, { attr, value: '' }] list.some((r) => r.attr.attr_key === attr.attr_key)
? list
: [...list, { attr, value: '', inputUnit: attrUnit(attr) }]
)) ))
setQ('') setQ('')
setHits([]) setHits([])
@ -65,10 +96,36 @@ export default function FoodNutrientFields({ macros, setMacros, extras, setExtra
setExtras((list) => list.map((r) => (r.attr.attr_key === key ? { ...r, value } : r))) setExtras((list) => list.map((r) => (r.attr.attr_key === key ? { ...r, value } : r)))
} }
const setInputUnit = (key, unit) => {
setExtras((list) => list.map((r) => (r.attr.attr_key === key ? { ...r, inputUnit: unit } : r)))
}
const createAttr = async () => {
const name = (newName || q).trim()
if (!name) {
setCreateErr('Name des Stoffs fehlt')
return
}
setCreating(true)
setCreateErr(null)
try {
const attr = await api.createFoodAttribute({ name_de: name, unit: newUnit })
add(attr)
setNewName('')
} catch (e) {
setCreateErr(e.message)
} finally {
setCreating(false)
}
}
const unusedQuick = quick.filter((a) => !extras.some((r) => r.attr.attr_key === a.attr_key))
return ( return (
<div> <div>
<p style={{ fontSize: 12, color: 'var(--text3)', margin: '0 0 8px' }}> <p style={{ fontSize: 12, color: 'var(--text3)', margin: '0 0 8px' }}>
Zahlen in der Einheit des Stoffs. Bei Etikett pro 8 ml / 8 g die Portionsgröße setzen gespeichert wird immer pro 100 g. Etikettwerte so eintragen, wie sie dastehen. Portionsgröße setzen (5 ml Öl 5 g). Gespeichert wird immer pro 100 g.
Ein Öl/Supplement ist ein Lebensmittel, keine Liste Listen können keine Listen enthalten.
</p> </p>
<label style={{ display: 'block', fontSize: 12, color: 'var(--text2)', marginBottom: 8 }}> <label style={{ display: 'block', fontSize: 12, color: 'var(--text2)', marginBottom: 8 }}>
Angabe gilt für{' '} Angabe gilt für{' '}
@ -81,7 +138,7 @@ export default function FoodNutrientFields({ macros, setMacros, extras, setExtra
value={servingG} value={servingG}
onChange={(e) => setServingG(e.target.value)} onChange={(e) => setServingG(e.target.value)}
/> />
{' '}g (100 = pro 100 g) {' '}g (100 = pro 100 g; 5 ml Öl meist 5)
</label> </label>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
{[['kcal', 'kcal'], ['protein_g', 'Protein g'], ['fat_g', 'Fett g'], ['carbs_g', 'Kohlenhydrate g']].map(([key, label]) => ( {[['kcal', 'kcal'], ['protein_g', 'Protein g'], ['fat_g', 'Fett g'], ['carbs_g', 'Kohlenhydrate g']].map(([key, label]) => (
@ -99,11 +156,20 @@ export default function FoodNutrientFields({ macros, setMacros, extras, setExtra
</label> </label>
))} ))}
</div> </div>
<p style={{ fontSize: 12, fontWeight: 600, margin: '12px 0 6px' }}>Weitere Stoffe (EPA, DHA, Ballaststoffe, )</p> <p style={{ fontSize: 12, fontWeight: 600, margin: '12px 0 6px' }}>Weitere Stoffe</p>
{unusedQuick.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 8 }}>
{unusedQuick.map((a) => (
<button key={a.attr_key} type="button" className="btn btn-secondary" style={{ fontSize: 12 }} onClick={() => add(a)}>
{a.attr_key}
</button>
))}
</div>
)}
<input <input
className="form-input" className="form-input"
style={{ width: '100%', textAlign: 'left' }} style={{ width: '100%', textAlign: 'left' }}
placeholder="Stoff suchen, z. B. EPA oder Omega" placeholder="Stoff suchen, z. B. EPA, Omega, Vitamin D"
value={q} value={q}
onChange={(e) => { setQ(e.target.value); search(e.target.value) }} onChange={(e) => { setQ(e.target.value); search(e.target.value) }}
/> />
@ -122,21 +188,63 @@ export default function FoodNutrientFields({ macros, setMacros, extras, setExtra
</span> </span>
</button> </button>
))} ))}
{extras.map((row) => ( <div style={{ marginTop: 10, padding: 10, background: 'var(--surface2)', borderRadius: 8 }}>
<label key={row.attr.attr_key} style={{ display: 'block', fontSize: 12, color: 'var(--text2)', marginTop: 8 }}> <p style={{ fontSize: 12, color: 'var(--text2)', margin: '0 0 6px' }}>Stoff nicht gefunden? Neu anlegen (wird für alle Lebensmittel nutzbar).</p>
{row.attr.name_de}{row.attr.unit ? ` (${row.attr.unit})` : ''} <input
<input className="form-input"
className="form-input" style={{ width: '100%', textAlign: 'left', marginBottom: 6 }}
type="number" placeholder="Name, z. B. Vitamin D3"
min="0" value={newName}
step="0.001" onChange={(e) => setNewName(e.target.value)}
style={{ width: '100%', textAlign: 'left', marginTop: 4 }} />
value={row.value} <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
onChange={(e) => setValue(row.attr.attr_key, e.target.value)} <select className="form-input" style={{ width: 90, textAlign: 'left' }} value={newUnit} onChange={(e) => setNewUnit(e.target.value)}>
placeholder="Wert in der Einheit oben" <option value="g">g</option>
/> <option value="mg">mg</option>
</label> <option value="µg">µg</option>
))} </select>
<button type="button" className="btn btn-secondary" disabled={creating} onClick={createAttr}>
{creating ? '…' : 'Parameter anlegen'}
</button>
</div>
{createErr && <p style={{ color: 'var(--danger)', fontSize: 12, margin: '6px 0 0' }}>{createErr}</p>}
</div>
{extras.map((row) => {
const target = attrUnit(row.attr)
const entered = row.inputUnit || target
const mass = MASS[target.toLowerCase()]
return (
<label key={row.attr.attr_key} style={{ display: 'block', fontSize: 12, color: 'var(--text2)', marginTop: 8 }}>
{row.attr.name_de} (Katalog: {target})
<span style={{ display: 'flex', gap: 6, marginTop: 4 }}>
<input
className="form-input"
type="number"
min="0"
step="0.001"
style={{ flex: 1, textAlign: 'left' }}
value={row.value}
onChange={(e) => setValue(row.attr.attr_key, e.target.value)}
placeholder="Wert vom Etikett"
/>
{mass ? (
<select
className="form-input"
style={{ width: 72, textAlign: 'left' }}
value={entered}
onChange={(e) => setInputUnit(row.attr.attr_key, e.target.value)}
>
<option value="g">g</option>
<option value="mg">mg</option>
<option value="µg">µg</option>
</select>
) : (
<span style={{ alignSelf: 'center' }}>{target}</span>
)}
</span>
</label>
)
})}
</div> </div>
) )
} }

View File

@ -288,7 +288,8 @@ export const api = {
searchBlsFoods: (q, limit=20, signal) => req(`/bls/foods?q=${encodeURIComponent(q||'')}&limit=${limit}`, signal ? { signal } : {}), searchBlsFoods: (q, limit=20, signal) => req(`/bls/foods?q=${encodeURIComponent(q||'')}&limit=${limit}`, signal ? { signal } : {}),
suggestFoodsBatch: (names, limit=3) => req('/bls/foods/suggest-batch', json({ names, limit })), suggestFoodsBatch: (names, limit=3) => req('/bls/foods/suggest-batch', json({ names, limit })),
createUserFood: (d) => req('/bls/foods/manual', json(d)), createUserFood: (d) => req('/bls/foods/manual', json(d)),
listFoodAttributes: (q='', limit=40) => req(`/bls/attributes?q=${encodeURIComponent(q||'')}&limit=${limit}`), listFoodAttributes: (q='', limit=40, keys='') => req(`/bls/attributes?q=${encodeURIComponent(q||'')}&limit=${limit}${keys ? `&keys=${encodeURIComponent(keys)}` : ''}`),
createFoodAttribute: (d) => req('/bls/attributes', json(d)),
listMyFoodMappings: () => req('/bls/mappings'), listMyFoodMappings: () => req('/bls/mappings'),
upsertMyFoodMapping: (d) => req('/bls/mappings', json(d)), upsertMyFoodMapping: (d) => req('/bls/mappings', json(d)),
deleteMyFoodMapping: (id) => req(`/bls/mappings/${id}`, {method:'DELETE'}), deleteMyFoodMapping: (id) => req(`/bls/mappings/${id}`, {method:'DELETE'}),