- Move all positioning logic from inline styles to CSS - New classes: .badge-container-right, .badge-button-layout - All badge styling now in UsageBadge.css (single source) - Easier to maintain and adjust globally - Mobile responsive adjustments in one place Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
468 lines
19 KiB
JavaScript
468 lines
19 KiB
JavaScript
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 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)
|
||
}
|
||
|
||
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>
|
||
</>}
|
||
</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>
|
||
|
||
{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>
|
||
)
|
||
}
|