diff --git a/frontend/src/pages/AdminActivityAttributeProfilesPage.jsx b/frontend/src/pages/AdminActivityAttributeProfilesPage.jsx
new file mode 100644
index 0000000..3fae2b4
--- /dev/null
+++ b/frontend/src/pages/AdminActivityAttributeProfilesPage.jsx
@@ -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 (
+
+ )
+ }
+
+ return (
+
+
+
+ ← Training (Hub)
+
+
+
Session-Metriken (EAV)
+
+ Messgrößen-Katalog und Zuordnung zu Kategorie (Basis) bzw.{' '}
+ Trainingstyp (Zusatz/Override). Nutzer sehen die Felder beim Bearbeiten einer
+ Aktivität, wenn der Eintrag passend kategorisiert ist.
+
+
+ {toast && (
+
+ {toast}
+
+ )}
+ {error && (
+
+ {error}
+ setError(null)}>
+ OK
+
+
+ )}
+
+
+
+
Parameter-Katalog
+
+ setIncludeInactive(e.target.checked)}
+ />
+ Inaktive anzeigen
+
+
refreshCatalog()}>
+ Neu laden
+
+
setShowParamForm((v) => !v)}>
+ Neuer Parameter
+
+
+
+ {showParamForm && (
+
+ )}
+
+
+
+
+
+ ID
+ key
+ DE
+ Typ
+ aktiv
+
+
+
+
+ {params.map((r) => (
+
+ {r.id}
+
+ {r.key}
+
+ {r.name_de}
+
+ {r.data_type} · {r.category}
+
+ {r.is_active === false ? 'nein' : 'ja'}
+
+ {r.is_active !== false && (
+ deactivateParameter(r.id)}
+ >
+
+
+ )}
+
+
+ ))}
+
+
+
+
+
+
+
Zuordnung: Trainings-Kategorie
+
+ Kategorie
+ setSelCategory(e.target.value)}
+ style={{ maxWidth: 280 }}
+ >
+ {categoryKeys.map((k) => (
+
+ {catMeta[k]?.name_de || k}
+
+ ))}
+
+
+
+
+ Parameter
+ setCatAdd((a) => ({ ...a, training_parameter_id: e.target.value }))}
+ >
+ — wählen —
+ {activeParams.map((p) => (
+
+ {p.id} · {p.key} ({p.name_de})
+
+ ))}
+
+
+
+ sort
+ setCatAdd((a) => ({ ...a, sort_order: e.target.value }))}
+ />
+
+
+ setCatAdd((a) => ({ ...a, required: e.target.checked }))}
+ />
+ Pflicht
+
+
+ ui_group
+ setCatAdd((a) => ({ ...a, ui_group: e.target.value }))}
+ />
+
+
+ Hinzufügen
+
+
+
+ {catLinks.map((l) => (
+
+
+ {l.parameter_key} · {l.parameter_name_de} · sort {l.sort_order}
+ {l.required ? ' · Pflicht' : ''}
+ {l.ui_group ? ` · ${l.ui_group}` : ''}
+
+ {
+ if (!confirm('Zuordnung entfernen?')) return
+ await api.adminDeleteTrainingCategoryParameter(l.id)
+ await loadCatLinks()
+ showToast('Entfernt')
+ }}
+ >
+
+
+
+ ))}
+
+
+
+
+
Zuordnung: Trainingstyp (Zusatz / Override)
+
+ Trainingstyp
+ setSelTypeId(e.target.value)}
+ style={{ maxWidth: 420 }}
+ >
+ {flatTypes.map((t) => (
+
+ {t.id} · {t.name_de} ({t.category})
+
+ ))}
+
+
+
+
+ Parameter
+ setTypeAdd((a) => ({ ...a, training_parameter_id: e.target.value }))}
+ >
+ — wählen —
+ {activeParams.map((p) => (
+
+ {p.id} · {p.key}
+
+ ))}
+
+
+
+ sort (leer=Erben)
+ setTypeAdd((a) => ({ ...a, sort_order: e.target.value }))}
+ />
+
+
+ Pflicht (leer=Erben)
+ setTypeAdd((a) => ({ ...a, required: e.target.value }))}
+ >
+ —
+ ja
+ nein
+
+
+
+ ui_group
+ setTypeAdd((a) => ({ ...a, ui_group: e.target.value }))}
+ />
+
+
+ Hinzufügen
+
+
+
+ {typeLinks.map((l) => (
+
+
+ {l.parameter_key} · sort {l.sort_order ?? '—'} · Pflicht{' '}
+ {l.required === null || l.required === undefined ? '—' : l.required ? 'ja' : 'nein'}
+
+ {
+ if (!confirm('Zuordnung entfernen?')) return
+ await api.adminDeleteTrainingTypeParameter(l.id)
+ await loadTypeLinks()
+ showToast('Entfernt')
+ }}
+ >
+
+
+
+ ))}
+
+
+
+ )
+}
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js
index 205acc2..f09d55c 100644
--- a/frontend/src/utils/api.js
+++ b/frontend/src/utils/api.js
@@ -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}`),