mitai-jinkendo/frontend/src/pages/SettingsPage.jsx
Lars 555ff62b56
All checks were successful
Deploy Development / deploy (push) Successful in 45s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
feat: global placeholder export with values (Settings page)
Zentraler Export aller verfügbaren Platzhalter mit aktuellen Werten.

Backend:
- GET /api/prompts/placeholders/export-values
  - Returns all placeholders organized by category
  - Includes resolved values for current profile
  - Includes metadata (description, example)
  - Flat list + categorized structure

Frontend SettingsPage:
- Button "📊 Platzhalter exportieren"
- Downloads: placeholders-{profile}-{date}.json
- Shows all 38+ placeholders with current values
- Useful for:
  - Understanding available data
  - Debugging prompt templates
  - Verifying placeholder resolution

Frontend api.js:
- exportPlaceholderValues()

Export Format:
{
  "export_date": "2026-03-26T...",
  "profile_id": "...",
  "count": 38,
  "all_placeholders": { "name": "Lars", ... },
  "placeholders_by_category": {
    "Profil": [...],
    "Körper": [...],
    ...
  }
}

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 10:05:11 +01:00

547 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. 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 { Save, Download, Upload, Trash2, Plus, Check, Pencil, X, LogOut, Shield, Key, BarChart3 } from 'lucide-react'
import { useProfile } from '../context/ProfileContext'
import { useAuth } from '../context/AuthContext'
import { Avatar } from './ProfileSelect'
import { api } from '../utils/api'
import AdminPanel from './AdminPanel'
import FeatureUsageOverview from '../components/FeatureUsageOverview'
import UsageBadge from '../components/UsageBadge'
const COLORS = ['#1D9E75','#378ADD','#D85A30','#EF9F27','#7F77DD','#D4537E','#639922','#888780']
function ProfileForm({ profile, onSave, onCancel, title }) {
const [form, setForm] = useState({
name: profile?.name || '',
sex: profile?.sex || 'm',
dob: profile?.dob || '',
height: profile?.height || '',
goal_weight: profile?.goal_weight || '',
goal_bf_pct: profile?.goal_bf_pct || '',
avatar_color: profile?.avatar_color || COLORS[0],
})
const set = (k,v) => setForm(f=>({...f,[k]:v}))
return (
<div style={{background:'var(--surface2)',borderRadius:10,padding:14,marginTop:8,
border:'1.5px solid var(--accent)'}}>
{title && <div style={{fontWeight:600,fontSize:14,marginBottom:12,color:'var(--accent)'}}>{title}</div>}
<div className="form-row">
<label className="form-label">Name</label>
<input type="text" className="form-input" value={form.name}
onChange={e=>set('name',e.target.value)} autoFocus/>
<span className="form-unit"/>
</div>
<div style={{marginBottom:12}}>
<div style={{fontSize:12,color:'var(--text3)',marginBottom:8}}>Avatar-Farbe</div>
<div style={{display:'flex',gap:8,alignItems:'center'}}>
<Avatar profile={{...form}} size={36}/>
<div style={{display:'flex',gap:6,flexWrap:'wrap'}}>
{COLORS.map(c=>(
<div key={c} onClick={()=>set('avatar_color',c)}
style={{width:26,height:26,borderRadius:'50%',background:c,cursor:'pointer',
border:`3px solid ${form.avatar_color===c?'white':'transparent'}`,
boxShadow:form.avatar_color===c?`0 0 0 2px ${c}`:'none'}}/>
))}
</div>
</div>
</div>
<div className="form-row">
<label className="form-label">Geschlecht</label>
<select className="form-select" value={form.sex} onChange={e=>set('sex',e.target.value)}>
<option value="m">Männlich</option>
<option value="f">Weiblich</option>
</select>
</div>
<div className="form-row">
<label className="form-label">Geburtsdatum</label>
<input type="date" className="form-input" style={{width:140}} value={form.dob||''}
onChange={e=>set('dob',e.target.value)}/>
<span className="form-unit"/>
</div>
<div className="form-row">
<label className="form-label">Größe</label>
<input type="number" className="form-input" min={100} max={250} value={form.height||''}
onChange={e=>set('height',e.target.value)}/>
<span className="form-unit">cm</span>
</div>
<div style={{fontSize:11,fontWeight:600,color:'var(--text3)',textTransform:'uppercase',
letterSpacing:'0.04em',margin:'10px 0 6px'}}>Ziele (optional)</div>
<div className="form-row">
<label className="form-label">Zielgewicht</label>
<input type="number" className="form-input" min={30} max={300} step={0.1}
value={form.goal_weight||''} onChange={e=>set('goal_weight',e.target.value)} placeholder=""/>
<span className="form-unit">kg</span>
</div>
<div className="form-row">
<label className="form-label">Ziel-KF%</label>
<input type="number" className="form-input" min={3} max={50} step={0.1}
value={form.goal_bf_pct||''} onChange={e=>set('goal_bf_pct',e.target.value)} placeholder=""/>
<span className="form-unit">%</span>
</div>
<div style={{display:'flex',gap:8,marginTop:12}}>
<button className="btn btn-primary" style={{flex:1}} onClick={()=>onSave(form)}>
<Save size={13}/> Speichern
</button>
<button className="btn btn-secondary" style={{flex:1}} onClick={onCancel}>
<X size={13}/> Abbrechen
</button>
</div>
</div>
)
}
export default function SettingsPage() {
const { profiles, activeProfile, setActiveProfile, refreshProfiles } = useProfile()
const { logout, isAdmin, canExport } = useAuth()
const [adminOpen, setAdminOpen] = useState(false)
const [pinOpen, setPinOpen] = useState(false)
const [newPin, setNewPin] = useState('')
const [pinMsg, setPinMsg] = useState(null)
const [exportUsage, setExportUsage] = useState(null) // Phase 3: Usage badge
// Load feature usage for export badges
useEffect(() => {
api.getFeatureUsage().then(features => {
const exportFeature = features.find(f => f.feature_id === 'data_export')
setExportUsage(exportFeature)
}).catch(err => console.error('Failed to load usage:', err))
}, [])
const handleLogout = async () => {
if (!confirm('Ausloggen?')) return
await logout()
}
const handlePinChange = async () => {
if (newPin.length < 4) return setPinMsg('Mind. 4 Zeichen')
try {
const token = localStorage.getItem('bodytrack_token')||''
const pid = localStorage.getItem('bodytrack_active_profile')||''
const r = await fetch('/api/auth/pin', {
method:'PUT',
headers:{'Content-Type':'application/json','X-Auth-Token':token,'X-Profile-Id':pid},
body: JSON.stringify({pin: newPin})
})
if (!r.ok) throw new Error('Fehler')
setNewPin(''); setPinMsg('✓ PIN geändert')
setTimeout(()=>setPinMsg(null), 2000)
} catch(e) { setPinMsg('Fehler beim Speichern') }
}
// editingId: string ID of profile being edited, or 'new' for new profile, or null
const [editingId, setEditingId] = useState(null)
const [saved, setSaved] = useState(false)
const [importing, setImporting] = useState(false)
const [importMsg, setImportMsg] = useState(null)
const handleImport = async (e) => {
const file = e.target.files?.[0]
if (!file) return
if (!confirm(`Backup "${file.name}" importieren? Vorhandene Einträge werden nicht überschrieben.`)) {
e.target.value = '' // Reset file input
return
}
setImporting(true)
setImportMsg(null)
try {
const formData = new FormData()
formData.append('file', file)
const token = localStorage.getItem('bodytrack_token')||''
const pid = localStorage.getItem('bodytrack_active_profile')||''
const res = await fetch('/api/import/zip', {
method: 'POST',
headers: {
'X-Auth-Token': token,
'X-Profile-Id': pid
},
body: formData
})
const data = await res.json()
if (!res.ok) {
throw new Error(data.detail || 'Import fehlgeschlagen')
}
// Show success message with stats
const stats = data.stats
const lines = []
if (stats.weight > 0) lines.push(`${stats.weight} Gewicht`)
if (stats.circumferences > 0) lines.push(`${stats.circumferences} Umfänge`)
if (stats.caliper > 0) lines.push(`${stats.caliper} Caliper`)
if (stats.nutrition > 0) lines.push(`${stats.nutrition} Ernährung`)
if (stats.activity > 0) lines.push(`${stats.activity} Aktivität`)
if (stats.photos > 0) lines.push(`${stats.photos} Fotos`)
if (stats.insights > 0) lines.push(`${stats.insights} KI-Analysen`)
setImportMsg({
type: 'success',
text: `✓ Import erfolgreich: ${lines.join(', ')}`
})
// Refresh data (in case new entries were added)
await refreshProfiles()
} catch (err) {
setImportMsg({
type: 'error',
text: `${err.message}`
})
} finally {
setImporting(false)
e.target.value = '' // Reset file input
setTimeout(() => setImportMsg(null), 5000)
}
}
const handleQualityFilterChange = async (level) => {
// Issue #31: Update global quality filter
await api.updateActiveProfile({ quality_filter_level: level })
await refreshProfiles()
const updated = profiles.find(p => p.id === activeProfile?.id)
if (updated) setActiveProfile({...updated, quality_filter_level: level})
setSaved(true)
setTimeout(() => setSaved(false), 2000)
}
const handleSave = async (form, profileId) => {
const data = {}
if (form.name) data.name = form.name
if (form.sex) data.sex = form.sex
if (form.dob) data.dob = form.dob
if (form.height) data.height = parseFloat(form.height)
if (form.avatar_color) data.avatar_color = form.avatar_color
if (form.goal_weight) data.goal_weight = parseFloat(form.goal_weight)
if (form.goal_bf_pct) data.goal_bf_pct = parseFloat(form.goal_bf_pct)
if (profileId === 'new') {
const p = await api.createProfile({ ...data, name: form.name || 'Neues Profil' })
await refreshProfiles()
// Don't auto-switch just close the form
} else {
await api.updateProfile(profileId, data)
await refreshProfiles()
// If editing active profile, update it
if (profileId === activeProfile?.id) {
const updated = profiles.find(p => p.id === profileId)
if (updated) setActiveProfile({...updated, ...data})
}
}
setEditingId(null)
setSaved(true)
setTimeout(() => setSaved(false), 2000)
}
const handleDelete = async (id) => {
if (!confirm('Profil und ALLE zugehörigen Daten unwiderruflich löschen?')) return
await api.deleteProfile(id)
await refreshProfiles()
if (activeProfile?.id === id) {
const remaining = profiles.filter(p => p.id !== id)
if (remaining.length) setActiveProfile(remaining[0])
}
setEditingId(null)
}
const handleExportPlaceholders = async () => {
try {
const data = await api.exportPlaceholderValues()
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `placeholders-${activeProfile?.name || 'profile'}-${new Date().toISOString().split('T')[0]}.json`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
} catch (e) {
alert('Fehler beim Export: ' + e.message)
}
}
return (
<div>
<h1 className="page-title">Einstellungen</h1>
{/* Profile list */}
<div className="card section-gap">
<div className="card-title">Profile ({profiles.length})</div>
{profiles.map(p => (
<div key={p.id}>
<div style={{display:'flex',alignItems:'center',gap:10,padding:'10px 0',
borderBottom:'1px solid var(--border)'}}>
<Avatar profile={p} size={40}/>
<div style={{flex:1}}>
<div style={{fontSize:14,fontWeight:600}}>{p.name}</div>
<div style={{fontSize:11,color:'var(--text3)'}}>
{p.sex==='m'?'Männlich':'Weiblich'}
{p.height ? ` · ${p.height} cm` : ''}
{p.goal_weight ? ` · Ziel: ${p.goal_weight} kg` : ''}
</div>
</div>
<div style={{display:'flex',gap:6,alignItems:'center'}}>
{activeProfile?.id === p.id
? <span style={{fontSize:11,color:'var(--accent)',fontWeight:600,padding:'3px 8px',
background:'var(--accent-light)',borderRadius:6}}>Aktiv</span>
: <button className="btn btn-secondary" style={{padding:'4px 10px',fontSize:12}}
onClick={handleLogout}>
Nutzer wechseln
</button>
}
<button className="btn btn-secondary" style={{padding:'4px 8px'}}
onClick={()=>setEditingId(editingId===p.id ? null : p.id)}>
<Pencil size={12}/>
</button>
{profiles.length > 1 && (
<button className="btn btn-danger" style={{padding:'4px 8px'}}
onClick={()=>handleDelete(p.id)}>
<Trash2 size={12}/>
</button>
)}
</div>
</div>
{/* Edit form only shown for THIS profile */}
{editingId === p.id && (
<ProfileForm
profile={p}
onSave={(form) => handleSave(form, p.id)}
onCancel={() => setEditingId(null)}
/>
)}
</div>
))}
{/* New profile */}
{editingId === 'new' ? (
<ProfileForm
title="Neues Profil"
onSave={(form) => handleSave(form, 'new')}
onCancel={() => setEditingId(null)}
/>
) : (
<button className="btn btn-secondary btn-full" style={{marginTop:12}}
onClick={() => setEditingId('new')}>
<Plus size={14}/> Neues Profil anlegen
</button>
)}
</div>
{/* Auth actions */}
<div className="card section-gap">
<div className="card-title">🔐 Konto</div>
<div style={{display:'flex',gap:8,flexDirection:'column'}}>
<button className="btn btn-secondary btn-full"
onClick={()=>setPinOpen(o=>!o)}
style={{display:'flex',alignItems:'center',gap:8,justifyContent:'center'}}>
<Key size={14}/> PIN / Passwort ändern
</button>
{pinOpen && (
<div style={{padding:12,background:'var(--surface2)',borderRadius:8}}>
<div style={{display:'flex',gap:8}}>
<input type="password" className="form-input" placeholder="Neue PIN/Passwort"
value={newPin} onChange={e=>setNewPin(e.target.value)} style={{flex:1}}/>
<button className="btn btn-primary" onClick={handlePinChange}>Setzen</button>
</div>
{pinMsg && <div style={{fontSize:12,color:pinMsg.startsWith('✓')?'var(--accent)':'#D85A30',marginTop:6}}>{pinMsg}</div>}
</div>
)}
<button className="btn btn-secondary btn-full"
onClick={handleLogout}
style={{display:'flex',alignItems:'center',gap:8,justifyContent:'center',color:'var(--warn)'}}>
<LogOut size={14}/> Ausloggen
</button>
</div>
</div>
{/* Feature Usage Overview (Phase 3) */}
<div className="card section-gap">
<div className="card-title" style={{display:'flex',alignItems:'center',gap:6}}>
<BarChart3 size={15} color="var(--accent)"/> Kontingente
</div>
<p style={{fontSize:13,color:'var(--text2)',marginBottom:12,lineHeight:1.6}}>
Übersicht über deine Feature-Nutzung und verfügbare Kontingente.
</p>
<FeatureUsageOverview />
</div>
{/* Admin Panel */}
{isAdmin && (
<div className="card section-gap">
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between'}}>
<div className="card-title" style={{margin:0,display:'flex',alignItems:'center',gap:6}}>
<Shield size={15} color="var(--accent)"/> Admin
</div>
<button className="btn btn-secondary" style={{fontSize:12}}
onClick={()=>setAdminOpen(o=>!o)}>
{adminOpen?'Schließen':'Öffnen'}
</button>
</div>
{adminOpen && <div style={{marginTop:12}}><AdminPanel/></div>}
</div>
)}
{/* Export */}
<div className="card section-gap">
<div className="card-title">Daten exportieren</div>
<p style={{fontSize:13,color:'var(--text2)',marginBottom:12,lineHeight:1.6}}>
Exportiert alle Daten von <strong>{activeProfile?.name}</strong>:
Gewicht, Umfänge, Caliper, Ernährung, Aktivität und KI-Auswertungen.
</p>
<div style={{display:'flex',flexDirection:'column',gap:8}}>
{!canExport && (
<div style={{padding:'10px 12px',background:'#FCEBEB',borderRadius:8,
fontSize:13,color:'#D85A30',marginBottom:8}}>
🔒 Export ist für dein Profil nicht freigeschaltet. Bitte den Admin kontaktieren.
</div>
)}
{canExport && <>
<button className="btn btn-primary btn-full"
onClick={()=>api.exportZip()}>
<div className="badge-button-layout">
<div className="badge-button-header">
<span><Download size={14}/> ZIP exportieren</span>
{exportUsage && <UsageBadge {...exportUsage} />}
</div>
<span className="badge-button-description">je eine CSV pro Kategorie</span>
</div>
</button>
<button className="btn btn-secondary btn-full"
onClick={()=>api.exportJson()}>
<div className="badge-button-layout">
<div className="badge-button-header">
<span><Download size={14}/> JSON exportieren</span>
{exportUsage && <UsageBadge {...exportUsage} />}
</div>
<span className="badge-button-description">maschinenlesbar, alles in einer Datei</span>
</div>
</button>
<button className="btn btn-full"
onClick={handleExportPlaceholders}
style={{ background: 'var(--surface2)', border: '1px solid var(--border)' }}>
<div className="badge-button-layout">
<div className="badge-button-header">
<span><BarChart3 size={14}/> Platzhalter exportieren</span>
</div>
<span className="badge-button-description">alle verfügbaren Platzhalter mit aktuellen Werten</span>
</div>
</button>
</>}
</div>
<p style={{fontSize:11,color:'var(--text3)',marginTop:8}}>
Der ZIP-Export enthält separate Dateien für Excel und eine lesbare KI-Auswertungsdatei.
</p>
</div>
{/* Import */}
<div className="card section-gap">
<div className="card-title">Backup importieren</div>
<p style={{fontSize:13,color:'var(--text2)',marginBottom:12,lineHeight:1.6}}>
Importiere einen ZIP-Export zurück in <strong>{activeProfile?.name}</strong>.
Vorhandene Einträge werden nicht überschrieben.
</p>
<div style={{display:'flex',flexDirection:'column',gap:8}}>
{!canExport && (
<div style={{padding:'10px 12px',background:'#FCEBEB',borderRadius:8,
fontSize:13,color:'#D85A30',marginBottom:8}}>
🔒 Import ist für dein Profil nicht freigeschaltet. Bitte den Admin kontaktieren.
</div>
)}
{canExport && (
<>
<label className="btn btn-primary btn-full"
style={{cursor:importing?'wait':'pointer',opacity:importing?0.6:1}}>
<input type="file" accept=".zip" onChange={handleImport}
disabled={importing}
style={{display:'none'}}/>
{importing ? (
<>Importiere...</>
) : (
<>
<Upload size={14}/> ZIP-Backup importieren
</>
)}
</label>
{importMsg && (
<div style={{
padding:'10px 12px',
background: importMsg.type === 'success' ? '#E1F5EE' : '#FCEBEB',
borderRadius:8,
fontSize:12,
color: importMsg.type === 'success' ? 'var(--accent)' : '#D85A30',
lineHeight:1.4
}}>
{importMsg.text}
</div>
)}
</>
)}
</div>
<p style={{fontSize:11,color:'var(--text3)',marginTop:8}}>
Der Import erkennt automatisch das Format und importiert nur neue Einträge.
</p>
</div>
{/* Issue #31: Global Quality Filter */}
<div className="card section-gap">
<div className="card-title">Datenqualität</div>
<p style={{fontSize:13,color:'var(--text2)',marginBottom:12,lineHeight:1.6}}>
Qualitätsfilter wirkt auf <strong>alle Ansichten</strong>: Dashboard, Charts, Statistiken und KI-Analysen.
</p>
<div style={{marginBottom:8}}>
<label style={{fontSize:12,fontWeight:600,color:'var(--text3)',display:'block',marginBottom:6}}>
QUALITÄTSFILTER (GLOBAL)
</label>
<select
className="form-select"
value={activeProfile?.quality_filter_level || 'all'}
onChange={e => handleQualityFilterChange(e.target.value)}
style={{width:'100%',fontSize:13}}>
<option value="all">📊 Alle Activities</option>
<option value="quality"> Hochwertig (excellent, good, acceptable)</option>
<option value="very_good"> Sehr gut (excellent, good)</option>
<option value="excellent"> Exzellent (nur excellent)</option>
</select>
</div>
<div style={{padding:'10px 12px',background:'var(--surface2)',borderRadius:8,fontSize:11,
color:'var(--text3)',lineHeight:1.5}}>
<div style={{fontWeight:600,marginBottom:4,color:'var(--text2)'}}>Aktuell: {
{
'all': '📊 Alle Activities (kein Filter)',
'quality': '✓ Hochwertig (excellent, good, acceptable)',
'very_good': '✓✓ Sehr gut (excellent, good)',
'excellent': '⭐ Exzellent (nur excellent)'
}[activeProfile?.quality_filter_level || 'all']
}</div>
Diese Einstellung wirkt auf:
<ul style={{margin:'6px 0 0 20px',padding:0}}>
<li>Dashboard Charts</li>
<li>Verlauf & Auswertungen</li>
<li>Trainingstyp-Verteilung</li>
<li>KI-Analysen & Pipeline</li>
<li>Alle Statistiken</li>
</ul>
</div>
</div>
{saved && (
<div style={{position:'fixed',bottom:80,left:'50%',transform:'translateX(-50%)',
background:'var(--accent)',color:'white',padding:'8px 20px',borderRadius:20,
fontSize:13,fontWeight:600,display:'flex',alignItems:'center',gap:6,zIndex:100}}>
<Check size={14}/> Gespeichert
</div>
)}
</div>
)
}