import { useState, useEffect, useCallback } from 'react' import { Link } from 'react-router-dom' import { Plus, Trash2, Save, RefreshCw, Pencil } 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 [tab, setTab] = useState('catalog') 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 [editParam, setEditParam] = useState(null) const [selCategory, setSelCategory] = useState('cardio') const [catLinks, setCatLinks] = useState([]) const [catAdd, setCatAdd] = useState({ training_parameter_id: '', sort_order: 0, required: false, ui_group: '', }) const [editingCatId, setEditingCatId] = useState(null) const [catDraft, setCatDraft] = useState({ 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 [editingTypeId, setEditingTypeId] = useState(null) const [typeDraft, setTypeDraft] = useState({ 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 categoryKeys = Object.keys(catMeta).length > 0 ? Object.keys(catMeta).sort() : ['cardio', 'strength', 'hiit', 'martial_arts', 'mobility', 'recovery', 'other'] 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 saveEditedParameter = async () => { if (!editParam) return setError(null) try { await api.adminUpdateTrainingParameter(editParam.id, { name_de: editParam.name_de.trim(), name_en: editParam.name_en.trim(), category: editParam.category, data_type: editParam.data_type, unit: editParam.unit?.trim() || null, source_field: editParam.source_field?.trim() || null, is_active: !!editParam.is_active, validation_rules: editParam.validation_rules || {}, }) showToast('Parameter gespeichert') setEditParam(null) await refreshCatalog() } catch (e) { setError(e.message || 'Update 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 saveCatLink = async (linkId) => { try { await api.adminUpdateTrainingCategoryParameter(linkId, { sort_order: Number(catDraft.sort_order) || 0, required: !!catDraft.required, ui_group: catDraft.ui_group.trim() || null, }) showToast('Kategorie-Zuordnung aktualisiert') setEditingCatId(null) await loadCatLinks() } catch (e) { setError(e.message || 'Update fehlgeschlagen') } } 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 saveTypeLink = async (linkId) => { try { const payload = {} if (typeDraft.sort_order !== '' && typeDraft.sort_order != null) { payload.sort_order = Number(typeDraft.sort_order) } else { payload.sort_order = null } if (typeDraft.required === '' || typeDraft.required == null) { payload.required = null } else { payload.required = typeDraft.required === true || typeDraft.required === 'true' } payload.ui_group = typeDraft.ui_group.trim() || null await api.adminUpdateTrainingTypeParameter(linkId, payload) showToast('Typ-Zuordnung aktualisiert') setEditingTypeId(null) await loadTypeLinks() } catch (e) { setError(e.message || 'Update fehlgeschlagen') } } if (loading && !params.length) { return (
Lade…
) } return (
← Training (Hub)

Session-Metriken (EAV)

Hinweise
  • Daten: Offizielle Migrationen löschen keine Zeilen in activity_log. Leere Tabellen nach Deploy deuten auf neues DB-Volume, manuelles SQL oder falsche Umgebung hin – vor Prod immer{' '} pg_dump.
  • Doppelzuordnung: Dieselbe Metrik darf eine Zeile pro Kategorie und eine{' '} pro Trainingstyp haben (Unique-Constraint). Wenn dieselbe Metrik in Kategorie und{' '} Trainingstyp vorkommt, überschreibt die Typ-Zeile sort/required/ui_group (Merge in Layer 1) – kein Datenfehler.
  • Nach Migration 055 werden Standard-Parameter allen Kategorien zugeordnet und vorhandene{' '} activity_log-Spalten idempotent nach EAV gespiegelt (sofern noch keine EAV-Zeile existiert).
{toast && (
{toast}
)} {error && (
{error}
)}
{[ ['catalog', 'Katalog'], ['category', 'Kategorie'], ['type', 'Trainingstyp'], ].map(([id, label]) => ( ))}
{tab === 'catalog' && (
Parameter-Katalog
{showParamForm && (
setParamForm((f) => ({ ...f, key: e.target.value }))} />
setParamForm((f) => ({ ...f, name_de: e.target.value }))} /> setParamForm((f) => ({ ...f, name_en: e.target.value }))} />
setParamForm((f) => ({ ...f, unit: e.target.value }))} /> setParamForm((f) => ({ ...f, source_field: e.target.value }))} />
)} {editParam && (
Bearbeiten: {editParam.key}
setEditParam((p) => ({ ...p, name_de: e.target.value }))} /> setEditParam((p) => ({ ...p, name_en: e.target.value }))} />
setEditParam((p) => ({ ...p, unit: e.target.value }))} /> setEditParam((p) => ({ ...p, source_field: e.target.value }))} />
)}
{params.map((r) => ( ))}
ID key DE Typ aktiv
{r.id} {r.key} {r.name_de} {r.data_type} · {r.category} {r.is_active === false ? 'nein' : 'ja'}
{r.is_active !== false && ( )}
)} {tab === 'category' && (
Zuordnung: Trainings-Kategorie
setCatAdd((a) => ({ ...a, sort_order: e.target.value }))} />
setCatAdd((a) => ({ ...a, ui_group: e.target.value }))} />
    {catLinks.map((l) => (
  • {editingCatId === l.id ? (
    {l.parameter_key} · {l.parameter_name_de}
    setCatDraft((d) => ({ ...d, sort_order: e.target.value }))} />
    setCatDraft((d) => ({ ...d, ui_group: e.target.value }))} />
    ) : (
    {l.parameter_key} · {l.parameter_name_de} · sort {l.sort_order} {l.required ? ' · Pflicht' : ''} {l.ui_group ? ` · ${l.ui_group}` : ''}
    )}
  • ))}
)} {tab === 'type' && (
Zuordnung: Trainingstyp (Zusatz / Override)
setTypeAdd((a) => ({ ...a, sort_order: e.target.value }))} />
setTypeAdd((a) => ({ ...a, ui_group: e.target.value }))} />
    {typeLinks.map((l) => (
  • {editingTypeId === l.id ? (
    {l.parameter_key}
    setTypeDraft((d) => ({ ...d, sort_order: e.target.value }))} />
    setTypeDraft((d) => ({ ...d, ui_group: e.target.value }))} />
    ) : (
    {l.parameter_key} · sort {l.sort_order ?? '—'} · Pflicht{' '} {l.required === null || l.required === undefined ? '—' : l.required ? 'ja' : 'nein'}
    )}
  • ))}
)}
) }