145 lines
5.0 KiB
React
145 lines
5.0 KiB
React
|
|
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>
|
|||
|
|
)
|
|||
|
|
}
|