feat: complete Phase 4 enforcement UI for all features (frontend)
Alle verbleibenden Screens mit proaktiver Limit-Anzeige:
- ActivityPage: Manuelle Einträge mit Badge + deaktiviertem Button
- Analysis: AI-Analysen (Pipeline + Einzelanalysen) mit Hover-Tooltip
- NutritionPage: Hat bereits Error-Handling (bulk import)
Konsistentes Pattern:
- Usage-Badge im Titel
- Button deaktiviert + Hover-Tooltip bei Limit
- "🔒 Limit erreicht" Button-Text
- Error-Handling für API-Fehler
- Usage reload nach erfolgreichem Speichern
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
4b8e6755dc
commit
ed057fe545
|
|
@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react'
|
||||||
import { Upload, Pencil, Trash2, Check, X, CheckCircle } from 'lucide-react'
|
import { Upload, Pencil, Trash2, Check, X, CheckCircle } from 'lucide-react'
|
||||||
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from 'recharts'
|
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from 'recharts'
|
||||||
import { api } from '../utils/api'
|
import { api } from '../utils/api'
|
||||||
|
import UsageBadge from '../components/UsageBadge'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import 'dayjs/locale/de'
|
import 'dayjs/locale/de'
|
||||||
dayjs.locale('de')
|
dayjs.locale('de')
|
||||||
|
|
@ -79,7 +80,7 @@ function ImportPanel({ onImported }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Manual Entry ──────────────────────────────────────────────────────────────
|
// ── Manual Entry ──────────────────────────────────────────────────────────────
|
||||||
function EntryForm({ form, setForm, onSave, onCancel, saveLabel='Speichern' }) {
|
function EntryForm({ form, setForm, onSave, onCancel, saveLabel='Speichern', saving=false, error=null, usage=null }) {
|
||||||
const set = (k,v) => setForm(f=>({...f,[k]:v}))
|
const set = (k,v) => setForm(f=>({...f,[k]:v}))
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -130,8 +131,25 @@ function EntryForm({ form, setForm, onSave, onCancel, saveLabel='Speichern' }) {
|
||||||
value={form.notes||''} onChange={e=>set('notes',e.target.value)}/>
|
value={form.notes||''} onChange={e=>set('notes',e.target.value)}/>
|
||||||
<span className="form-unit"/>
|
<span className="form-unit"/>
|
||||||
</div>
|
</div>
|
||||||
|
{error && (
|
||||||
|
<div style={{padding:'10px',background:'var(--danger-bg)',border:'1px solid var(--danger)',borderRadius:8,fontSize:13,color:'var(--danger)',marginBottom:8}}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div style={{display:'flex',gap:6,marginTop:8}}>
|
<div style={{display:'flex',gap:6,marginTop:8}}>
|
||||||
<button className="btn btn-primary" style={{flex:1}} onClick={onSave}>{saveLabel}</button>
|
<div
|
||||||
|
title={usage && !usage.allowed ? `Limit erreicht (${usage.used}/${usage.limit}). Kontaktiere den Admin oder warte bis zum nächsten Reset.` : ''}
|
||||||
|
style={{flex:1,display:'inline-block'}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
style={{width:'100%', cursor: (usage && !usage.allowed) ? 'not-allowed' : 'pointer'}}
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={saving || (usage && !usage.allowed)}
|
||||||
|
>
|
||||||
|
{(usage && !usage.allowed) ? '🔒 Limit erreicht' : saveLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{onCancel && <button className="btn btn-secondary" style={{flex:1}} onClick={onCancel}><X size={13}/> Abbrechen</button>}
|
{onCancel && <button className="btn btn-secondary" style={{flex:1}} onClick={onCancel}><X size={13}/> Abbrechen</button>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -145,15 +163,32 @@ export default function ActivityPage() {
|
||||||
const [tab, setTab] = useState('list')
|
const [tab, setTab] = useState('list')
|
||||||
const [form, setForm] = useState(empty())
|
const [form, setForm] = useState(empty())
|
||||||
const [editing, setEditing] = useState(null)
|
const [editing, setEditing] = useState(null)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
const [saved, setSaved] = useState(false)
|
const [saved, setSaved] = useState(false)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const [activityUsage, setActivityUsage] = useState(null) // Phase 4: Usage badge
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
const [e, s] = await Promise.all([api.listActivity(), api.activityStats()])
|
const [e, s] = await Promise.all([api.listActivity(), api.activityStats()])
|
||||||
setEntries(e); setStats(s)
|
setEntries(e); setStats(s)
|
||||||
}
|
}
|
||||||
useEffect(()=>{ load() },[])
|
|
||||||
|
const loadUsage = () => {
|
||||||
|
api.getFeatureUsage().then(features => {
|
||||||
|
const activityFeature = features.find(f => f.feature_id === 'activity_entries')
|
||||||
|
setActivityUsage(activityFeature)
|
||||||
|
}).catch(err => console.error('Failed to load usage:', err))
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(()=>{
|
||||||
|
load()
|
||||||
|
loadUsage()
|
||||||
|
},[])
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
const payload = {...form}
|
const payload = {...form}
|
||||||
if(payload.duration_min) payload.duration_min = parseFloat(payload.duration_min)
|
if(payload.duration_min) payload.duration_min = parseFloat(payload.duration_min)
|
||||||
if(payload.kcal_active) payload.kcal_active = parseFloat(payload.kcal_active)
|
if(payload.kcal_active) payload.kcal_active = parseFloat(payload.kcal_active)
|
||||||
|
|
@ -162,8 +197,17 @@ export default function ActivityPage() {
|
||||||
if(payload.rpe) payload.rpe = parseInt(payload.rpe)
|
if(payload.rpe) payload.rpe = parseInt(payload.rpe)
|
||||||
payload.source = 'manual'
|
payload.source = 'manual'
|
||||||
await api.createActivity(payload)
|
await api.createActivity(payload)
|
||||||
setSaved(true); await load()
|
setSaved(true)
|
||||||
|
await load()
|
||||||
|
await loadUsage() // Reload usage after save
|
||||||
setTimeout(()=>{ setSaved(false); setForm(empty()) }, 1500)
|
setTimeout(()=>{ setSaved(false); setForm(empty()) }, 1500)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Save failed:', err)
|
||||||
|
setError(err.message || 'Fehler beim Speichern')
|
||||||
|
setTimeout(()=>setError(null), 5000)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUpdate = async () => {
|
const handleUpdate = async () => {
|
||||||
|
|
@ -225,9 +269,13 @@ export default function ActivityPage() {
|
||||||
|
|
||||||
{tab==='add' && (
|
{tab==='add' && (
|
||||||
<div className="card section-gap">
|
<div className="card section-gap">
|
||||||
<div className="card-title">Training eintragen</div>
|
<div className="card-title badge-container-right">
|
||||||
|
<span>Training eintragen</span>
|
||||||
|
{activityUsage && <UsageBadge {...activityUsage} />}
|
||||||
|
</div>
|
||||||
<EntryForm form={form} setForm={setForm}
|
<EntryForm form={form} setForm={setForm}
|
||||||
onSave={handleSave} saveLabel={saved?'✓ Gespeichert!':'Speichern'}/>
|
onSave={handleSave} saveLabel={saved?'✓ Gespeichert!':'Speichern'}
|
||||||
|
saving={saving} error={error} usage={activityUsage}/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -254,12 +254,22 @@ export default function Analysis() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button className="btn btn-primary" style={{flexShrink:0,minWidth:100}}
|
<div
|
||||||
onClick={runPipeline} disabled={!!loading||pipelineLoading}>
|
title={aiUsage && !aiUsage.allowed ? `Limit erreicht (${aiUsage.used}/${aiUsage.limit}). Kontaktiere den Admin oder warte bis zum nächsten Reset.` : ''}
|
||||||
|
style={{display:'inline-block'}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
style={{flexShrink:0,minWidth:100, cursor: (aiUsage && !aiUsage.allowed) ? 'not-allowed' : 'pointer'}}
|
||||||
|
onClick={runPipeline}
|
||||||
|
disabled={!!loading||pipelineLoading||(aiUsage && !aiUsage.allowed)}
|
||||||
|
>
|
||||||
{pipelineLoading
|
{pipelineLoading
|
||||||
? <><div className="spinner" style={{width:13,height:13}}/> Läuft…</>
|
? <><div className="spinner" style={{width:13,height:13}}/> Läuft…</>
|
||||||
|
: (aiUsage && !aiUsage.allowed) ? '🔒 Limit'
|
||||||
: <><Brain size={13}/> Starten</>}
|
: <><Brain size={13}/> Starten</>}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
{!canUseAI && <div style={{fontSize:11,color:'#D85A30',marginTop:4}}>🔒 KI nicht freigeschaltet</div>}
|
{!canUseAI && <div style={{fontSize:11,color:'#D85A30',marginTop:4}}>🔒 KI nicht freigeschaltet</div>}
|
||||||
</div>
|
</div>
|
||||||
{pipelineLoading && (
|
{pipelineLoading && (
|
||||||
|
|
@ -306,13 +316,23 @@ export default function Analysis() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button className="btn btn-primary" style={{flexShrink:0,minWidth:90}}
|
<div
|
||||||
onClick={()=>runPrompt(p.slug)} disabled={!!loading||!canUseAI}>
|
title={aiUsage && !aiUsage.allowed ? `Limit erreicht (${aiUsage.used}/${aiUsage.limit}). Kontaktiere den Admin oder warte bis zum nächsten Reset.` : ''}
|
||||||
|
style={{display:'inline-block'}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
style={{flexShrink:0,minWidth:90, cursor: (aiUsage && !aiUsage.allowed) ? 'not-allowed' : 'pointer'}}
|
||||||
|
onClick={()=>runPrompt(p.slug)}
|
||||||
|
disabled={!!loading||!canUseAI||(aiUsage && !aiUsage.allowed)}
|
||||||
|
>
|
||||||
{loading===p.slug
|
{loading===p.slug
|
||||||
? <><div className="spinner" style={{width:13,height:13}}/> Läuft…</>
|
? <><div className="spinner" style={{width:13,height:13}}/> Läuft…</>
|
||||||
|
: (aiUsage && !aiUsage.allowed) ? '🔒 Limit'
|
||||||
: <><Brain size={13}/> {existing?'Neu erstellen':'Starten'}</>}
|
: <><Brain size={13}/> {existing?'Neu erstellen':'Starten'}</>}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{/* Show existing result collapsed */}
|
{/* Show existing result collapsed */}
|
||||||
{existing && newResult?.id !== existing.id && (
|
{existing && newResult?.id !== existing.id && (
|
||||||
<div style={{marginTop:8,borderTop:'1px solid var(--border)',paddingTop:8}}>
|
<div style={{marginTop:8,borderTop:'1px solid var(--border)',paddingTop:8}}>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user