v9d Phase 2d: Vitals Module Refactoring (Baseline + Blood Pressure) #22
|
|
@ -29,6 +29,7 @@ import AdminCouponsPage from './pages/AdminCouponsPage'
|
||||||
import AdminUserRestrictionsPage from './pages/AdminUserRestrictionsPage'
|
import AdminUserRestrictionsPage from './pages/AdminUserRestrictionsPage'
|
||||||
import AdminTrainingTypesPage from './pages/AdminTrainingTypesPage'
|
import AdminTrainingTypesPage from './pages/AdminTrainingTypesPage'
|
||||||
import AdminActivityMappingsPage from './pages/AdminActivityMappingsPage'
|
import AdminActivityMappingsPage from './pages/AdminActivityMappingsPage'
|
||||||
|
import AdminTrainingProfiles from './pages/AdminTrainingProfiles'
|
||||||
import SubscriptionPage from './pages/SubscriptionPage'
|
import SubscriptionPage from './pages/SubscriptionPage'
|
||||||
import SleepPage from './pages/SleepPage'
|
import SleepPage from './pages/SleepPage'
|
||||||
import RestDaysPage from './pages/RestDaysPage'
|
import RestDaysPage from './pages/RestDaysPage'
|
||||||
|
|
@ -180,6 +181,7 @@ function AppShell() {
|
||||||
<Route path="/admin/user-restrictions" element={<AdminUserRestrictionsPage/>}/>
|
<Route path="/admin/user-restrictions" element={<AdminUserRestrictionsPage/>}/>
|
||||||
<Route path="/admin/training-types" element={<AdminTrainingTypesPage/>}/>
|
<Route path="/admin/training-types" element={<AdminTrainingTypesPage/>}/>
|
||||||
<Route path="/admin/activity-mappings" element={<AdminActivityMappingsPage/>}/>
|
<Route path="/admin/activity-mappings" element={<AdminActivityMappingsPage/>}/>
|
||||||
|
<Route path="/admin/training-profiles" element={<AdminTrainingProfiles/>}/>
|
||||||
<Route path="/subscription" element={<SubscriptionPage/>}/>
|
<Route path="/subscription" element={<SubscriptionPage/>}/>
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -444,6 +444,11 @@ export default function AdminPanel() {
|
||||||
🔗 Activity-Mappings (lernendes System)
|
🔗 Activity-Mappings (lernendes System)
|
||||||
</button>
|
</button>
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link to="/admin/training-profiles">
|
||||||
|
<button className="btn btn-secondary btn-full">
|
||||||
|
⭐ Training Type Profiles (#15)
|
||||||
|
</button>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
297
frontend/src/pages/AdminTrainingProfiles.jsx
Normal file
297
frontend/src/pages/AdminTrainingProfiles.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user