feat: add nutrition entry editing and import history
Features:
- Import history panel showing all CSV imports with date, count, and range
- Edit/delete functionality for nutrition entries (inline editing)
- New backend endpoints: GET /import-history, PUT /{id}, DELETE /{id}
UI Changes:
- Import history displayed under import panel
- "Daten" tab now has edit/delete buttons per entry
- Inline form for editing macros (kcal, protein, fat, carbs)
- Confirmation dialog for deletion
Backend:
- nutrition.py: Added import_history, update_nutrition, delete_nutrition endpoints
- Groups imports by created date to show history
Frontend:
- NutritionPage: New DataTab and ImportHistory components
- api.js: Added nutritionImportHistory, updateNutrition, deleteNutrition
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
d833a60ad4
commit
0f072f4735
|
|
@ -163,3 +163,61 @@ def nutrition_weekly(weeks: int=16, x_profile_id: Optional[str]=Header(default=N
|
||||||
def avg(k): return round(sum(float(e.get(k) or 0) for e in en)/n,1)
|
def avg(k): return round(sum(float(e.get(k) or 0) for e in en)/n,1)
|
||||||
result.append({'week':wk,'days':n,'kcal':avg('kcal'),'protein_g':avg('protein_g'),'fat_g':avg('fat_g'),'carbs_g':avg('carbs_g')})
|
result.append({'week':wk,'days':n,'kcal':avg('kcal'),'protein_g':avg('protein_g'),'fat_g':avg('fat_g'),'carbs_g':avg('carbs_g')})
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/import-history")
|
||||||
|
def import_history(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
||||||
|
"""Get import history by grouping entries by created timestamp."""
|
||||||
|
pid = get_pid(x_profile_id)
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
cur.execute("""
|
||||||
|
SELECT
|
||||||
|
DATE(created) as import_date,
|
||||||
|
COUNT(*) as count,
|
||||||
|
MIN(date) as date_from,
|
||||||
|
MAX(date) as date_to,
|
||||||
|
MAX(created) as last_created
|
||||||
|
FROM nutrition_log
|
||||||
|
WHERE profile_id=%s AND source='csv'
|
||||||
|
GROUP BY DATE(created)
|
||||||
|
ORDER BY DATE(created) DESC
|
||||||
|
""", (pid,))
|
||||||
|
return [r2d(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{entry_id}")
|
||||||
|
def update_nutrition(entry_id: str, kcal: float, protein_g: float, fat_g: float, carbs_g: float,
|
||||||
|
x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
||||||
|
"""Update nutrition entry macros."""
|
||||||
|
pid = get_pid(x_profile_id)
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
# Verify ownership
|
||||||
|
cur.execute("SELECT id FROM nutrition_log WHERE id=%s AND profile_id=%s", (entry_id, pid))
|
||||||
|
if not cur.fetchone():
|
||||||
|
raise HTTPException(404, "Eintrag nicht gefunden")
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE nutrition_log
|
||||||
|
SET kcal=%s, protein_g=%s, fat_g=%s, carbs_g=%s
|
||||||
|
WHERE id=%s AND profile_id=%s
|
||||||
|
""", (round(kcal,1), round(protein_g,1), round(fat_g,1), round(carbs_g,1), entry_id, pid))
|
||||||
|
|
||||||
|
return {"success": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{entry_id}")
|
||||||
|
def delete_nutrition(entry_id: str, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
||||||
|
"""Delete nutrition entry."""
|
||||||
|
pid = get_pid(x_profile_id)
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
# Verify ownership
|
||||||
|
cur.execute("SELECT id FROM nutrition_log WHERE id=%s AND profile_id=%s", (entry_id, pid))
|
||||||
|
if not cur.fetchone():
|
||||||
|
raise HTTPException(404, "Eintrag nicht gefunden")
|
||||||
|
|
||||||
|
cur.execute("DELETE FROM nutrition_log WHERE id=%s AND profile_id=%s", (entry_id, pid))
|
||||||
|
|
||||||
|
return {"success": True}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,220 @@ function rollingAvg(arr, key, window=7) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Data Tab (Editable Entry List) ───────────────────────────────────────────
|
||||||
|
function DataTab({ entries, onUpdate }) {
|
||||||
|
const [editId, setEditId] = useState(null)
|
||||||
|
const [editValues, setEditValues] = useState({})
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
|
const startEdit = (e) => {
|
||||||
|
setEditId(e.id)
|
||||||
|
setEditValues({
|
||||||
|
kcal: e.kcal || 0,
|
||||||
|
protein_g: e.protein_g || 0,
|
||||||
|
fat_g: e.fat_g || 0,
|
||||||
|
carbs_g: e.carbs_g || 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelEdit = () => {
|
||||||
|
setEditId(null)
|
||||||
|
setEditValues({})
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveEdit = async (id) => {
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await nutritionApi.updateNutrition(
|
||||||
|
id,
|
||||||
|
editValues.kcal,
|
||||||
|
editValues.protein_g,
|
||||||
|
editValues.fat_g,
|
||||||
|
editValues.carbs_g
|
||||||
|
)
|
||||||
|
setEditId(null)
|
||||||
|
setEditValues({})
|
||||||
|
onUpdate()
|
||||||
|
} catch(e) {
|
||||||
|
setError('Speichern fehlgeschlagen: ' + e.message)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteEntry = async (id, date) => {
|
||||||
|
if (!confirm(`Eintrag vom ${dayjs(date).format('DD.MM.YYYY')} wirklich löschen?`)) return
|
||||||
|
try {
|
||||||
|
await nutritionApi.deleteNutrition(id)
|
||||||
|
onUpdate()
|
||||||
|
} catch(e) {
|
||||||
|
setError('Löschen fehlgeschlagen: ' + e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="card section-gap">
|
||||||
|
<div className="card-title">Alle Einträge (0)</div>
|
||||||
|
<p className="muted">Noch keine Ernährungsdaten. Importiere FDDB CSV oben.</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card section-gap">
|
||||||
|
<div className="card-title">Alle Einträge ({entries.length})</div>
|
||||||
|
{error && (
|
||||||
|
<div style={{padding:'8px 12px',background:'#FCEBEB',borderRadius:8,fontSize:13,color:'#D85A30',marginBottom:12}}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{entries.map((e, i) => {
|
||||||
|
const isEditing = editId === e.id
|
||||||
|
return (
|
||||||
|
<div key={e.id || i} style={{
|
||||||
|
borderBottom: i < entries.length - 1 ? '1px solid var(--border)' : 'none',
|
||||||
|
padding: '12px 0'
|
||||||
|
}}>
|
||||||
|
{!isEditing ? (
|
||||||
|
<>
|
||||||
|
<div style={{display:'flex', justifyContent:'space-between', alignItems:'center', marginBottom:6}}>
|
||||||
|
<strong style={{fontSize:14}}>{dayjs(e.date).format('dd, DD. MMMM YYYY')}</strong>
|
||||||
|
<div style={{display:'flex',gap:6}}>
|
||||||
|
<button onClick={() => startEdit(e)}
|
||||||
|
style={{padding:'4px 10px',fontSize:11,borderRadius:6,border:'1px solid var(--border2)',
|
||||||
|
background:'var(--surface)',color:'var(--text2)',cursor:'pointer'}}>
|
||||||
|
✏️ Bearbeiten
|
||||||
|
</button>
|
||||||
|
<button onClick={() => deleteEntry(e.id, e.date)}
|
||||||
|
style={{padding:'4px 10px',fontSize:11,borderRadius:6,border:'1px solid #D85A30',
|
||||||
|
background:'#FCEBEB',color:'#D85A30',cursor:'pointer'}}>
|
||||||
|
🗑️
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{fontSize:13, fontWeight:600, color:'#EF9F27',marginBottom:6}}>
|
||||||
|
{Math.round(e.kcal || 0)} kcal
|
||||||
|
</div>
|
||||||
|
<div style={{display:'flex', gap:12, fontSize:12, color:'var(--text2)'}}>
|
||||||
|
<span>🥩 Protein: <strong>{Math.round(e.protein_g || 0)}g</strong></span>
|
||||||
|
<span>🫙 Fett: <strong>{Math.round(e.fat_g || 0)}g</strong></span>
|
||||||
|
<span>🍞 Kohlenhydrate: <strong>{Math.round(e.carbs_g || 0)}g</strong></span>
|
||||||
|
</div>
|
||||||
|
{e.source && (
|
||||||
|
<div style={{fontSize:10, color:'var(--text3)', marginTop:4}}>
|
||||||
|
Quelle: {e.source}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div style={{marginBottom:8}}>
|
||||||
|
<strong style={{fontSize:14}}>{dayjs(e.date).format('dd, DD. MMMM YYYY')}</strong>
|
||||||
|
</div>
|
||||||
|
<div style={{display:'grid',gridTemplateColumns:'1fr 1fr',gap:8,marginBottom:10}}>
|
||||||
|
<div>
|
||||||
|
<label style={{fontSize:11,color:'var(--text3)',display:'block',marginBottom:4}}>Kalorien</label>
|
||||||
|
<input type="number" className="form-input" value={editValues.kcal}
|
||||||
|
onChange={e => setEditValues({...editValues, kcal: parseFloat(e.target.value)||0})}
|
||||||
|
style={{width:'100%'}}/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{fontSize:11,color:'var(--text3)',display:'block',marginBottom:4}}>Protein (g)</label>
|
||||||
|
<input type="number" className="form-input" value={editValues.protein_g}
|
||||||
|
onChange={e => setEditValues({...editValues, protein_g: parseFloat(e.target.value)||0})}
|
||||||
|
style={{width:'100%'}}/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{fontSize:11,color:'var(--text3)',display:'block',marginBottom:4}}>Fett (g)</label>
|
||||||
|
<input type="number" className="form-input" value={editValues.fat_g}
|
||||||
|
onChange={e => setEditValues({...editValues, fat_g: parseFloat(e.target.value)||0})}
|
||||||
|
style={{width:'100%'}}/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{fontSize:11,color:'var(--text3)',display:'block',marginBottom:4}}>Kohlenhydrate (g)</label>
|
||||||
|
<input type="number" className="form-input" value={editValues.carbs_g}
|
||||||
|
onChange={e => setEditValues({...editValues, carbs_g: parseFloat(e.target.value)||0})}
|
||||||
|
style={{width:'100%'}}/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{display:'flex',gap:8}}>
|
||||||
|
<button onClick={() => saveEdit(e.id)} disabled={saving}
|
||||||
|
className="btn btn-primary" style={{flex:1}}>
|
||||||
|
{saving ? 'Speichere…' : '✓ Speichern'}
|
||||||
|
</button>
|
||||||
|
<button onClick={cancelEdit} disabled={saving}
|
||||||
|
className="btn btn-secondary" style={{flex:1}}>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Import History ────────────────────────────────────────────────────────────
|
||||||
|
function ImportHistory() {
|
||||||
|
const [history, setHistory] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const data = await nutritionApi.nutritionImportHistory()
|
||||||
|
setHistory(Array.isArray(data) ? data : [])
|
||||||
|
} catch(e) {
|
||||||
|
console.error('Failed to load import history:', e)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (loading) return null
|
||||||
|
if (!history.length) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card section-gap">
|
||||||
|
<div className="card-title">Import-Historie</div>
|
||||||
|
<div style={{display:'flex',flexDirection:'column',gap:8}}>
|
||||||
|
{history.map((h, i) => (
|
||||||
|
<div key={i} style={{
|
||||||
|
padding: '10px 12px',
|
||||||
|
background: 'var(--surface2)',
|
||||||
|
borderRadius: 8,
|
||||||
|
borderLeft: '3px solid var(--accent)',
|
||||||
|
fontSize: 13
|
||||||
|
}}>
|
||||||
|
<div style={{display:'flex',justifyContent:'space-between',marginBottom:4}}>
|
||||||
|
<strong>{dayjs(h.import_date).format('DD.MM.YYYY')}</strong>
|
||||||
|
<span style={{color:'var(--text3)',fontSize:11}}>
|
||||||
|
{dayjs(h.last_created).format('HH:mm')} Uhr
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{color:'var(--text2)',fontSize:12}}>
|
||||||
|
<span>{h.count} {h.count === 1 ? 'Eintrag' : 'Einträge'}</span>
|
||||||
|
{h.date_from && h.date_to && (
|
||||||
|
<span style={{marginLeft:8,color:'var(--text3)'}}>
|
||||||
|
({dayjs(h.date_from).format('DD.MM.YY')} – {dayjs(h.date_to).format('DD.MM.YY')})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Import Panel ──────────────────────────────────────────────────────────────
|
// ── Import Panel ──────────────────────────────────────────────────────────────
|
||||||
function ImportPanel({ onImported }) {
|
function ImportPanel({ onImported }) {
|
||||||
const fileRef = useRef()
|
const fileRef = useRef()
|
||||||
|
|
@ -337,7 +551,7 @@ export default function NutritionPage() {
|
||||||
nutritionApi.nutritionCorrelations(),
|
nutritionApi.nutritionCorrelations(),
|
||||||
nutritionApi.nutritionWeekly(16),
|
nutritionApi.nutritionWeekly(16),
|
||||||
nutritionApi.listNutrition(365), // BUG-002 fix: load raw entries
|
nutritionApi.listNutrition(365), // BUG-002 fix: load raw entries
|
||||||
api.getActiveProfile(),
|
nutritionApi.getActiveProfile(),
|
||||||
])
|
])
|
||||||
setCorr(Array.isArray(corr)?corr:[])
|
setCorr(Array.isArray(corr)?corr:[])
|
||||||
setWeekly(Array.isArray(wkly)?wkly:[])
|
setWeekly(Array.isArray(wkly)?wkly:[])
|
||||||
|
|
@ -355,6 +569,7 @@ export default function NutritionPage() {
|
||||||
<h1 className="page-title">Ernährung</h1>
|
<h1 className="page-title">Ernährung</h1>
|
||||||
|
|
||||||
<ImportPanel onImported={load}/>
|
<ImportPanel onImported={load}/>
|
||||||
|
<ImportHistory/>
|
||||||
|
|
||||||
{loading && <div className="empty-state"><div className="spinner"/></div>}
|
{loading && <div className="empty-state"><div className="spinner"/></div>}
|
||||||
|
|
||||||
|
|
@ -378,35 +593,7 @@ export default function NutritionPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tab==='data' && (
|
{tab==='data' && (
|
||||||
<div className="card section-gap">
|
<DataTab entries={entries} onUpdate={load}/>
|
||||||
<div className="card-title">Alle Einträge ({entries.length})</div>
|
|
||||||
{entries.length === 0 && (
|
|
||||||
<p className="muted">Noch keine Ernährungsdaten. Importiere FDDB CSV oben.</p>
|
|
||||||
)}
|
|
||||||
{entries.map((e, i) => (
|
|
||||||
<div key={e.id || i} style={{
|
|
||||||
borderBottom: i < entries.length - 1 ? '1px solid var(--border)' : 'none',
|
|
||||||
padding: '10px 0'
|
|
||||||
}}>
|
|
||||||
<div style={{display:'flex', justifyContent:'space-between', alignItems:'center', marginBottom:4}}>
|
|
||||||
<strong style={{fontSize:14}}>{dayjs(e.date).format('dd, DD. MMMM YYYY')}</strong>
|
|
||||||
<div style={{fontSize:13, fontWeight:600, color:'#EF9F27'}}>
|
|
||||||
{Math.round(e.kcal || 0)} kcal
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{display:'flex', gap:12, fontSize:12, color:'var(--text2)'}}>
|
|
||||||
<span>🥩 Protein: <strong>{Math.round(e.protein_g || 0)}g</strong></span>
|
|
||||||
<span>🫙 Fett: <strong>{Math.round(e.fat_g || 0)}g</strong></span>
|
|
||||||
<span>🍞 Kohlenhydrate: <strong>{Math.round(e.carbs_g || 0)}g</strong></span>
|
|
||||||
</div>
|
|
||||||
{e.source && (
|
|
||||||
<div style={{fontSize:10, color:'var(--text3)', marginTop:2}}>
|
|
||||||
Quelle: {e.source}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab==='overview' && (
|
{tab==='overview' && (
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,9 @@ export const api = {
|
||||||
listNutrition: (l=365) => req(`/nutrition?limit=${l}`),
|
listNutrition: (l=365) => req(`/nutrition?limit=${l}`),
|
||||||
nutritionCorrelations: () => req('/nutrition/correlations'),
|
nutritionCorrelations: () => req('/nutrition/correlations'),
|
||||||
nutritionWeekly: (w=16) => req(`/nutrition/weekly?weeks=${w}`),
|
nutritionWeekly: (w=16) => req(`/nutrition/weekly?weeks=${w}`),
|
||||||
|
nutritionImportHistory: () => req('/nutrition/import-history'),
|
||||||
|
updateNutrition: (id,kcal,protein,fat,carbs) => req(`/nutrition/${id}?kcal=${kcal}&protein_g=${protein}&fat_g=${fat}&carbs_g=${carbs}`,{method:'PUT'}),
|
||||||
|
deleteNutrition: (id) => req(`/nutrition/${id}`,{method:'DELETE'}),
|
||||||
|
|
||||||
// Stats & AI
|
// Stats & AI
|
||||||
getStats: () => req('/stats'),
|
getStats: () => req('/stats'),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user