mitai-jinkendo/frontend/src/pages/SettingsPage.jsx
Lars e4e2f23d7f
All checks were successful
Deploy Development / deploy (push) Successful in 48s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 17s
feat: Enhance dashboard layout and widget configuration
- Updated dashboard layout schema to introduce separate default layouts for product and lab dashboards.
- Added new functions for managing product and lab default layouts, improving user customization options.
- Updated app_dashboard version to 1.9.0 to reflect the introduction of product vs lab layout defaults and new API fields for dashboard configuration.
- Enhanced tests to validate new layout functionalities and ensure proper widget visibility based on user settings.
2026-04-08 07:41:16 +02:00

690 lines
26 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, Check, LogOut, Key, BarChart3, Target, LayoutGrid, LayoutDashboard } from 'lucide-react'
import { Link } from 'react-router-dom'
import { useProfile } from '../context/ProfileContext'
import { useAuth } from '../context/AuthContext'
import { Avatar } from './ProfileSelect'
import { api } from '../utils/api'
import FeatureUsageOverview from '../components/FeatureUsageOverview'
import UsageBadge from '../components/UsageBadge'
const COLORS = ['#1D9E75','#378ADD','#D85A30','#EF9F27','#7F77DD','#D4537E','#639922','#888780']
function dobInputValue(dob) {
if (!dob) return ''
const s = String(dob)
return s.length >= 10 ? s.slice(0, 10) : s
}
export default function SettingsPage() {
const { profiles, activeProfile, setActiveProfile, refreshProfiles } = useProfile()
const { logout, canExport, isAdmin } = useAuth()
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') }
}
const [form, setForm] = useState({
name: '',
email: '',
sex: 'm',
dob: '',
height: '',
goal_weight: '',
goal_bf_pct: '',
avatar_color: COLORS[0],
})
const setF = (k, v) => setForm((f) => ({ ...f, [k]: v }))
const [profileErr, setProfileErr] = 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)
}
}
useEffect(() => {
if (!activeProfile) return
const sexRaw = activeProfile.sex || 'm'
setForm({
name: activeProfile.name || '',
email: activeProfile.email || '',
sex: sexRaw === 'f' ? 'w' : sexRaw,
dob: dobInputValue(activeProfile.dob),
height: activeProfile.height != null ? String(activeProfile.height) : '',
goal_weight: activeProfile.goal_weight != null ? String(activeProfile.goal_weight) : '',
goal_bf_pct: activeProfile.goal_bf_pct != null ? String(activeProfile.goal_bf_pct) : '',
avatar_color: activeProfile.avatar_color || COLORS[0],
})
setProfileErr(null)
}, [activeProfile?.id])
const handleQualityFilterChange = async (level) => {
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 handleSaveMyProfile = async () => {
if (!activeProfile) return
const name = form.name.trim()
if (!name) {
setProfileErr('Bitte einen Namen eingeben.')
return
}
const h = parseFloat(form.height)
if (!form.height || Number.isNaN(h) || h < 100 || h > 250) {
setProfileErr('Bitte eine gültige Größe (100250 cm) eingeben.')
return
}
let goal_weight = null
if (form.goal_weight !== '') {
goal_weight = parseFloat(form.goal_weight)
if (Number.isNaN(goal_weight)) {
setProfileErr('Zielgewicht: bitte eine gültige Zahl eingeben oder leer lassen.')
return
}
}
let goal_bf_pct = null
if (form.goal_bf_pct !== '') {
goal_bf_pct = parseFloat(form.goal_bf_pct)
if (Number.isNaN(goal_bf_pct)) {
setProfileErr('Ziel-KF%: bitte eine gültige Zahl eingeben oder leer lassen.')
return
}
}
setProfileErr(null)
try {
const payload = {
name,
sex: form.sex,
dob: form.dob ? form.dob : null,
height: h,
avatar_color: form.avatar_color,
goal_weight,
goal_bf_pct,
email: form.email.trim() === '' ? null : form.email.trim(),
}
await api.updateActiveProfile(payload)
await refreshProfiles()
setSaved(true)
setTimeout(() => setSaved(false), 2000)
} catch (e) {
setProfileErr(e.message || 'Speichern fehlgeschlagen')
}
}
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>
{/* Aktives Profil (nur eigenes Profil; weitere Profile nur im Admin) */}
<div className="card section-gap">
<div className="card-title" style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<Avatar profile={{ ...form, name: form.name || '?' }} size={40} />
Mein Profil
</div>
<p style={{ fontSize: 13, color: 'var(--text2)', marginBottom: 14, lineHeight: 1.6 }}>
Hier bearbeitest du nur das <strong>aktive Profil</strong>. Zum Anlegen weiterer Profile oder zum
Verwalten anderer Nutzer nutzt du den Admin-Bereich (Zugriff nur als Administrator).
</p>
{isAdmin && (
<div
style={{
fontSize: 12,
color: 'var(--accent-dark)',
background: 'var(--accent-light)',
padding: '10px 12px',
borderRadius: 8,
marginBottom: 14,
lineHeight: 1.5,
}}
>
Admin:{' '}
<Link to="/admin/g/users" style={{ fontWeight: 600, color: 'var(--accent-dark)' }}>
Benutzerverwaltung
</Link>
</div>
)}
<div className="settings-page__field">
<label className="settings-page__field-label" htmlFor="settings-profile-name">
Name
</label>
<input
id="settings-profile-name"
type="text"
className="form-input"
value={form.name}
onChange={(e) => setF('name', e.target.value)}
autoComplete="name"
/>
</div>
<div className="settings-page__field">
<label className="settings-page__field-label" htmlFor="settings-profile-email">
E-Mail
</label>
<input
id="settings-profile-email"
type="email"
className="form-input"
placeholder="für Login, Recovery & Zusammenfassungen"
value={form.email}
onChange={(e) => setF('email', e.target.value)}
autoComplete="email"
/>
</div>
{activeProfile?.email && activeProfile?.email_verified === false && (
<div
style={{
fontSize: 12,
color: 'var(--warn-text)',
background: 'var(--warn-bg)',
padding: '8px 10px',
borderRadius: 8,
marginBottom: 12,
lineHeight: 1.5,
}}
>
Diese E-Mail ist noch nicht bestätigt. Nach einer Änderung der Adresse ist ggf. erneut eine
Bestätigung nötig.
</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, name: form.name || '?' }} size={36} />
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{COLORS.map((c) => (
<div
key={c}
role="button"
tabIndex={0}
onClick={() => setF('avatar_color', c)}
onKeyDown={(e) => e.key === 'Enter' && setF('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) => setF('sex', e.target.value)}>
<option value="m">Männlich</option>
<option value="w">Weiblich</option>
<option value="d">Divers</option>
</select>
</div>
<div className="form-row">
<label className="form-label">Geburtsdatum</label>
<input
type="date"
className="form-input"
style={{ width: 'auto', minWidth: 140 }}
value={form.dob}
onChange={(e) => setF('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) => setF('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: '14px 0 6px',
}}
>
Ziele (optional, Legacy)
</div>
<p style={{ fontSize: 12, color: 'var(--text3)', marginBottom: 10, lineHeight: 1.5 }}>
Diese Felder bleiben vorerst erhalten; strategische Ziele verwaltest du unter{' '}
<Link to="/goals">Analyse Ziele</Link>.
</p>
<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) => setF('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) => setF('goal_bf_pct', e.target.value)}
placeholder=""
/>
<span className="form-unit">%</span>
</div>
{profileErr && (
<div
style={{
fontSize: 13,
color: '#D85A30',
background: '#FCEBEB',
padding: '10px 12px',
borderRadius: 8,
marginBottom: 12,
lineHeight: 1.4,
}}
>
{profileErr}
</div>
)}
<button type="button" className="btn btn-primary btn-full" style={{ marginTop: 8 }} onClick={handleSaveMyProfile}>
<Save size={14} /> Profil speichern
</button>
</div>
<div className="card section-gap">
<div className="card-title" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<LayoutDashboard size={15} color="var(--accent)" /> Startseite (Übersicht)
</div>
<p style={{ fontSize: 13, color: 'var(--text2)', marginBottom: 12, lineHeight: 1.6 }}>
Kacheln wählen und sortieren. Es wird nur dein persönliches Layout gespeichert der App-Standard für neue
Nutzer wird dadurch nicht überschrieben.
</p>
<Link
to="/settings/dashboard-layout"
className="btn btn-primary btn-full"
style={{ textAlign: 'center', textDecoration: 'none', boxSizing: 'border-box' }}
>
Übersicht anpassen
</Link>
</div>
<div className="card section-gap">
<div className="card-title" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Target size={15} color="var(--accent)" /> Strategische Ziele
</div>
<p style={{ fontSize: 13, color: 'var(--text2)', marginBottom: 12, lineHeight: 1.6 }}>
Konkrete Ziele, Focus Areas und Fortschritt eigener Bereich{' '}
<strong>Ziele</strong> in der Navigation (nicht in der KI-Analyse).
</p>
<Link to="/goals" className="btn btn-secondary btn-full" style={{ textAlign: 'center', textDecoration: 'none', boxSizing: 'border-box' }}>
Zu den Zielen
</Link>
</div>
<div
className="card section-gap"
style={{ borderStyle: 'dashed', borderColor: 'var(--border2)', background: 'var(--surface2)' }}
>
<div className="card-title" style={{ fontSize: 14 }}>
Pilot: Visualisierungs-Module
</div>
<p style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 12, lineHeight: 1.5 }}>
Ziel-Übersicht-Pilot: Schnelleingabe, KPIs, Körper-Chart, Aktivität. Die reguläre Übersicht konfigurierst du
unter <strong>Übersicht anpassen</strong> oben.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<Link
to="/pilot/viz"
className="btn btn-secondary btn-full"
style={{ textAlign: 'center', textDecoration: 'none', boxSizing: 'border-box' }}
>
Pilot öffnen
</Link>
<Link
to="/app/dashboard-lab"
className="btn btn-secondary btn-full"
style={{
textAlign: 'center',
textDecoration: 'none',
boxSizing: 'border-box',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
}}
>
<LayoutGrid size={18} />
Dashboard-Lab (Layout API)
</Link>
</div>
</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>
{/* 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>
)
}