Katalog, lernendes Mapping ohne KI, optionale Items und Import-Policy. Playwright-Smoke und Issue-Audit um Ernährung/Zuordnen/API ergänzt. Co-authored-by: Cursor <cursoragent@cursor.com>
145 lines
5.0 KiB
JavaScript
145 lines
5.0 KiB
JavaScript
import { useEffect, useState } from 'react'
|
||
import { api } from '../utils/api'
|
||
|
||
export default function NutritionFoodMap({ onChanged }) {
|
||
const [unmapped, setUnmapped] = useState([])
|
||
const [learned, setLearned] = useState([])
|
||
const [error, setError] = useState(null)
|
||
const [query, setQuery] = useState({})
|
||
const [hits, setHits] = useState({})
|
||
const [saving, setSaving] = useState(null)
|
||
|
||
const load = async () => {
|
||
try {
|
||
const [u, m] = await Promise.all([api.listUnmappedFoods(), api.listMyFoodMappings()])
|
||
setUnmapped(u)
|
||
setLearned(m)
|
||
} catch (e) {
|
||
setError(e.message)
|
||
}
|
||
}
|
||
|
||
useEffect(() => { load() }, [])
|
||
|
||
const search = async (key, q) => {
|
||
setQuery((s) => ({ ...s, [key]: q }))
|
||
if (!q || q.length < 2) {
|
||
setHits((s) => ({ ...s, [key]: [] }))
|
||
return
|
||
}
|
||
try {
|
||
const rows = await api.searchBlsFoods(q)
|
||
setHits((s) => ({ ...s, [key]: rows }))
|
||
} catch (e) {
|
||
setError(e.message)
|
||
}
|
||
}
|
||
|
||
const assign = async (sourceName, foodId, key) => {
|
||
setSaving(key)
|
||
setError(null)
|
||
try {
|
||
await api.upsertMyFoodMapping({ source_name: sourceName, food_id: foodId })
|
||
setHits((s) => ({ ...s, [key]: [] }))
|
||
await load()
|
||
onChanged?.()
|
||
} catch (e) {
|
||
setError(e.message)
|
||
} finally {
|
||
setSaving(null)
|
||
}
|
||
}
|
||
|
||
const remove = async (id) => {
|
||
if (!confirm('Zuordnung wirklich löschen?')) return
|
||
try {
|
||
await api.deleteMyFoodMapping(id)
|
||
await load()
|
||
onChanged?.()
|
||
} catch (e) {
|
||
setError(e.message)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="card section-gap">
|
||
<div className="card-title">Lebensmittel zuordnen</div>
|
||
<p style={{ fontSize: 13, color: 'var(--text2)', lineHeight: 1.6, marginBottom: 12 }}>
|
||
Einmal bestätigt, bleibt die Zuordnung erhalten und gilt für spätere Importe.
|
||
Du kannst sie jederzeit ändern oder löschen. Keine automatische KI-Zuordnung.
|
||
</p>
|
||
{error && <div style={{ color: 'var(--danger)', fontSize: 13, marginBottom: 10 }}>{error}</div>}
|
||
|
||
<h3 style={{ fontSize: 14, margin: '12px 0 8px' }}>Offen ({unmapped.length})</h3>
|
||
{unmapped.length === 0 && <p className="muted">Keine ungemappten Bezeichner.</p>}
|
||
{unmapped.map((u) => {
|
||
const key = u.source_name_normalized
|
||
return (
|
||
<div key={key} style={{ borderTop: '1px solid var(--border)', padding: '10px 0' }}>
|
||
<div style={{ fontWeight: 600 }}>{u.source_name_raw}</div>
|
||
<div style={{ fontSize: 12, color: 'var(--text3)' }}>
|
||
{u.count}× · {u.first_date} – {u.last_date}
|
||
</div>
|
||
<input
|
||
className="form-input"
|
||
style={{ marginTop: 6 }}
|
||
placeholder="BLS-Code oder Name suchen…"
|
||
value={query[key] || ''}
|
||
onChange={(e) => search(key, e.target.value)}
|
||
/>
|
||
{(hits[key] || []).map((h) => (
|
||
<button
|
||
key={h.id}
|
||
type="button"
|
||
className="btn btn-secondary"
|
||
style={{ marginTop: 6, marginRight: 6 }}
|
||
disabled={saving === key}
|
||
onClick={() => assign(u.source_name_raw, h.id, key)}
|
||
>
|
||
{h.bls_code ? `${h.bls_code} · ` : ''}{h.name_de}
|
||
{h.catalog_kind !== 'official_bls' ? ' (manuell)' : ''}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)
|
||
})}
|
||
|
||
<h3 style={{ fontSize: 14, margin: '20px 0 8px' }}>Gelernt ({learned.length})</h3>
|
||
{learned.map((m) => (
|
||
<div key={m.id} style={{ display: 'flex', justifyContent: 'space-between', gap: 8, padding: '8px 0', borderTop: '1px solid var(--border)' }}>
|
||
<div>
|
||
<div style={{ fontWeight: 500 }}>{m.source_name_raw}</div>
|
||
<div style={{ fontSize: 12, color: 'var(--text3)' }}>
|
||
→ {m.bls_code ? `${m.bls_code} · ` : ''}{m.food_name_de}
|
||
{m.catalog_kind !== 'official_bls' ? ' (manuell)' : ''}
|
||
</div>
|
||
</div>
|
||
<button type="button" className="btn btn-secondary" onClick={() => remove(m.id)}>Löschen</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export function DayMarkButtons({ date, markType, onChanged }) {
|
||
const setMark = async (type) => {
|
||
try {
|
||
if (markType === type) await api.deleteNutritionDayMark(date)
|
||
else await api.putNutritionDayMark(date, { mark_type: type })
|
||
onChanged?.()
|
||
} catch (e) {
|
||
alert(e.message)
|
||
}
|
||
}
|
||
return (
|
||
<span style={{ display: 'inline-flex', gap: 4 }}>
|
||
<button type="button" className="btn btn-secondary" style={{ fontSize: 11, padding: '4px 8px' }} onClick={() => setMark('fasting')}>
|
||
{markType === 'fasting' ? '✓ Fasten' : 'Fasten'}
|
||
</button>
|
||
<button type="button" className="btn btn-secondary" style={{ fontSize: 11, padding: '4px 8px' }} onClick={() => setMark('incomplete')}>
|
||
{markType === 'incomplete' ? '✓ Lücke' : 'Lücke'}
|
||
</button>
|
||
</span>
|
||
)
|
||
}
|