mitai-jinkendo/frontend/src/pages/Analysis.jsx
Lars ed057fe545
All checks were successful
Deploy Development / deploy (push) Successful in 35s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
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>
2026-03-21 07:42:50 +01:00

500 lines
24 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect } from 'react'
import { Brain, Pencil, Trash2, ChevronDown, ChevronUp, Check, X } from 'lucide-react'
import { api } from '../utils/api'
import { useAuth } from '../context/AuthContext'
import Markdown from '../utils/Markdown'
import UsageBadge from '../components/UsageBadge'
import dayjs from 'dayjs'
import 'dayjs/locale/de'
dayjs.locale('de')
const SLUG_LABELS = {
gesamt: '🔍 Gesamtanalyse',
koerper: '🫧 Körperkomposition',
ernaehrung: '🍽️ Ernährung',
aktivitaet: '🏋️ Aktivität',
gesundheit: '❤️ Gesundheitsindikatoren',
ziele: '🎯 Zielfortschritt',
pipeline: '🔬 Mehrstufige Gesamtanalyse',
pipeline_body: '🔬 Pipeline: Körper-Analyse (JSON)',
pipeline_nutrition: '🔬 Pipeline: Ernährungs-Analyse (JSON)',
pipeline_activity: '🔬 Pipeline: Aktivitäts-Analyse (JSON)',
pipeline_synthesis: '🔬 Pipeline: Synthese',
pipeline_goals: '🔬 Pipeline: Zielabgleich',
}
function InsightCard({ ins, onDelete, defaultOpen=false }) {
const [open, setOpen] = useState(defaultOpen)
return (
<div className="card section-gap" style={{borderLeft:`3px solid var(--accent)`}}>
<div style={{display:'flex',alignItems:'center',gap:8,marginBottom:open?12:0,cursor:'pointer'}}
onClick={()=>setOpen(o=>!o)}>
<div style={{flex:1}}>
<div style={{fontSize:13,fontWeight:600}}>
{SLUG_LABELS[ins.scope] || ins.scope}
</div>
<div style={{fontSize:11,color:'var(--text3)'}}>
{dayjs(ins.created).format('DD. MMMM YYYY, HH:mm')}
</div>
</div>
<button style={{background:'none',border:'none',cursor:'pointer',color:'var(--text3)',padding:4}}
onClick={e=>{e.stopPropagation();onDelete(ins.id)}}>
<Trash2 size={13}/>
</button>
{open ? <ChevronUp size={16} color="var(--text3)"/> : <ChevronDown size={16} color="var(--text3)"/>}
</div>
{open && <Markdown text={ins.content}/>}
</div>
)
}
function PromptEditor({ prompt, onSave, onCancel }) {
const [template, setTemplate] = useState(prompt.template)
const [name, setName] = useState(prompt.name)
const [desc, setDesc] = useState(prompt.description||'')
const VARS = ['{{name}}','{{geschlecht}}','{{height}}','{{goal_weight}}','{{goal_bf_pct}}',
'{{weight_trend}}','{{weight_aktuell}}','{{kf_aktuell}}','{{caliper_summary}}',
'{{circ_summary}}','{{nutrition_summary}}','{{nutrition_detail}}',
'{{protein_ziel_low}}','{{protein_ziel_high}}','{{activity_summary}}',
'{{activity_kcal_summary}}','{{activity_detail}}']
return (
<div className="card section-gap">
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:12}}>
<div className="card-title" style={{margin:0}}>Prompt bearbeiten</div>
<button style={{background:'none',border:'none',cursor:'pointer',color:'var(--text3)'}}
onClick={onCancel}><X size={16}/></button>
</div>
<div className="form-row">
<label className="form-label">Name</label>
<input type="text" className="form-input" value={name} onChange={e=>setName(e.target.value)}/>
<span className="form-unit"/>
</div>
<div className="form-row">
<label className="form-label">Beschreibung</label>
<input type="text" className="form-input" value={desc} onChange={e=>setDesc(e.target.value)}/>
<span className="form-unit"/>
</div>
<div style={{marginBottom:8}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:6}}>
Variablen (antippen zum Einfügen):
</div>
<div style={{display:'flex',flexWrap:'wrap',gap:4}}>
{VARS.map(v=>(
<button key={v} onClick={()=>setTemplate(t=>t+v)}
style={{fontSize:10,padding:'2px 7px',borderRadius:4,border:'1px solid var(--border2)',
background:'var(--surface2)',cursor:'pointer',fontFamily:'monospace',color:'var(--accent)'}}>
{v}
</button>
))}
</div>
</div>
<textarea value={template} onChange={e=>setTemplate(e.target.value)}
style={{width:'100%',minHeight:280,padding:10,fontFamily:'monospace',fontSize:12,
background:'var(--surface2)',border:'1.5px solid var(--border2)',borderRadius:8,
color:'var(--text1)',resize:'vertical',lineHeight:1.5,boxSizing:'border-box'}}/>
<div style={{display:'flex',gap:8,marginTop:8}}>
<button className="btn btn-primary" style={{flex:1}}
onClick={()=>onSave({name,description:desc,template})}>
<Check size={14}/> Speichern
</button>
<button className="btn btn-secondary" style={{flex:1}} onClick={onCancel}>Abbrechen</button>
</div>
</div>
)
}
export default function Analysis() {
const { canUseAI, isAdmin } = useAuth()
const [prompts, setPrompts] = useState([])
const [allInsights, setAllInsights] = useState([])
const [loading, setLoading] = useState(null)
const [error, setError] = useState(null)
const [editing, setEditing] = useState(null)
const [tab, setTab] = useState('run')
const [newResult, setNewResult] = useState(null)
const [pipelineLoading, setPipelineLoading] = useState(false)
const [aiUsage, setAiUsage] = useState(null) // Phase 3: Usage badge
const loadAll = async () => {
const [p, i] = await Promise.all([
api.listPrompts(),
api.listInsights()
])
setPrompts(Array.isArray(p)?p:[])
setAllInsights(Array.isArray(i)?i:[])
}
useEffect(()=>{
loadAll()
// Load feature usage for badges
api.getFeatureUsage().then(features => {
const aiFeature = features.find(f => f.feature_id === 'ai_calls')
setAiUsage(aiFeature)
}).catch(err => console.error('Failed to load usage:', err))
},[])
const runPipeline = async () => {
setPipelineLoading(true); setError(null); setNewResult(null)
try {
const result = await api.insightPipeline()
setNewResult(result)
await loadAll()
setTab('run')
} catch(e) {
setError('Pipeline-Fehler: ' + e.message)
} finally { setPipelineLoading(false) }
}
const runPrompt = async (slug) => {
setLoading(slug); setError(null); setNewResult(null)
try {
const result = await api.runInsight(slug)
setNewResult(result) // show immediately
await loadAll() // refresh lists
setTab('run') // stay on run tab to see result
} catch(e) {
setError('Fehler: ' + e.message)
} finally { setLoading(null) }
}
const savePrompt = async (promptId, data) => {
const token = localStorage.getItem('bodytrack_token')||''
await fetch(`/api/prompts/${promptId}`, {
method:'PUT',
headers:{'Content-Type':'application/json', 'X-Auth-Token': token},
body:JSON.stringify(data)
})
setEditing(null); await loadAll()
}
const deleteInsight = async (id) => {
if (!confirm('Analyse löschen?')) return
const pid = localStorage.getItem('bodytrack_active_profile')||''
await fetch(`/api/insights/${id}`, {
method:'DELETE', headers: pid ? {'X-Profile-Id':pid} : {}
})
if (newResult?.id === id) setNewResult(null)
await loadAll()
}
// Group insights by scope for history view
const grouped = {}
allInsights.forEach(ins => {
const key = ins.scope || 'sonstige'
grouped[key] = grouped[key] || []
grouped[key].push(ins)
})
const activePrompts = prompts.filter(p=>p.active && !p.slug.startsWith('pipeline_') && p.slug !== 'pipeline')
// Pipeline is available if the "pipeline" prompt is active
const pipelinePrompt = prompts.find(p=>p.slug==='pipeline')
const pipelineAvailable = pipelinePrompt?.active ?? true // Default to true if not found (backwards compatibility)
return (
<div>
<h1 className="page-title">KI-Analyse</h1>
<div className="tabs">
<button className={'tab'+(tab==='run'?' active':'')} onClick={()=>setTab('run')}>Analysen starten</button>
<button className={'tab'+(tab==='history'?' active':'')} onClick={()=>setTab('history')}>
Verlauf
{allInsights.length>0 && <span style={{marginLeft:4,fontSize:10,background:'var(--accent)',
color:'white',padding:'1px 5px',borderRadius:8}}>{allInsights.length}</span>}
</button>
{isAdmin && <button className={'tab'+(tab==='prompts'?' active':'')} onClick={()=>setTab('prompts')}>Prompts</button>}
</div>
{error && (
<div style={{padding:'10px 14px',background:'#FCEBEB',borderRadius:8,fontSize:13,
color:'#D85A30',marginBottom:12,lineHeight:1.5}}>
{error.includes('nicht aktiviert') || error.includes('Limit')
? <>🔒 <strong>KI-Zugang eingeschränkt</strong><br/>
Dein Profil hat keinen Zugang zu KI-Analysen oder das Tageslimit wurde erreicht.
Bitte den Admin kontaktieren.</>
: error}
</div>
)}
{/* ── Analysen starten ── */}
{tab==='run' && (
<div>
{/* Fresh result shown immediately */}
{newResult && (
<div style={{marginBottom:16}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--accent)',marginBottom:8}}>
Neue Analyse erstellt:
</div>
<InsightCard
ins={{...newResult, created: new Date().toISOString()}}
onDelete={deleteInsight}
defaultOpen={true}
/>
</div>
)}
{/* Pipeline button - only if all sub-prompts are active */}
{pipelineAvailable && (
<div className="card" style={{marginBottom:16,borderColor:'var(--accent)',borderWidth:2}}>
<div style={{display:'flex',alignItems:'flex-start',gap:12}}>
<div style={{flex:1}}>
<div className="badge-container-right" style={{fontWeight:700,fontSize:15,color:'var(--accent)'}}>
<span>🔬 Mehrstufige Gesamtanalyse</span>
{aiUsage && <UsageBadge {...aiUsage} />}
</div>
<div style={{fontSize:12,color:'var(--text2)',marginTop:3,lineHeight:1.5}}>
3 spezialisierte KI-Calls parallel (Körper + Ernährung + Aktivität),
dann Synthese + Zielabgleich. Detaillierteste Auswertung.
</div>
{allInsights.find(i=>i.scope==='pipeline') && (
<div style={{fontSize:11,color:'var(--text3)',marginTop:3}}>
Letzte Analyse: {dayjs(allInsights.find(i=>i.scope==='pipeline').created).format('DD.MM.YYYY, HH:mm')}
</div>
)}
</div>
<div
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
? <><div className="spinner" style={{width:13,height:13}}/> Läuft</>
: (aiUsage && !aiUsage.allowed) ? '🔒 Limit'
: <><Brain size={13}/> Starten</>}
</button>
</div>
{!canUseAI && <div style={{fontSize:11,color:'#D85A30',marginTop:4}}>🔒 KI nicht freigeschaltet</div>}
</div>
{pipelineLoading && (
<div style={{marginTop:10,padding:'8px 12px',background:'var(--accent-light)',
borderRadius:8,fontSize:12,color:'var(--accent-dark)'}}>
Stufe 1: 3 parallele Analyse-Calls dann Synthese dann Zielabgleich
</div>
)}
</div>
)}
{!canUseAI && (
<div style={{padding:'14px 16px',background:'#FCEBEB',borderRadius:10,
border:'1px solid #D85A3033',marginBottom:16}}>
<div style={{fontSize:14,fontWeight:600,color:'#D85A30',marginBottom:4}}>
🔒 KI-Analysen nicht freigeschaltet
</div>
<div style={{fontSize:13,color:'var(--text2)',lineHeight:1.5}}>
Dein Profil hat keinen Zugang zu KI-Analysen.
Bitte den Admin bitten, KI für dein Profil zu aktivieren
(Einstellungen Admin Profil bearbeiten).
</div>
</div>
)}
{canUseAI && <p style={{fontSize:13,color:'var(--text2)',marginBottom:14,lineHeight:1.6}}>
Oder wähle eine Einzelanalyse:
</p>}
{activePrompts.map(p => {
// Show latest existing insight for this prompt
const existing = allInsights.find(i=>i.scope===p.slug)
return (
<div key={p.id} className="card section-gap">
<div style={{display:'flex',alignItems:'flex-start',gap:12}}>
<div style={{flex:1}}>
<div className="badge-container-right" style={{fontWeight:600,fontSize:15}}>
<span>{SLUG_LABELS[p.slug]||p.name}</span>
{aiUsage && <UsageBadge {...aiUsage} />}
</div>
{p.description && <div style={{fontSize:12,color:'var(--text3)',marginTop:2}}>{p.description}</div>}
{existing && (
<div style={{fontSize:11,color:'var(--text3)',marginTop:3}}>
Letzte Auswertung: {dayjs(existing.created).format('DD.MM.YYYY, HH:mm')}
</div>
)}
</div>
<div
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
? <><div className="spinner" style={{width:13,height:13}}/> Läuft</>
: (aiUsage && !aiUsage.allowed) ? '🔒 Limit'
: <><Brain size={13}/> {existing?'Neu erstellen':'Starten'}</>}
</button>
</div>
</div>
{/* Show existing result collapsed */}
{existing && newResult?.id !== existing.id && (
<div style={{marginTop:8,borderTop:'1px solid var(--border)',paddingTop:8}}>
<InsightCard ins={existing} onDelete={deleteInsight} defaultOpen={false}/>
</div>
)}
</div>
)
})}
{activePrompts.length===0 && (
<div className="empty-state"><p>Keine aktiven Prompts. Aktiviere im Tab "Prompts".</p></div>
)}
</div>
)}
{/* ── Verlauf gruppiert ── */}
{tab==='history' && (
<div>
{allInsights.length===0
? <div className="empty-state"><h3>Noch keine Analysen</h3></div>
: Object.entries(grouped).map(([scope, ins]) => (
<div key={scope} style={{marginBottom:20}}>
<div style={{fontSize:13,fontWeight:700,color:'var(--text3)',
textTransform:'uppercase',letterSpacing:'0.05em',marginBottom:8}}>
{SLUG_LABELS[scope]||scope} ({ins.length})
</div>
{ins.map(i => <InsightCard key={i.id} ins={i} onDelete={deleteInsight}/>)}
</div>
))
}
</div>
)}
{/* ── Prompts ── */}
{tab==='prompts' && (
<div>
<p style={{fontSize:13,color:'var(--text2)',marginBottom:14,lineHeight:1.6}}>
Passe Prompts an. Variablen wie{' '}
<code style={{fontSize:11,background:'var(--surface2)',padding:'1px 4px',borderRadius:3}}>{'{{name}}'}</code>{' '}
werden automatisch mit deinen Daten befüllt.
</p>
{editing ? (
<PromptEditor prompt={editing}
onSave={(data)=>savePrompt(editing.id,data)}
onCancel={()=>setEditing(null)}/>
) : (() => {
const singlePrompts = prompts.filter(p=>!p.slug.startsWith('pipeline_'))
const pipelinePrompts = prompts.filter(p=>p.slug.startsWith('pipeline_'))
const jsonSlugs = ['pipeline_body','pipeline_nutrition','pipeline_activity']
return (
<>
{/* Single prompts */}
<div style={{fontSize:12,fontWeight:700,color:'var(--text3)',
textTransform:'uppercase',letterSpacing:'0.05em',marginBottom:8}}>
Einzelanalysen
</div>
{singlePrompts.map(p=>(
<div key={p.id} className="card section-gap" style={{opacity:p.active?1:0.6}}>
<div style={{display:'flex',alignItems:'center',gap:10}}>
<div style={{flex:1}}>
<div style={{fontWeight:600,fontSize:14,display:'flex',alignItems:'center',gap:8}}>
{SLUG_LABELS[p.slug]||p.name}
{!p.active && <span style={{fontSize:10,color:'#D85A30',
background:'#FCEBEB',padding:'2px 8px',borderRadius:4,fontWeight:600}}> Deaktiviert</span>}
</div>
{p.description && <div style={{fontSize:12,color:'var(--text3)',marginTop:1}}>{p.description}</div>}
</div>
<button className="btn btn-secondary" style={{padding:'5px 8px',fontSize:12}}
onClick={()=>{
const token = localStorage.getItem('bodytrack_token')||''
fetch(`/api/prompts/${p.id}`,{
method:'PUT',
headers:{'Content-Type':'application/json','X-Auth-Token':token},
body:JSON.stringify({active:!p.active})
}).then(loadAll)
}}>
{p.active?'Deaktivieren':'Aktivieren'}
</button>
<button className="btn btn-secondary" style={{padding:'5px 8px'}}
onClick={()=>setEditing(p)}><Pencil size={13}/></button>
</div>
<div style={{marginTop:8,padding:'8px 10px',background:'var(--surface2)',borderRadius:6,
fontSize:11,fontFamily:'monospace',color:'var(--text3)',maxHeight:60,overflow:'hidden',lineHeight:1.4}}>
{p.template.slice(0,200)}
</div>
</div>
))}
{/* Pipeline prompts */}
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',margin:'20px 0 8px'}}>
<div style={{fontSize:12,fontWeight:700,color:'var(--text3)',
textTransform:'uppercase',letterSpacing:'0.05em'}}>
Mehrstufige Pipeline
</div>
{(() => {
const pipelinePrompt = prompts.find(p=>p.slug==='pipeline')
return pipelinePrompt && (
<button className="btn btn-secondary" style={{padding:'5px 12px',fontSize:12}}
onClick={()=>{
const token = localStorage.getItem('bodytrack_token')||''
fetch(`/api/prompts/${pipelinePrompt.id}`,{
method:'PUT',
headers:{'Content-Type':'application/json','X-Auth-Token':token},
body:JSON.stringify({active:!pipelinePrompt.active})
}).then(loadAll)
}}>
{pipelinePrompt.active ? 'Gesamte Pipeline deaktivieren' : 'Gesamte Pipeline aktivieren'}
</button>
)
})()}
</div>
{(() => {
const pipelinePrompt = prompts.find(p=>p.slug==='pipeline')
const isPipelineActive = pipelinePrompt?.active ?? true
return (
<div style={{padding:'10px 12px',
background: isPipelineActive ? 'var(--warn-bg)' : '#FCEBEB',
borderRadius:8,fontSize:12,
color: isPipelineActive ? 'var(--warn-text)' : '#D85A30',
marginBottom:12,lineHeight:1.6}}>
{isPipelineActive ? (
<> <strong>Hinweis:</strong> Pipeline-Stage-1-Prompts müssen valides JSON zurückgeben.
Halte das JSON-Format im Prompt erhalten. Stage 2 + 3 können frei angepasst werden.</>
) : (
<> <strong>Pipeline deaktiviert:</strong> Die mehrstufige Gesamtanalyse ist aktuell nicht verfügbar.
Aktiviere sie mit dem Schalter oben, um sie auf der Analyse-Seite zu nutzen.</>
)}
</div>
)
})()}
{pipelinePrompts.map(p=>{
const isJson = jsonSlugs.includes(p.slug)
return (
<div key={p.id} className="card section-gap"
style={{borderLeft:`3px solid ${isJson?'var(--warn)':'var(--accent)'}`,opacity:p.active?1:0.6}}>
<div style={{display:'flex',alignItems:'center',gap:10}}>
<div style={{flex:1}}>
<div style={{fontWeight:600,fontSize:14,display:'flex',alignItems:'center',gap:8}}>
{p.name}
{isJson && <span style={{fontSize:10,background:'var(--warn-bg)',
color:'var(--warn-text)',padding:'1px 6px',borderRadius:4}}>JSON-Output</span>}
{!p.active && <span style={{fontSize:10,color:'#D85A30',
background:'#FCEBEB',padding:'2px 8px',borderRadius:4,fontWeight:600}}> Deaktiviert</span>}
</div>
{p.description && <div style={{fontSize:12,color:'var(--text3)',marginTop:1}}>{p.description}</div>}
</div>
<button className="btn btn-secondary" style={{padding:'5px 8px'}}
onClick={()=>setEditing(p)}><Pencil size={13}/></button>
</div>
<div style={{marginTop:8,padding:'8px 10px',background:'var(--surface2)',borderRadius:6,
fontSize:11,fontFamily:'monospace',color:'var(--text3)',maxHeight:80,overflow:'hidden',lineHeight:1.4}}>
{p.template.slice(0,300)}
</div>
</div>
)
})}
</>
)
})()}
</div>
)}
</div>
)
}