v9d Phase 2d: Vitals Module Refactoring (Baseline + Blood Pressure) #22

Merged
Lars merged 29 commits from develop into main 2026-03-23 16:27:03 +01:00
3 changed files with 304 additions and 0 deletions
Showing only changes of commit 1d252b5299 - Show all commits

View File

@ -29,6 +29,7 @@ import AdminCouponsPage from './pages/AdminCouponsPage'
import AdminUserRestrictionsPage from './pages/AdminUserRestrictionsPage'
import AdminTrainingTypesPage from './pages/AdminTrainingTypesPage'
import AdminActivityMappingsPage from './pages/AdminActivityMappingsPage'
import AdminTrainingProfiles from './pages/AdminTrainingProfiles'
import SubscriptionPage from './pages/SubscriptionPage'
import SleepPage from './pages/SleepPage'
import RestDaysPage from './pages/RestDaysPage'
@ -180,6 +181,7 @@ function AppShell() {
<Route path="/admin/user-restrictions" element={<AdminUserRestrictionsPage/>}/>
<Route path="/admin/training-types" element={<AdminTrainingTypesPage/>}/>
<Route path="/admin/activity-mappings" element={<AdminActivityMappingsPage/>}/>
<Route path="/admin/training-profiles" element={<AdminTrainingProfiles/>}/>
<Route path="/subscription" element={<SubscriptionPage/>}/>
</Routes>
</main>

View File

@ -444,6 +444,11 @@ export default function AdminPanel() {
🔗 Activity-Mappings (lernendes System)
</button>
</Link>
<Link to="/admin/training-profiles">
<button className="btn btn-secondary btn-full">
Training Type Profiles (#15)
</button>
</Link>
</div>
</div>
</div>

View File

@ -0,0 +1,297 @@
import { useState, useEffect } from 'react'
import { api } from '../utils/api'
import '../app.css'
export default function AdminTrainingProfiles() {
const [stats, setStats] = useState(null)
const [trainingTypes, setTrainingTypes] = useState([])
const [templates, setTemplates] = useState([])
const [selectedType, setSelectedType] = useState(null)
const [editingProfile, setEditingProfile] = useState(null)
const [profileJson, setProfileJson] = useState('')
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
useEffect(() => {
load()
}, [])
const load = async () => {
try {
setLoading(true)
const [typesData, statsData, templatesData] = await Promise.all([
api.get('/admin/training-types'),
api.get('/admin/training-types/profiles/stats'),
api.get('/admin/training-types/profiles/templates')
])
setTrainingTypes(typesData)
setStats(statsData)
setTemplates(templatesData)
} catch (e) {
setError(e.message)
} finally {
setLoading(false)
}
}
const openEditor = (type) => {
setSelectedType(type)
setEditingProfile(type.profile || null)
setProfileJson(JSON.stringify(type.profile || {}, null, 2))
setError('')
setSuccess('')
}
const closeEditor = () => {
setSelectedType(null)
setEditingProfile(null)
setProfileJson('')
}
const saveProfile = async () => {
try {
// Validate JSON
const profile = JSON.parse(profileJson)
// Update training type
await api.put(`/admin/training-types/${selectedType.id}`, { profile })
setSuccess(`Profil für "${selectedType.name_de}" gespeichert`)
closeEditor()
load()
} catch (e) {
setError(e.message || 'Ungültiges JSON')
}
}
const applyTemplate = async (typeId, templateKey) => {
if (!confirm(`Template "${templateKey}" auf diesen Trainingstyp anwenden?`)) return
try {
await api.post(`/admin/training-types/${typeId}/profile/apply-template`, {
template_key: templateKey
})
setSuccess('Template erfolgreich angewendet')
load()
} catch (e) {
setError(e.message)
}
}
const batchReEvaluate = async () => {
if (!confirm('Alle Aktivitäten neu evaluieren? Das kann einige Sekunden dauern.')) return
try {
const result = await api.post('/evaluation/batch')
setSuccess(
`Batch-Evaluation abgeschlossen: ${result.stats.evaluated} evaluiert, ` +
`${result.stats.skipped} übersprungen, ${result.stats.errors} Fehler`
)
} catch (e) {
setError(e.message)
}
}
if (loading) return <div className="spinner" />
return (
<div style={{ padding: '20px', maxWidth: '1200px', margin: '0 auto', paddingBottom: '100px' }}>
<h1>Training Type Profiles</h1>
<p style={{ color: 'var(--text2)', marginBottom: '24px' }}>
Konfiguriere Bewertungsprofile für Trainingstypen
</p>
{error && (
<div style={{
padding: '12px',
background: 'var(--danger)',
color: 'white',
borderRadius: '8px',
marginBottom: '16px'
}}>
{error}
</div>
)}
{success && (
<div style={{
padding: '12px',
background: 'var(--accent)',
color: 'white',
borderRadius: '8px',
marginBottom: '16px'
}}>
{success}
</div>
)}
{/* Statistics */}
{stats && (
<div className="card" style={{ marginBottom: '24px' }}>
<h3>Übersicht</h3>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px', marginTop: '16px' }}>
<div>
<div style={{ fontSize: '32px', fontWeight: '600', color: 'var(--accent)' }}>{stats.total}</div>
<div style={{ color: 'var(--text2)', fontSize: '14px' }}>Trainingstypen gesamt</div>
</div>
<div>
<div style={{ fontSize: '32px', fontWeight: '600', color: 'var(--accent)' }}>{stats.configured}</div>
<div style={{ color: 'var(--text2)', fontSize: '14px' }}>Profile konfiguriert</div>
</div>
<div>
<div style={{ fontSize: '32px', fontWeight: '600', color: 'var(--text3)' }}>{stats.unconfigured}</div>
<div style={{ color: 'var(--text2)', fontSize: '14px' }}>Noch keine Profile</div>
</div>
</div>
<button
onClick={batchReEvaluate}
className="btn btn-primary"
style={{ marginTop: '16px', width: '100%' }}
>
🔄 Alle Aktivitäten neu evaluieren
</button>
</div>
)}
{/* Training Types List */}
<div className="card">
<h3>Trainingstypen</h3>
<div style={{ marginTop: '16px' }}>
{trainingTypes.map(type => (
<div
key={type.id}
style={{
padding: '16px',
borderBottom: '1px solid var(--border)',
display: 'flex',
alignItems: 'center',
gap: '16px'
}}
>
<div style={{ fontSize: '24px' }}>{type.icon || '📊'}</div>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: '500', marginBottom: '4px' }}>
{type.name_de}
{type.profile && (
<span style={{
marginLeft: '8px',
padding: '2px 8px',
background: 'var(--accent)',
color: 'white',
borderRadius: '4px',
fontSize: '12px'
}}>
Profil
</span>
)}
</div>
<div style={{ fontSize: '14px', color: 'var(--text2)' }}>
{type.category} {type.subcategory && `· ${type.subcategory}`}
</div>
</div>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
{/* Template Buttons */}
{templates.map(template => (
<button
key={template.key}
onClick={() => applyTemplate(type.id, template.key)}
className="btn"
style={{ padding: '6px 12px', fontSize: '13px' }}
title={`Template "${template.name_de}" anwenden`}
>
{template.icon} {template.name_de}
</button>
))}
<button
onClick={() => openEditor(type)}
className="btn btn-primary"
style={{ padding: '6px 16px' }}
>
{type.profile ? '✏️ Bearbeiten' : ' Profil erstellen'}
</button>
</div>
</div>
))}
</div>
</div>
{/* Profile Editor Modal */}
{selectedType && (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0,0,0,0.7)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
padding: '20px'
}}>
<div style={{
background: 'var(--bg)',
borderRadius: '12px',
maxWidth: '900px',
width: '100%',
maxHeight: '90vh',
overflow: 'auto',
padding: '24px'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
<h2>{selectedType.icon} {selectedType.name_de} - Profil bearbeiten</h2>
<button onClick={closeEditor} className="btn" style={{ padding: '8px 16px' }}> Schließen</button>
</div>
<p style={{ color: 'var(--text2)', marginBottom: '16px' }}>
JSON-basierter Editor. Siehe Dokumentation für vollständige Struktur.
</p>
<textarea
value={profileJson}
onChange={(e) => setProfileJson(e.target.value)}
style={{
width: '100%',
minHeight: '400px',
padding: '12px',
fontFamily: 'monospace',
fontSize: '13px',
border: '1px solid var(--border)',
borderRadius: '8px',
background: 'var(--surface)',
color: 'var(--text1)',
resize: 'vertical'
}}
/>
<div style={{ display: 'flex', gap: '12px', marginTop: '16px' }}>
<button onClick={saveProfile} className="btn btn-primary" style={{ flex: 1 }}>
💾 Profil speichern
</button>
<button onClick={closeEditor} className="btn" style={{ flex: 1 }}>
Abbrechen
</button>
</div>
{selectedType.profile && (
<div style={{ marginTop: '16px', padding: '12px', background: 'var(--surface2)', borderRadius: '8px' }}>
<strong>Aktuelles Profil:</strong>
<div style={{ fontSize: '13px', color: 'var(--text2)', marginTop: '8px' }}>
Version: {selectedType.profile.version || 'n/a'}
<br />
Regel-Sets: {selectedType.profile.rule_sets ? Object.keys(selectedType.profile.rule_sets).join(', ') : 'keine'}
</div>
</div>
)}
</div>
</div>
)}
</div>
)
}