2026-03-21 06:43:10 +01:00
|
|
|
|
import { useState, useEffect } from 'react'
|
2026-04-06 19:45:06 +02:00
|
|
|
|
import { Save, Download, Upload, Check, LogOut, Key, BarChart3, Target, Gauge } from 'lucide-react'
|
2026-04-05 11:14:01 +02:00
|
|
|
|
import { Link } from 'react-router-dom'
|
2026-03-16 13:35:11 +01:00
|
|
|
|
import { useProfile } from '../context/ProfileContext'
|
|
|
|
|
|
import { useAuth } from '../context/AuthContext'
|
|
|
|
|
|
import { Avatar } from './ProfileSelect'
|
|
|
|
|
|
import { api } from '../utils/api'
|
2026-03-21 06:39:52 +01:00
|
|
|
|
import FeatureUsageOverview from '../components/FeatureUsageOverview'
|
2026-03-21 06:43:10 +01:00
|
|
|
|
import UsageBadge from '../components/UsageBadge'
|
2026-03-16 13:35:11 +01:00
|
|
|
|
|
|
|
|
|
|
const COLORS = ['#1D9E75','#378ADD','#D85A30','#EF9F27','#7F77DD','#D4537E','#639922','#888780']
|
|
|
|
|
|
|
2026-04-05 11:14:01 +02:00
|
|
|
|
function dobInputValue(dob) {
|
|
|
|
|
|
if (!dob) return ''
|
|
|
|
|
|
const s = String(dob)
|
|
|
|
|
|
return s.length >= 10 ? s.slice(0, 10) : s
|
2026-03-16 13:35:11 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default function SettingsPage() {
|
|
|
|
|
|
const { profiles, activeProfile, setActiveProfile, refreshProfiles } = useProfile()
|
2026-04-05 11:14:01 +02:00
|
|
|
|
const { logout, canExport, isAdmin } = useAuth()
|
2026-03-16 13:35:11 +01:00
|
|
|
|
const [pinOpen, setPinOpen] = useState(false)
|
|
|
|
|
|
const [newPin, setNewPin] = useState('')
|
|
|
|
|
|
const [pinMsg, setPinMsg] = useState(null)
|
2026-03-21 06:43:10 +01:00
|
|
|
|
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))
|
|
|
|
|
|
}, [])
|
2026-03-16 13:35:11 +01:00
|
|
|
|
|
|
|
|
|
|
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') }
|
|
|
|
|
|
}
|
2026-04-05 11:14:01 +02:00
|
|
|
|
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)
|
2026-03-16 13:35:11 +01:00
|
|
|
|
const [saved, setSaved] = useState(false)
|
2026-03-18 22:52:35 +01:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-16 13:35:11 +01:00
|
|
|
|
|
2026-04-05 11:14:01 +02:00
|
|
|
|
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])
|
|
|
|
|
|
|
2026-03-23 22:29:49 +01:00
|
|
|
|
const handleQualityFilterChange = async (level) => {
|
|
|
|
|
|
await api.updateActiveProfile({ quality_filter_level: level })
|
|
|
|
|
|
await refreshProfiles()
|
|
|
|
|
|
const updated = profiles.find(p => p.id === activeProfile?.id)
|
2026-04-05 11:14:01 +02:00
|
|
|
|
if (updated) setActiveProfile({ ...updated, quality_filter_level: level })
|
2026-03-23 22:29:49 +01:00
|
|
|
|
setSaved(true)
|
|
|
|
|
|
setTimeout(() => setSaved(false), 2000)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-05 11:14:01 +02:00
|
|
|
|
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 (100–250 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
|
2026-03-16 13:35:11 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-05 11:14:01 +02:00
|
|
|
|
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')
|
2026-03-16 13:35:11 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-26 10:05:11 +01:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-16 13:35:11 +01:00
|
|
|
|
return (
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h1 className="page-title">Einstellungen</h1>
|
|
|
|
|
|
|
2026-04-05 11:14:01 +02:00
|
|
|
|
{/* Aktives Profil (nur eigenes Profil; weitere Profile nur im Admin) */}
|
2026-03-16 13:35:11 +01:00
|
|
|
|
<div className="card section-gap">
|
2026-04-05 11:14:01 +02:00
|
|
|
|
<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>
|
|
|
|
|
|
)}
|
2026-03-16 13:35:11 +01:00
|
|
|
|
|
2026-04-05 11:19:35 +02:00
|
|
|
|
<div className="settings-page__field">
|
|
|
|
|
|
<label className="settings-page__field-label" htmlFor="settings-profile-name">
|
|
|
|
|
|
Name
|
|
|
|
|
|
</label>
|
2026-04-05 11:14:01 +02:00
|
|
|
|
<input
|
2026-04-05 11:19:35 +02:00
|
|
|
|
id="settings-profile-name"
|
2026-04-05 11:14:01 +02:00
|
|
|
|
type="text"
|
|
|
|
|
|
className="form-input"
|
|
|
|
|
|
value={form.name}
|
|
|
|
|
|
onChange={(e) => setF('name', e.target.value)}
|
2026-04-05 11:19:35 +02:00
|
|
|
|
autoComplete="name"
|
2026-04-05 11:14:01 +02:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-04-05 11:19:35 +02:00
|
|
|
|
<div className="settings-page__field">
|
|
|
|
|
|
<label className="settings-page__field-label" htmlFor="settings-profile-email">
|
|
|
|
|
|
E-Mail
|
|
|
|
|
|
</label>
|
2026-04-05 11:14:01 +02:00
|
|
|
|
<input
|
2026-04-05 11:19:35 +02:00
|
|
|
|
id="settings-profile-email"
|
2026-04-05 11:14:01 +02:00
|
|
|
|
type="email"
|
|
|
|
|
|
className="form-input"
|
|
|
|
|
|
placeholder="für Login, Recovery & Zusammenfassungen"
|
|
|
|
|
|
value={form.email}
|
|
|
|
|
|
onChange={(e) => setF('email', e.target.value)}
|
2026-04-05 11:19:35 +02:00
|
|
|
|
autoComplete="email"
|
2026-04-05 11:14:01 +02:00
|
|
|
|
/>
|
|
|
|
|
|
</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>
|
2026-03-16 13:35:11 +01:00
|
|
|
|
</div>
|
2026-04-05 11:14:01 +02:00
|
|
|
|
</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)}
|
2026-03-16 13:35:11 +01:00
|
|
|
|
/>
|
2026-04-05 11:14:01 +02:00
|
|
|
|
<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>
|
2026-03-16 13:35:11 +01:00
|
|
|
|
)}
|
2026-04-05 11:14:01 +02:00
|
|
|
|
|
|
|
|
|
|
<button type="button" className="btn btn-primary btn-full" style={{ marginTop: 8 }} onClick={handleSaveMyProfile}>
|
|
|
|
|
|
<Save size={14} /> Profil speichern
|
|
|
|
|
|
</button>
|
2026-03-16 13:35:11 +01:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-04-06 19:45:06 +02:00
|
|
|
|
<div className="card section-gap">
|
|
|
|
|
|
<div className="card-title" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
|
|
|
|
<Gauge size={15} color="var(--accent)" /> Referenzwerte
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p style={{ fontSize: 13, color: 'var(--text2)', marginBottom: 12, lineHeight: 1.6 }}>
|
|
|
|
|
|
Persönliche Kennwerte (z. B. HF-Schwellen, Trainingshäufigkeit) – historisch zum Profil gespeichert,
|
|
|
|
|
|
keine Admin-Konfiguration.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<Link
|
|
|
|
|
|
to="/settings/reference-values"
|
|
|
|
|
|
className="btn btn-secondary btn-full"
|
|
|
|
|
|
style={{ textAlign: 'center', textDecoration: 'none', boxSizing: 'border-box' }}
|
|
|
|
|
|
>
|
|
|
|
|
|
Referenzwerte verwalten
|
|
|
|
|
|
</Link>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-04-05 12:07:11 +02:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-03-16 13:35:11 +01:00
|
|
|
|
{/* 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>
|
|
|
|
|
|
|
2026-03-21 06:39:52 +01:00
|
|
|
|
{/* 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>
|
|
|
|
|
|
|
2026-03-16 13:35:11 +01:00
|
|
|
|
{/* Export */}
|
ROLLBACK: complete removal of broken feature enforcement system
Reverts all feature enforcement changes (commits 3745ebd, cbad50a, cd4d912, 8415509)
to restore original working functionality.
Issues caused by feature enforcement implementation:
- Export buttons disappeared and never reappeared
- KI analysis counter not incrementing
- New analyses not saving
- Pipeline appearing twice
- Many core features broken
Restored files to working state before enforcement implementation (commit 0210844):
- Backend: auth.py, insights.py, exportdata.py, importdata.py, nutrition.py, activity.py
- Frontend: Analysis.jsx, SettingsPage.jsx, api.js
- Removed: FeatureGate.jsx, useFeatureAccess.js
The original simple AI limit system (ai_enabled, ai_limit_day) is now active again.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 15:19:56 +01:00
|
|
|
|
<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 && <>
|
2026-03-21 06:54:45 +01:00
|
|
|
|
<button className="btn btn-primary btn-full"
|
2026-03-18 22:04:42 +01:00
|
|
|
|
onClick={()=>api.exportZip()}>
|
2026-03-21 06:54:45 +01:00
|
|
|
|
<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>
|
2026-03-21 06:50:12 +01:00
|
|
|
|
</div>
|
2026-03-16 13:35:11 +01:00
|
|
|
|
</button>
|
2026-03-21 06:54:45 +01:00
|
|
|
|
<button className="btn btn-secondary btn-full"
|
2026-03-18 22:04:42 +01:00
|
|
|
|
onClick={()=>api.exportJson()}>
|
2026-03-21 06:54:45 +01:00
|
|
|
|
<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>
|
2026-03-21 06:50:12 +01:00
|
|
|
|
</div>
|
2026-03-16 13:35:11 +01:00
|
|
|
|
</button>
|
2026-03-26 10:05:11 +01:00
|
|
|
|
<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>
|
ROLLBACK: complete removal of broken feature enforcement system
Reverts all feature enforcement changes (commits 3745ebd, cbad50a, cd4d912, 8415509)
to restore original working functionality.
Issues caused by feature enforcement implementation:
- Export buttons disappeared and never reappeared
- KI analysis counter not incrementing
- New analyses not saving
- Pipeline appearing twice
- Many core features broken
Restored files to working state before enforcement implementation (commit 0210844):
- Backend: auth.py, insights.py, exportdata.py, importdata.py, nutrition.py, activity.py
- Frontend: Analysis.jsx, SettingsPage.jsx, api.js
- Removed: FeatureGate.jsx, useFeatureAccess.js
The original simple AI limit system (ai_enabled, ai_limit_day) is now active again.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 15:19:56 +01:00
|
|
|
|
</>}
|
2026-03-16 13:35:11 +01:00
|
|
|
|
</div>
|
ROLLBACK: complete removal of broken feature enforcement system
Reverts all feature enforcement changes (commits 3745ebd, cbad50a, cd4d912, 8415509)
to restore original working functionality.
Issues caused by feature enforcement implementation:
- Export buttons disappeared and never reappeared
- KI analysis counter not incrementing
- New analyses not saving
- Pipeline appearing twice
- Many core features broken
Restored files to working state before enforcement implementation (commit 0210844):
- Backend: auth.py, insights.py, exportdata.py, importdata.py, nutrition.py, activity.py
- Frontend: Analysis.jsx, SettingsPage.jsx, api.js
- Removed: FeatureGate.jsx, useFeatureAccess.js
The original simple AI limit system (ai_enabled, ai_limit_day) is now active again.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 15:19:56 +01:00
|
|
|
|
<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>
|
2026-03-16 13:35:11 +01:00
|
|
|
|
|
2026-03-18 22:52:35 +01:00
|
|
|
|
{/* Import */}
|
ROLLBACK: complete removal of broken feature enforcement system
Reverts all feature enforcement changes (commits 3745ebd, cbad50a, cd4d912, 8415509)
to restore original working functionality.
Issues caused by feature enforcement implementation:
- Export buttons disappeared and never reappeared
- KI analysis counter not incrementing
- New analyses not saving
- Pipeline appearing twice
- Many core features broken
Restored files to working state before enforcement implementation (commit 0210844):
- Backend: auth.py, insights.py, exportdata.py, importdata.py, nutrition.py, activity.py
- Frontend: Analysis.jsx, SettingsPage.jsx, api.js
- Removed: FeatureGate.jsx, useFeatureAccess.js
The original simple AI limit system (ai_enabled, ai_limit_day) is now active again.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 15:19:56 +01:00
|
|
|
|
<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>
|
2026-03-18 22:52:35 +01:00
|
|
|
|
)}
|
ROLLBACK: complete removal of broken feature enforcement system
Reverts all feature enforcement changes (commits 3745ebd, cbad50a, cd4d912, 8415509)
to restore original working functionality.
Issues caused by feature enforcement implementation:
- Export buttons disappeared and never reappeared
- KI analysis counter not incrementing
- New analyses not saving
- Pipeline appearing twice
- Many core features broken
Restored files to working state before enforcement implementation (commit 0210844):
- Backend: auth.py, insights.py, exportdata.py, importdata.py, nutrition.py, activity.py
- Frontend: Analysis.jsx, SettingsPage.jsx, api.js
- Removed: FeatureGate.jsx, useFeatureAccess.js
The original simple AI limit system (ai_enabled, ai_limit_day) is now active again.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 15:19:56 +01:00
|
|
|
|
</>
|
|
|
|
|
|
)}
|
2026-03-18 22:52:35 +01:00
|
|
|
|
</div>
|
ROLLBACK: complete removal of broken feature enforcement system
Reverts all feature enforcement changes (commits 3745ebd, cbad50a, cd4d912, 8415509)
to restore original working functionality.
Issues caused by feature enforcement implementation:
- Export buttons disappeared and never reappeared
- KI analysis counter not incrementing
- New analyses not saving
- Pipeline appearing twice
- Many core features broken
Restored files to working state before enforcement implementation (commit 0210844):
- Backend: auth.py, insights.py, exportdata.py, importdata.py, nutrition.py, activity.py
- Frontend: Analysis.jsx, SettingsPage.jsx, api.js
- Removed: FeatureGate.jsx, useFeatureAccess.js
The original simple AI limit system (ai_enabled, ai_limit_day) is now active again.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 15:19:56 +01:00
|
|
|
|
<p style={{fontSize:11,color:'var(--text3)',marginTop:8}}>
|
|
|
|
|
|
Der Import erkennt automatisch das Format und importiert nur neue Einträge.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
2026-03-18 22:52:35 +01:00
|
|
|
|
|
2026-03-23 22:29:49 +01:00
|
|
|
|
{/* 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>
|
|
|
|
|
|
|
2026-03-16 13:35:11 +01:00
|
|
|
|
{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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|