feat: Implement Activity Attribute Profiles and session metrics editing
All checks were successful
Deploy Development / deploy (push) Successful in 55s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 1s
Build Test / build-frontend (push) Successful in 16s

- Added new Admin UI for managing Activity Attribute Profiles.
- Enhanced ActivityPage to support dynamic loading and editing of session metrics.
- Updated API utility functions to handle new endpoints for training parameters and metrics.
- Improved form handling for session metrics, including validation and error management.
- Updated documentation to reflect new features and changes in session metrics handling.
This commit is contained in:
Lars 2026-04-14 11:56:16 +02:00
parent 48508c164e
commit cf7379b2f6
7 changed files with 803 additions and 8 deletions

View File

@ -81,8 +81,8 @@ Router: `backend/routers/admin_training_parameters.py`, `backend/routers/admin_a
## 5. Agent-Checkliste (nächste Iterationen)
- [ ] Admin-UI: Matrix Kategorie / Trainingstyp ↔ Parameter.
- [ ] `/activity` Frontend: dynamische Felder aus `GET /api/activity/{id}`.
- [x] Admin-UI: `frontend/src/pages/AdminActivityAttributeProfilesPage.jsx`, Route `/admin/activity-attribute-profiles`, Admin-Nav-Gruppe „Trainingstypen“.
- [x] `/activity` Frontend: Bearbeiten lädt `GET /api/activity/{id}`, dynamische Felder + `PUT /api/activity/{id}/metrics`.
- [ ] Universal CSV: Mapping-Spalten → `training_parameters.key` + Schreiben in EAV (Executor).
- [ ] Optional: Backfill `activity_log.*``activity_session_metrics` nach `source_field`.
- [ ] Dedupe Polar/Apple: nach stabilen `started_at`/`ended_at` + Policy (eigenes Issue).

View File

@ -121,6 +121,7 @@ frontend/src/
- **Agent-Guide:** `.claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md` (Prod: nur additive Migration **054**; Layer1 `data_layer/activity_session_metrics.py`).
- **DB:** `training_category_parameter`, `training_type_parameter`, `activity_session_metrics`; `activity_log.started_at` / `ended_at` (nullable).
- **API:** Admin `/api/admin/training-parameters`, `/api/admin/training-category-parameters`, `/api/admin/training-type-parameters`; Nutzer `GET /api/activity/{id}`, `PUT /api/activity/{id}/metrics`; Platzhalter-Pfad `training_sessions_recent_json` liefert pro Session `session_metrics` (wenn befüllt).
- **Frontend:** Admin `/admin/activity-attribute-profiles`; Aktivität → Verlauf → Bearbeiten: Profil-Kennwerte; `api.js` ergänzt.
### Updates (11.04.2026 - Ernährung: TDEE, Bilanz, Kalorien-Score)

View File

@ -35,6 +35,7 @@ import AdminCouponsPage from './pages/AdminCouponsPage'
import AdminUserRestrictionsPage from './pages/AdminUserRestrictionsPage'
import AdminTrainingTypesPage from './pages/AdminTrainingTypesPage'
import AdminActivityMappingsPage from './pages/AdminActivityMappingsPage'
import AdminActivityAttributeProfilesPage from './pages/AdminActivityAttributeProfilesPage'
import AdminTrainingProfiles from './pages/AdminTrainingProfiles'
import AdminPromptsPage from './pages/AdminPromptsPage'
import AdminGoalTypesPage from './pages/AdminGoalTypesPage'
@ -255,6 +256,7 @@ function AppShell() {
<Route path="widget-features" element={<AdminWidgetFeatureAssignmentsPage />} />
<Route path="training-types" element={<AdminTrainingTypesPage/>}/>
<Route path="activity-mappings" element={<AdminActivityMappingsPage/>}/>
<Route path="activity-attribute-profiles" element={<AdminActivityAttributeProfilesPage />} />
<Route path="training-profiles" element={<AdminTrainingProfiles/>}/>
<Route path="prompts" element={<AdminPromptsPage/>}/>
<Route path="goal-types" element={<AdminGoalTypesPage/>}/>

View File

@ -74,6 +74,11 @@ export const ADMIN_GROUPS = [
label: 'Trainings-Profile',
description: 'Training-Type-Profile (#15).',
},
{
to: '/admin/activity-attribute-profiles',
label: 'Session-Metriken (EAV)',
description: 'Messgrößen-Katalog und Zuordnung zu Kategorie / Trainingstyp.',
},
],
},
{

View File

@ -27,6 +27,80 @@ function empty() {
}
}
function buildMetricsPayload(schema, draft) {
const out = []
for (const s of schema) {
const raw = draft[s.key]
if (s.data_type === 'boolean') {
if (raw === '' || raw === null || raw === undefined) {
if (s.required) throw new Error(`Pflichtfeld: ${s.name_de}`)
continue
}
out.push({ parameter_key: s.key, value: !!raw })
continue
}
if (raw === '' || raw === null || raw === undefined) {
if (s.required) throw new Error(`Pflichtfeld: ${s.name_de}`)
continue
}
let v = raw
if (s.data_type === 'integer') {
v = parseInt(String(raw), 10)
if (Number.isNaN(v)) throw new Error(`Ungültige Zahl: ${s.name_de}`)
} else if (s.data_type === 'float') {
v = parseFloat(String(raw))
if (Number.isNaN(v)) throw new Error(`Ungültige Zahl: ${s.name_de}`)
} else {
v = String(raw)
}
out.push({ parameter_key: s.key, value: v })
}
return out
}
function SessionMetricsFields({ schema, values, setValues }) {
if (!schema || schema.length === 0) return null
const set = (k, v) => setValues((prev) => ({ ...prev, [k]: v }))
return (
<div style={{ marginTop: 12, paddingTop: 12, borderTop: '1px solid var(--border)' }}>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>Weitere Kennwerte (Profil)</div>
{schema.map((s) => (
<div key={s.key} className="form-row">
<label className="form-label">
{s.name_de}
{s.required ? ' *' : ''}
{s.unit ? ` (${s.unit})` : ''}
</label>
{s.data_type === 'boolean' ? (
<input
type="checkbox"
style={{ width: 'auto', marginRight: 'auto' }}
checked={!!values[s.key]}
onChange={(e) => set(s.key, e.target.checked)}
/>
) : s.data_type === 'integer' || s.data_type === 'float' ? (
<input
type="number"
className="form-input"
step={s.data_type === 'integer' ? 1 : 'any'}
value={values[s.key] ?? ''}
onChange={(e) => set(s.key, e.target.value)}
/>
) : (
<input
type="text"
className="form-input"
value={values[s.key] ?? ''}
onChange={(e) => set(s.key, e.target.value)}
/>
)}
<span className="form-unit" />
</div>
))}
</div>
)
}
// Import Panel
function ImportPanel({ onImported }) {
const fileRef = useRef()
@ -85,7 +159,17 @@ function ImportPanel({ onImported }) {
}
// Manual Entry
function EntryForm({ form, setForm, onSave, onCancel, saveLabel='Speichern', saving=false, error=null, usage=null }) {
function EntryForm({
form,
setForm,
onSave,
onCancel,
saveLabel = 'Speichern',
saving = false,
error = null,
usage = null,
formExtras = null,
}) {
const set = (k,v) => setForm(f=>({...f,[k]:v}))
return (
<div>
@ -144,6 +228,7 @@ function EntryForm({ form, setForm, onSave, onCancel, saveLabel='Speichern', sav
value={form.notes||''} onChange={e=>set('notes',e.target.value)}/>
<span className="form-unit"/>
</div>
{formExtras}
{error && (
<div style={{padding:'10px',background:'var(--danger-bg)',border:'1px solid var(--danger)',borderRadius:8,fontSize:13,color:'var(--danger)',marginBottom:8}}>
{error}
@ -181,6 +266,10 @@ export default function ActivityPage() {
const [error, setError] = useState(null)
const [activityUsage, setActivityUsage] = useState(null) // Phase 4: Usage badge
const [categories, setCategories] = useState({}) // v9d: Training categories
const [sessionDetail, setSessionDetail] = useState(null)
const [metricDraft, setMetricDraft] = useState({})
const [sessionLoadError, setSessionLoadError] = useState(null)
const [savingEdit, setSavingEdit] = useState(false)
const load = async () => {
const [e, s] = await Promise.all([api.listActivity(), api.activityStats()])
@ -200,6 +289,46 @@ export default function ActivityPage() {
api.getTrainingCategories().then(setCategories).catch(err => console.error('Failed to load categories:', err))
},[])
useEffect(() => {
if (!editing?.id) {
setSessionDetail(null)
setMetricDraft({})
setSessionLoadError(null)
return
}
let cancelled = false
setSessionLoadError(null)
;(async () => {
try {
const d = await api.getActivitySession(editing.id)
if (!cancelled) setSessionDetail(d)
} catch (err) {
if (!cancelled) {
setSessionDetail(null)
setSessionLoadError(err.message || 'Zusatzfelder konnten nicht geladen werden')
}
}
})()
return () => { cancelled = true }
}, [editing?.id])
useEffect(() => {
if (!sessionDetail) {
setMetricDraft({})
return
}
const m = {}
for (const row of sessionDetail.metrics || []) {
m[row.key] = row.value
}
for (const s of sessionDetail.schema || []) {
if (!(s.key in m)) {
m[s.key] = s.data_type === 'boolean' ? false : ''
}
}
setMetricDraft(m)
}, [sessionDetail])
const handleSave = async () => {
setSaving(true)
setError(null)
@ -226,9 +355,30 @@ export default function ActivityPage() {
}
const handleUpdate = async () => {
const payload = {...editing}
await api.updateActivity(editing.id, payload)
setEditing(null); await load()
setSavingEdit(true)
setError(null)
try {
const payload = { ...editing }
delete payload.id
if (payload.duration_min !== '' && payload.duration_min != null) payload.duration_min = parseFloat(payload.duration_min)
if (payload.kcal_active !== '' && payload.kcal_active != null) payload.kcal_active = parseFloat(payload.kcal_active)
if (payload.hr_avg !== '' && payload.hr_avg != null) payload.hr_avg = parseFloat(payload.hr_avg)
if (payload.hr_max !== '' && payload.hr_max != null) payload.hr_max = parseFloat(payload.hr_max)
if (payload.rpe !== '' && payload.rpe != null) payload.rpe = parseInt(payload.rpe, 10)
await api.updateActivity(editing.id, payload)
if (sessionDetail?.schema?.length > 0) {
const metrics = buildMetricsPayload(sessionDetail.schema, metricDraft)
await api.putActivityMetrics(editing.id, { metrics })
}
setEditing(null)
setSessionDetail(null)
await load()
} catch (err) {
setError(err.message || 'Speichern fehlgeschlagen')
setTimeout(() => setError(null), 6000)
} finally {
setSavingEdit(false)
}
}
const handleDelete = async (id) => {
@ -347,8 +497,27 @@ export default function ActivityPage() {
return (
<div key={e.id} className="card" style={{marginBottom:8,borderLeft:`3px solid ${color}`}}>
{isEd ? (
<EntryForm form={editing} setForm={setEditing}
onSave={handleUpdate} onCancel={()=>setEditing(null)} saveLabel="Speichern"/>
<EntryForm
form={editing}
setForm={setEditing}
onSave={handleUpdate}
onCancel={() => { setEditing(null); setSessionDetail(null); setSessionLoadError(null) }}
saveLabel="Speichern"
saving={savingEdit}
error={error}
formExtras={
<>
{sessionLoadError && (
<div style={{ fontSize: 12, color: 'var(--text3)', marginBottom: 8 }}>{sessionLoadError}</div>
)}
<SessionMetricsFields
schema={sessionDetail?.schema}
values={metricDraft}
setValues={setMetricDraft}
/>
</>
}
/>
) : (
<div>
<div style={{display:'flex',justifyContent:'space-between',alignItems:'flex-start'}}>

View File

@ -0,0 +1,594 @@
import { useState, useEffect, useCallback } from 'react'
import { Link } from 'react-router-dom'
import { Plus, Trash2, Save, RefreshCw } from 'lucide-react'
import { api } from '../utils/api'
const PARAM_GROUP = ['physical', 'physiological', 'subjective', 'environmental', 'performance']
const DATA_TYPES = ['integer', 'float', 'string', 'boolean']
const emptyParamForm = () => ({
key: '',
name_de: '',
name_en: '',
category: 'physical',
data_type: 'float',
unit: '',
source_field: '',
is_active: true,
})
export default function AdminActivityAttributeProfilesPage() {
const [params, setParams] = useState([])
const [includeInactive, setIncludeInactive] = useState(false)
const [catMeta, setCatMeta] = useState({})
const [flatTypes, setFlatTypes] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [toast, setToast] = useState(null)
const [showParamForm, setShowParamForm] = useState(false)
const [paramForm, setParamForm] = useState(emptyParamForm())
const [selCategory, setSelCategory] = useState('cardio')
const [catLinks, setCatLinks] = useState([])
const [catAdd, setCatAdd] = useState({
training_parameter_id: '',
sort_order: 0,
required: false,
ui_group: '',
})
const [selTypeId, setSelTypeId] = useState('')
const [typeLinks, setTypeLinks] = useState([])
const [typeAdd, setTypeAdd] = useState({
training_parameter_id: '',
sort_order: '',
required: '',
ui_group: '',
})
const showToast = (msg) => {
setToast(msg)
setTimeout(() => setToast(null), 2800)
}
const refreshCatalog = useCallback(async () => {
setLoading(true)
setError(null)
try {
const [p, cats, flat] = await Promise.all([
api.adminListTrainingParameters(includeInactive),
api.getTrainingCategories(),
api.listTrainingTypesFlat(),
])
setParams(Array.isArray(p) ? p : [])
setCatMeta(cats && typeof cats === 'object' ? cats : {})
setFlatTypes(Array.isArray(flat) ? flat : [])
setSelTypeId((prev) => (prev ? prev : flat?.length ? String(flat[0].id) : ''))
} catch (e) {
setError(e.message || 'Laden fehlgeschlagen')
} finally {
setLoading(false)
}
}, [includeInactive])
useEffect(() => {
refreshCatalog()
}, [refreshCatalog])
const loadCatLinks = useCallback(async () => {
try {
const data = await api.adminListTrainingCategoryParameters(selCategory)
setCatLinks(Array.isArray(data) ? data : [])
} catch (e) {
setError(e.message || 'Kategorie-Zuordnungen')
}
}, [selCategory])
useEffect(() => {
loadCatLinks()
}, [loadCatLinks])
const loadTypeLinks = useCallback(async () => {
if (!selTypeId) {
setTypeLinks([])
return
}
try {
const data = await api.adminListTrainingTypeParameters(Number(selTypeId))
setTypeLinks(Array.isArray(data) ? data : [])
} catch (e) {
setError(e.message || 'Typ-Zuordnungen')
}
}, [selTypeId])
useEffect(() => {
loadTypeLinks()
}, [loadTypeLinks])
const activeParams = params.filter((p) => p.is_active !== false)
const saveNewParameter = async () => {
setError(null)
if (!paramForm.key.trim() || !paramForm.name_de.trim() || !paramForm.name_en.trim()) {
setError('key, name_de und name_en sind Pflicht.')
return
}
try {
await api.adminCreateTrainingParameter({
key: paramForm.key.trim().toLowerCase(),
name_de: paramForm.name_de.trim(),
name_en: paramForm.name_en.trim(),
category: paramForm.category,
data_type: paramForm.data_type,
unit: paramForm.unit.trim() || null,
source_field: paramForm.source_field.trim() || null,
is_active: paramForm.is_active,
validation_rules: {},
})
showToast('Parameter angelegt')
setShowParamForm(false)
setParamForm(emptyParamForm())
await refreshCatalog()
} catch (e) {
setError(e.message || 'Speichern fehlgeschlagen')
}
}
const deactivateParameter = async (id) => {
if (!confirm('Parameter deaktivieren? (Bestehende EAV-Zeilen bleiben erhalten.)')) return
try {
await api.adminDeleteTrainingParameter(id)
showToast('Deaktiviert')
await refreshCatalog()
} catch (e) {
setError(e.message || 'Löschen fehlgeschlagen')
}
}
const addCatLink = async () => {
const pid = parseInt(catAdd.training_parameter_id, 10)
if (!pid) {
setError('Parameter-ID wählen')
return
}
try {
await api.adminAddTrainingCategoryParameter({
training_category: selCategory,
training_parameter_id: pid,
sort_order: Number(catAdd.sort_order) || 0,
required: !!catAdd.required,
ui_group: catAdd.ui_group.trim() || null,
})
showToast('Zuordnung gespeichert')
setCatAdd({ training_parameter_id: '', sort_order: 0, required: false, ui_group: '' })
await loadCatLinks()
} catch (e) {
setError(e.message || 'Konflikt oder ungültige Daten')
}
}
const addTypeLink = async () => {
const tid = Number(selTypeId)
const pid = parseInt(typeAdd.training_parameter_id, 10)
if (!tid || !pid) {
setError('Trainingstyp und Parameter wählen')
return
}
const body = {
training_type_id: tid,
training_parameter_id: pid,
sort_order: typeAdd.sort_order === '' ? null : Number(typeAdd.sort_order),
required: typeAdd.required === '' ? null : typeAdd.required === 'true' || typeAdd.required === true,
ui_group: typeAdd.ui_group.trim() || null,
}
try {
await api.adminAddTrainingTypeParameter(body)
showToast('Typ-Zuordnung gespeichert')
setTypeAdd({ training_parameter_id: '', sort_order: '', required: '', ui_group: '' })
await loadTypeLinks()
} catch (e) {
setError(e.message || 'Konflikt oder ungültige Daten')
}
}
const categoryKeys =
Object.keys(catMeta).length > 0
? Object.keys(catMeta).sort()
: ['cardio', 'strength', 'hiit', 'martial_arts', 'mobility', 'recovery', 'other']
if (loading && !params.length) {
return (
<div className="card section-gap">
<div className="spinner" /> Lade
</div>
)
}
return (
<div className="capture-page">
<div style={{ marginBottom: 12 }}>
<Link to="/admin/g/training" className="text-link" style={{ fontSize: 13 }}>
Training (Hub)
</Link>
</div>
<h1 className="page-title">Session-Metriken (EAV)</h1>
<p style={{ fontSize: 14, color: 'var(--text2)', maxWidth: 720, lineHeight: 1.5 }}>
Messgrößen-Katalog und Zuordnung zu <strong>Kategorie</strong> (Basis) bzw.{' '}
<strong>Trainingstyp</strong> (Zusatz/Override). Nutzer sehen die Felder beim Bearbeiten einer
Aktivität, wenn der Eintrag passend kategorisiert ist.
</p>
{toast && (
<div
className="card"
style={{ background: 'var(--accent-light)', color: 'var(--accent-dark)', marginBottom: 12 }}
>
{toast}
</div>
)}
{error && (
<div
className="card"
style={{ background: '#FCEBEB', color: '#D85A30', marginBottom: 12, fontSize: 14 }}
>
{error}
<button type="button" className="btn btn-secondary" style={{ marginLeft: 8 }} onClick={() => setError(null)}>
OK
</button>
</div>
)}
<div className="card section-gap">
<div className="card-title" style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span>Parameter-Katalog</span>
<label style={{ fontSize: 12, fontWeight: 400, display: 'flex', alignItems: 'center', gap: 6 }}>
<input
type="checkbox"
checked={includeInactive}
onChange={(e) => setIncludeInactive(e.target.checked)}
/>
Inaktive anzeigen
</label>
<button type="button" className="btn btn-secondary" onClick={() => refreshCatalog()}>
<RefreshCw size={14} /> Neu laden
</button>
<button type="button" className="btn btn-primary" onClick={() => setShowParamForm((v) => !v)}>
<Plus size={14} /> Neuer Parameter
</button>
</div>
{showParamForm && (
<div
style={{
border: '1px solid var(--border)',
borderRadius: 8,
padding: 12,
marginBottom: 12,
background: 'var(--surface2)',
}}
>
<div className="form-row">
<label className="form-label">key</label>
<input
className="form-input"
placeholder="z. B. avg_power"
value={paramForm.key}
onChange={(e) => setParamForm((f) => ({ ...f, key: e.target.value }))}
/>
</div>
<div className="form-row">
<label className="form-label">name_de / name_en</label>
<input
className="form-input"
value={paramForm.name_de}
onChange={(e) => setParamForm((f) => ({ ...f, name_de: e.target.value }))}
placeholder="DE"
/>
<input
className="form-input"
value={paramForm.name_en}
onChange={(e) => setParamForm((f) => ({ ...f, name_en: e.target.value }))}
placeholder="EN"
/>
</div>
<div className="form-row">
<label className="form-label">Gruppe / Datentyp</label>
<select
className="form-input"
value={paramForm.category}
onChange={(e) => setParamForm((f) => ({ ...f, category: e.target.value }))}
>
{PARAM_GROUP.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
<select
className="form-input"
value={paramForm.data_type}
onChange={(e) => setParamForm((f) => ({ ...f, data_type: e.target.value }))}
>
{DATA_TYPES.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</div>
<div className="form-row">
<label className="form-label">Einheit / source_field</label>
<input
className="form-input"
placeholder="z. B. W"
value={paramForm.unit}
onChange={(e) => setParamForm((f) => ({ ...f, unit: e.target.value }))}
/>
<input
className="form-input"
placeholder="activity_log-Spalte (optional)"
value={paramForm.source_field}
onChange={(e) => setParamForm((f) => ({ ...f, source_field: e.target.value }))}
/>
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<button type="button" className="btn btn-primary" onClick={saveNewParameter}>
<Save size={14} /> Anlegen
</button>
<button type="button" className="btn btn-secondary" onClick={() => setShowParamForm(false)}>
Abbrechen
</button>
</div>
</div>
)}
<div style={{ overflowX: 'auto' }}>
<table className="data-table" style={{ width: '100%', fontSize: 13 }}>
<thead>
<tr>
<th>ID</th>
<th>key</th>
<th>DE</th>
<th>Typ</th>
<th>aktiv</th>
<th />
</tr>
</thead>
<tbody>
{params.map((r) => (
<tr key={r.id}>
<td>{r.id}</td>
<td>
<code>{r.key}</code>
</td>
<td>{r.name_de}</td>
<td>
{r.data_type} · {r.category}
</td>
<td>{r.is_active === false ? 'nein' : 'ja'}</td>
<td>
{r.is_active !== false && (
<button
type="button"
className="btn btn-danger"
style={{ padding: '4px 8px' }}
onClick={() => deactivateParameter(r.id)}
>
<Trash2 size={14} />
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="card section-gap">
<div className="card-title">Zuordnung: Trainings-Kategorie</div>
<div className="form-row">
<label className="form-label">Kategorie</label>
<select
className="form-input"
value={selCategory}
onChange={(e) => setSelCategory(e.target.value)}
style={{ maxWidth: 280 }}
>
{categoryKeys.map((k) => (
<option key={k} value={k}>
{catMeta[k]?.name_de || k}
</option>
))}
</select>
</div>
<div className="form-row" style={{ alignItems: 'flex-end', flexWrap: 'wrap', gap: 8 }}>
<div style={{ flex: 1, minWidth: 200 }}>
<label className="form-label">Parameter</label>
<select
className="form-input"
value={catAdd.training_parameter_id}
onChange={(e) => setCatAdd((a) => ({ ...a, training_parameter_id: e.target.value }))}
>
<option value=""> wählen </option>
{activeParams.map((p) => (
<option key={p.id} value={p.id}>
{p.id} · {p.key} ({p.name_de})
</option>
))}
</select>
</div>
<div>
<label className="form-label">sort</label>
<input
type="number"
className="form-input"
style={{ width: 80 }}
value={catAdd.sort_order}
onChange={(e) => setCatAdd((a) => ({ ...a, sort_order: e.target.value }))}
/>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13 }}>
<input
type="checkbox"
checked={catAdd.required}
onChange={(e) => setCatAdd((a) => ({ ...a, required: e.target.checked }))}
/>
Pflicht
</label>
<div>
<label className="form-label">ui_group</label>
<input
className="form-input"
style={{ width: 120 }}
value={catAdd.ui_group}
onChange={(e) => setCatAdd((a) => ({ ...a, ui_group: e.target.value }))}
/>
</div>
<button type="button" className="btn btn-primary" onClick={addCatLink}>
<Plus size={14} /> Hinzufügen
</button>
</div>
<ul style={{ listStyle: 'none', padding: 0, marginTop: 12 }}>
{catLinks.map((l) => (
<li
key={l.id}
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '8px 0',
borderBottom: '1px solid var(--border)',
fontSize: 13,
}}
>
<span>
<strong>{l.parameter_key}</strong> · {l.parameter_name_de} · sort {l.sort_order}
{l.required ? ' · Pflicht' : ''}
{l.ui_group ? ` · ${l.ui_group}` : ''}
</span>
<button
type="button"
className="btn btn-danger"
style={{ padding: '4px 8px' }}
onClick={async () => {
if (!confirm('Zuordnung entfernen?')) return
await api.adminDeleteTrainingCategoryParameter(l.id)
await loadCatLinks()
showToast('Entfernt')
}}
>
<Trash2 size={14} />
</button>
</li>
))}
</ul>
</div>
<div className="card section-gap">
<div className="card-title">Zuordnung: Trainingstyp (Zusatz / Override)</div>
<div className="form-row">
<label className="form-label">Trainingstyp</label>
<select
className="form-input"
value={selTypeId}
onChange={(e) => setSelTypeId(e.target.value)}
style={{ maxWidth: 420 }}
>
{flatTypes.map((t) => (
<option key={t.id} value={t.id}>
{t.id} · {t.name_de} ({t.category})
</option>
))}
</select>
</div>
<div className="form-row" style={{ alignItems: 'flex-end', flexWrap: 'wrap', gap: 8 }}>
<div style={{ flex: 1, minWidth: 200 }}>
<label className="form-label">Parameter</label>
<select
className="form-input"
value={typeAdd.training_parameter_id}
onChange={(e) => setTypeAdd((a) => ({ ...a, training_parameter_id: e.target.value }))}
>
<option value=""> wählen </option>
{activeParams.map((p) => (
<option key={p.id} value={p.id}>
{p.id} · {p.key}
</option>
))}
</select>
</div>
<div>
<label className="form-label">sort (leer=Erben)</label>
<input
type="number"
className="form-input"
style={{ width: 80 }}
value={typeAdd.sort_order}
onChange={(e) => setTypeAdd((a) => ({ ...a, sort_order: e.target.value }))}
/>
</div>
<div>
<label className="form-label">Pflicht (leer=Erben)</label>
<select
className="form-input"
style={{ width: 100 }}
value={typeAdd.required}
onChange={(e) => setTypeAdd((a) => ({ ...a, required: e.target.value }))}
>
<option value=""></option>
<option value="true">ja</option>
<option value="false">nein</option>
</select>
</div>
<div>
<label className="form-label">ui_group</label>
<input
className="form-input"
style={{ width: 120 }}
value={typeAdd.ui_group}
onChange={(e) => setTypeAdd((a) => ({ ...a, ui_group: e.target.value }))}
/>
</div>
<button type="button" className="btn btn-primary" onClick={addTypeLink}>
<Plus size={14} /> Hinzufügen
</button>
</div>
<ul style={{ listStyle: 'none', padding: 0, marginTop: 12 }}>
{typeLinks.map((l) => (
<li
key={l.id}
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '8px 0',
borderBottom: '1px solid var(--border)',
fontSize: 13,
}}
>
<span>
<strong>{l.parameter_key}</strong> · sort {l.sort_order ?? '—'} · Pflicht{' '}
{l.required === null || l.required === undefined ? '—' : l.required ? 'ja' : 'nein'}
</span>
<button
type="button"
className="btn btn-danger"
style={{ padding: '4px 8px' }}
onClick={async () => {
if (!confirm('Zuordnung entfernen?')) return
await api.adminDeleteTrainingTypeParameter(l.id)
await loadTypeLinks()
showToast('Entfernt')
}}
>
<Trash2 size={14} />
</button>
</li>
))}
</ul>
</div>
</div>
)
}

View File

@ -314,6 +314,30 @@ export const api = {
adminDeleteActivityMapping: (id) => req(`/admin/activity-mappings/${id}`, {method:'DELETE'}),
adminGetMappingCoverage: () => req('/admin/activity-mappings/stats/coverage'),
// Admin: Training session metrics (EAV) & attribute profiles (Migration 054)
adminListTrainingParameters: (includeInactive = false) =>
req(`/admin/training-parameters${includeInactive ? '?include_inactive=true' : ''}`),
adminCreateTrainingParameter: (d) => req('/admin/training-parameters', json(d)),
adminUpdateTrainingParameter: (id, d) => req(`/admin/training-parameters/${id}`, jput(d)),
adminDeleteTrainingParameter: (id) =>
req(`/admin/training-parameters/${id}`, { method: 'DELETE' }),
adminListTrainingCategoryParameters: (category = '') =>
req(
`/admin/training-category-parameters${category ? `?category=${encodeURIComponent(category)}` : ''}`,
),
adminAddTrainingCategoryParameter: (d) => req('/admin/training-category-parameters', json(d)),
adminDeleteTrainingCategoryParameter: (id) =>
req(`/admin/training-category-parameters/${id}`, { method: 'DELETE' }),
adminListTrainingTypeParameters: (trainingTypeId) =>
req(`/admin/training-type-parameters?training_type_id=${encodeURIComponent(trainingTypeId)}`),
adminAddTrainingTypeParameter: (d) => req('/admin/training-type-parameters', json(d)),
adminDeleteTrainingTypeParameter: (id) =>
req(`/admin/training-type-parameters/${id}`, { method: 'DELETE' }),
getActivitySession: (id) => req(`/activity/${encodeURIComponent(id)}`),
putActivityMetrics: (id, body) =>
req(`/activity/${encodeURIComponent(id)}/metrics`, json(body)),
// Sleep Module (v9d Phase 2b)
listSleep: (l=90) => req(`/sleep?limit=${l}`),
getSleepByDate: (date) => req(`/sleep/by-date/${date}`),