feat: Add update functionality for training category and type parameters
All checks were successful
Deploy Development / deploy (push) Successful in 50s
Build Test / pytest-backend (push) Successful in 8s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s

- Introduced new endpoints for updating training category and type parameters in the backend.
- Added corresponding update functions in the frontend API utility.
- Enhanced the Admin Activity Attribute Profiles page to support editing and saving changes for category and type parameters.
- Implemented state management for editing parameters and improved error handling during updates.
This commit is contained in:
Lars 2026-04-14 12:26:52 +02:00
parent cf7379b2f6
commit 196b6c5cf1
5 changed files with 971 additions and 344 deletions

View File

@ -0,0 +1,213 @@
-- Migration 055: Seed training_category_parameter (all categories × parameters with activity_log source_field)
-- + idempotent backfill activity_log → activity_session_metrics (EAV)
-- Date: 2026-04-15
-- SAFE: INSERT … ON CONFLICT DO NOTHING only; no DELETE/TRUNCATE on activity_log.
-- Agent guide: .claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md
--1) Jede in training_types vorkommende Kategorie erhält alle aktiven Parameter mit source_field (Spalte in activity_log).
INSERT INTO training_category_parameter (
training_category,
training_parameter_id,
sort_order,
required,
ui_group
)
SELECT
tc.training_category,
tp.id,
ROW_NUMBER() OVER (
PARTITION BY tc.training_category
ORDER BY tp.category, tp.id
),
false,
NULL
FROM (
SELECT DISTINCT category AS training_category
FROM training_types
WHERE category IS NOT NULL AND trim(category) <> ''
) tc
CROSS JOIN training_parameters tp
WHERE tp.is_active = true
AND tp.source_field IS NOT NULL
AND trim(tp.source_field) <> ''
ON CONFLICT (training_category, training_parameter_id) DO NOTHING;
-- 2) Backfill: activity_log-Spalten → EAV (nur wenn noch keine Zeile existiert)
-- duration_min → integer
INSERT INTO activity_session_metrics (
activity_log_id, training_parameter_id,
value_num, value_int, value_text, value_bool, updated_at
)
SELECT
a.id,
tp.id,
NULL,
ROUND(a.duration_min::numeric)::bigint,
NULL,
NULL,
NOW()
FROM activity_log a
JOIN training_parameters tp ON tp.key = 'duration_min' AND tp.is_active = true
WHERE a.duration_min IS NOT NULL
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
-- distance_km → float
INSERT INTO activity_session_metrics (
activity_log_id, training_parameter_id,
value_num, value_int, value_text, value_bool, updated_at
)
SELECT a.id, tp.id, a.distance_km::double precision, NULL, NULL, NULL, NOW()
FROM activity_log a
JOIN training_parameters tp ON tp.key = 'distance_km' AND tp.is_active = true
WHERE a.distance_km IS NOT NULL
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
-- kcal_active
INSERT INTO activity_session_metrics (
activity_log_id, training_parameter_id,
value_num, value_int, value_text, value_bool, updated_at
)
SELECT a.id, tp.id, NULL, ROUND(a.kcal_active::numeric)::bigint, NULL, NULL, NOW()
FROM activity_log a
JOIN training_parameters tp ON tp.key = 'kcal_active' AND tp.is_active = true
WHERE a.kcal_active IS NOT NULL
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
INSERT INTO activity_session_metrics (
activity_log_id, training_parameter_id,
value_num, value_int, value_text, value_bool, updated_at
)
SELECT a.id, tp.id, NULL, ROUND(a.kcal_resting::numeric)::bigint, NULL, NULL, NOW()
FROM activity_log a
JOIN training_parameters tp ON tp.key = 'kcal_resting' AND tp.is_active = true
WHERE a.kcal_resting IS NOT NULL
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
-- hr_avg / hr_max → keys avg_hr, max_hr
INSERT INTO activity_session_metrics (
activity_log_id, training_parameter_id,
value_num, value_int, value_text, value_bool, updated_at
)
SELECT a.id, tp.id, NULL, ROUND(a.hr_avg::numeric)::bigint, NULL, NULL, NOW()
FROM activity_log a
JOIN training_parameters tp ON tp.key = 'avg_hr' AND tp.is_active = true
WHERE a.hr_avg IS NOT NULL
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
INSERT INTO activity_session_metrics (
activity_log_id, training_parameter_id,
value_num, value_int, value_text, value_bool, updated_at
)
SELECT a.id, tp.id, NULL, ROUND(a.hr_max::numeric)::bigint, NULL, NULL, NOW()
FROM activity_log a
JOIN training_parameters tp ON tp.key = 'max_hr' AND tp.is_active = true
WHERE a.hr_max IS NOT NULL
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
-- rpe
INSERT INTO activity_session_metrics (
activity_log_id, training_parameter_id,
value_num, value_int, value_text, value_bool, updated_at
)
SELECT a.id, tp.id, NULL, a.rpe::bigint, NULL, NULL, NOW()
FROM activity_log a
JOIN training_parameters tp ON tp.key = 'rpe' AND tp.is_active = true
WHERE a.rpe IS NOT NULL
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
-- min_hr (Spalte hr_min nach Migration 014)
INSERT INTO activity_session_metrics (
activity_log_id, training_parameter_id,
value_num, value_int, value_text, value_bool, updated_at
)
SELECT a.id, tp.id, NULL, a.hr_min, NULL, NULL, NOW()
FROM activity_log a
JOIN training_parameters tp ON tp.key = 'min_hr' AND tp.is_active = true
WHERE a.hr_min IS NOT NULL
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
INSERT INTO activity_session_metrics (
activity_log_id, training_parameter_id,
value_num, value_int, value_text, value_bool, updated_at
)
SELECT a.id, tp.id, a.pace_min_per_km::double precision, NULL, NULL, NULL, NOW()
FROM activity_log a
JOIN training_parameters tp ON tp.key = 'pace_min_per_km' AND tp.is_active = true
WHERE a.pace_min_per_km IS NOT NULL
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
INSERT INTO activity_session_metrics (
activity_log_id, training_parameter_id,
value_num, value_int, value_text, value_bool, updated_at
)
SELECT a.id, tp.id, NULL, a.cadence, NULL, NULL, NOW()
FROM activity_log a
JOIN training_parameters tp ON tp.key = 'cadence' AND tp.is_active = true
WHERE a.cadence IS NOT NULL
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
INSERT INTO activity_session_metrics (
activity_log_id, training_parameter_id,
value_num, value_int, value_text, value_bool, updated_at
)
SELECT a.id, tp.id, NULL, a.avg_power, NULL, NULL, NOW()
FROM activity_log a
JOIN training_parameters tp ON tp.key = 'avg_power' AND tp.is_active = true
WHERE a.avg_power IS NOT NULL
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
INSERT INTO activity_session_metrics (
activity_log_id, training_parameter_id,
value_num, value_int, value_text, value_bool, updated_at
)
SELECT a.id, tp.id, NULL, a.elevation_gain, NULL, NULL, NOW()
FROM activity_log a
JOIN training_parameters tp ON tp.key = 'elevation_gain' AND tp.is_active = true
WHERE a.elevation_gain IS NOT NULL
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
INSERT INTO activity_session_metrics (
activity_log_id, training_parameter_id,
value_num, value_int, value_text, value_bool, updated_at
)
SELECT a.id, tp.id, a.temperature_celsius::double precision, NULL, NULL, NULL, NOW()
FROM activity_log a
JOIN training_parameters tp ON tp.key = 'temperature_celsius' AND tp.is_active = true
WHERE a.temperature_celsius IS NOT NULL
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
INSERT INTO activity_session_metrics (
activity_log_id, training_parameter_id,
value_num, value_int, value_text, value_bool, updated_at
)
SELECT a.id, tp.id, NULL, a.humidity_percent, NULL, NULL, NOW()
FROM activity_log a
JOIN training_parameters tp ON tp.key = 'humidity_percent' AND tp.is_active = true
WHERE a.humidity_percent IS NOT NULL
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
INSERT INTO activity_session_metrics (
activity_log_id, training_parameter_id,
value_num, value_int, value_text, value_bool, updated_at
)
SELECT a.id, tp.id, a.avg_hr_percent::double precision, NULL, NULL, NULL, NOW()
FROM activity_log a
JOIN training_parameters tp ON tp.key = 'avg_hr_percent' AND tp.is_active = true
WHERE a.avg_hr_percent IS NOT NULL
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
INSERT INTO activity_session_metrics (
activity_log_id, training_parameter_id,
value_num, value_int, value_text, value_bool, updated_at
)
SELECT a.id, tp.id, a.kcal_per_km::double precision, NULL, NULL, NULL, NOW()
FROM activity_log a
JOIN training_parameters tp ON tp.key = 'kcal_per_km' AND tp.is_active = true
WHERE a.kcal_per_km IS NOT NULL
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
DO $$
BEGIN
RAISE NOTICE 'Migration 055: category parameter seed + EAV backfill from activity_log (no row deletes)';
END $$;

View File

@ -35,18 +35,18 @@ logger = logging.getLogger(__name__)
def list_activity(
limit: int = Query(200, ge=1, le=50_000),
days: Optional[int] = Query(None, ge=1, le=4000, description="Nur Einträge mit date >= HEUTE days (Kalendertage)"),
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth),
):
"""Get activity entries for current profile. Optional *days* filter by calendar window (not the same as *limit*)."""
pid = get_pid(x_profile_id)
# Immer das Profil der gültigen Session (X-Profile-Id wird hier nicht verwendet).
pid = str(session["profile_id"])
with get_db() as conn:
cur = get_cursor(conn)
# Issue #31: Apply global quality filter (profile from DB = saved level)
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
profile = r2d(cur.fetchone())
quality_filter = get_quality_filter_sql(profile)
quality_filter = get_quality_filter_sql(profile or {})
if days is not None:
cur.execute(
@ -229,13 +229,23 @@ def get_activity_session(
@router.get("/stats")
def activity_stats(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
def activity_stats(session: dict = Depends(require_auth)):
"""Get activity statistics (last 30 entries)."""
pid = get_pid(x_profile_id)
pid = str(session["profile_id"])
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
profile = r2d(cur.fetchone())
quality_filter = get_quality_filter_sql(profile or {})
cur.execute(
"SELECT * FROM activity_log WHERE profile_id=%s ORDER BY date DESC LIMIT 30", (pid,))
f"""
SELECT * FROM activity_log
WHERE profile_id=%s {quality_filter}
ORDER BY date DESC
LIMIT 30
""",
(pid,),
)
rows = [r2d(r) for r in cur.fetchall()]
if not rows: return {"count":0,"total_kcal":0,"total_min":0,"by_type":{}}
total_kcal=sum(float(r.get('kcal_active') or 0) for r in rows)

View File

@ -30,6 +30,18 @@ class TypeParameterCreate(BaseModel):
ui_group: Optional[str] = Field(None, max_length=50)
class CategoryParameterUpdate(BaseModel):
sort_order: Optional[int] = None
required: Optional[bool] = None
ui_group: Optional[str] = Field(None, max_length=50)
class TypeParameterUpdate(BaseModel):
sort_order: Optional[int] = None
required: Optional[bool] = None
ui_group: Optional[str] = Field(None, max_length=50)
@router.get("/training-category-parameters")
def admin_list_category_parameters(
category: Optional[str] = Query(None, description="Filter: training_types.category"),
@ -91,6 +103,41 @@ def admin_add_category_parameter(
return {"id": new_id}
@router.put("/training-category-parameters/{link_id}")
def admin_update_category_parameter(
link_id: int,
body: CategoryParameterUpdate,
session: dict = Depends(require_admin),
):
patch = body.model_dump(exclude_unset=True)
if not patch:
raise HTTPException(400, "Keine Felder zum Aktualisieren")
cols: list[str] = []
vals: list = []
if "sort_order" in patch:
cols.append("sort_order = %s")
vals.append(patch["sort_order"])
if "required" in patch:
cols.append("required = %s")
vals.append(patch["required"])
if "ui_group" in patch:
cols.append("ui_group = %s")
vals.append(patch["ui_group"].strip() if patch["ui_group"] else None)
if not cols:
raise HTTPException(400, "Keine Felder zum Aktualisieren")
vals.append(link_id)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
f"UPDATE training_category_parameter SET {', '.join(cols)} WHERE id = %s RETURNING id",
vals,
)
if not cur.fetchone():
raise HTTPException(404, "Eintrag nicht gefunden")
conn.commit()
return {"ok": True, "id": link_id}
@router.delete("/training-category-parameters/{link_id}")
def admin_delete_category_parameter(
link_id: int,
@ -167,6 +214,41 @@ def admin_add_type_parameter(
return {"id": new_id}
@router.put("/training-type-parameters/{link_id}")
def admin_update_type_parameter(
link_id: int,
body: TypeParameterUpdate,
session: dict = Depends(require_admin),
):
patch = body.model_dump(exclude_unset=True)
if not patch:
raise HTTPException(400, "Keine Felder zum Aktualisieren")
cols: list[str] = []
vals: list = []
if "sort_order" in patch:
cols.append("sort_order = %s")
vals.append(patch["sort_order"])
if "required" in patch:
cols.append("required = %s")
vals.append(patch["required"])
if "ui_group" in patch:
cols.append("ui_group = %s")
vals.append(patch["ui_group"].strip() if patch["ui_group"] else None)
if not cols:
raise HTTPException(400, "Keine Felder zum Aktualisieren")
vals.append(link_id)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
f"UPDATE training_type_parameter SET {', '.join(cols)} WHERE id = %s RETURNING id",
vals,
)
if not cur.fetchone():
raise HTTPException(404, "Eintrag nicht gefunden")
conn.commit()
return {"ok": True, "id": link_id}
@router.delete("/training-type-parameters/{link_id}")
def admin_delete_type_parameter(
link_id: int,

View File

@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from 'react'
import { Link } from 'react-router-dom'
import { Plus, Trash2, Save, RefreshCw } from 'lucide-react'
import { Plus, Trash2, Save, RefreshCw, Pencil } from 'lucide-react'
import { api } from '../utils/api'
const PARAM_GROUP = ['physical', 'physiological', 'subjective', 'environmental', 'performance']
@ -18,6 +18,7 @@ const emptyParamForm = () => ({
})
export default function AdminActivityAttributeProfilesPage() {
const [tab, setTab] = useState('catalog')
const [params, setParams] = useState([])
const [includeInactive, setIncludeInactive] = useState(false)
const [catMeta, setCatMeta] = useState({})
@ -28,6 +29,7 @@ export default function AdminActivityAttributeProfilesPage() {
const [showParamForm, setShowParamForm] = useState(false)
const [paramForm, setParamForm] = useState(emptyParamForm())
const [editParam, setEditParam] = useState(null)
const [selCategory, setSelCategory] = useState('cardio')
const [catLinks, setCatLinks] = useState([])
@ -37,6 +39,8 @@ export default function AdminActivityAttributeProfilesPage() {
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([])
@ -46,6 +50,8 @@ export default function AdminActivityAttributeProfilesPage() {
required: '',
ui_group: '',
})
const [editingTypeId, setEditingTypeId] = useState(null)
const [typeDraft, setTypeDraft] = useState({ sort_order: '', required: '', ui_group: '' })
const showToast = (msg) => {
setToast(msg)
@ -108,6 +114,11 @@ export default function AdminActivityAttributeProfilesPage() {
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()) {
@ -135,6 +146,28 @@ export default function AdminActivityAttributeProfilesPage() {
}
}
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 {
@ -168,6 +201,21 @@ export default function AdminActivityAttributeProfilesPage() {
}
}
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)
@ -179,7 +227,8 @@ export default function AdminActivityAttributeProfilesPage() {
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,
required:
typeAdd.required === '' ? null : typeAdd.required === 'true' || typeAdd.required === true,
ui_group: typeAdd.ui_group.trim() || null,
}
try {
@ -192,10 +241,28 @@ export default function AdminActivityAttributeProfilesPage() {
}
}
const categoryKeys =
Object.keys(catMeta).length > 0
? Object.keys(catMeta).sort()
: ['cardio', 'strength', 'hiit', 'martial_arts', 'mobility', 'recovery', 'other']
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 (
@ -213,11 +280,30 @@ export default function AdminActivityAttributeProfilesPage() {
</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>
<div
className="card section-gap"
style={{ background: 'var(--surface2)', border: '1px solid var(--border)', fontSize: 13, lineHeight: 1.55 }}
>
<strong>Hinweise</strong>
<ul style={{ margin: '8px 0 0', paddingLeft: 18 }}>
<li>
<strong>Daten:</strong> Offizielle Migrationen löschen keine Zeilen in <code>activity_log</code>. Leere
Tabellen nach Deploy deuten auf neues DB-Volume, manuelles SQL oder falsche Umgebung hin vor Prod immer{' '}
<code>pg_dump</code>.
</li>
<li>
<strong>Doppelzuordnung:</strong> Dieselbe Metrik darf <em>eine</em> Zeile pro Kategorie und <em>eine</em>{' '}
pro Trainingstyp haben (Unique-Constraint). Wenn dieselbe Metrik in <strong>Kategorie</strong> und{' '}
<strong>Trainingstyp</strong> vorkommt, überschreibt die <strong>Typ-Zeile</strong> sort/required/ui_group
(Merge in Layer&nbsp;1) kein Datenfehler.
</li>
<li>
Nach Migration <strong>055</strong> werden Standard-Parameter allen Kategorien zugeordnet und vorhandene{' '}
<code>activity_log</code>-Spalten idempotent nach EAV gespiegelt (sofern noch keine EAV-Zeile existiert).
</li>
</ul>
</div>
{toast && (
<div
@ -239,6 +325,24 @@ export default function AdminActivityAttributeProfilesPage() {
</div>
)}
<div className="tabs" style={{ marginBottom: 16, overflowX: 'auto' }}>
{[
['catalog', 'Katalog'],
['category', 'Kategorie'],
['type', 'Trainingstyp'],
].map(([id, label]) => (
<button
key={id}
type="button"
className={'tab' + (tab === id ? ' active' : '')}
onClick={() => setTab(id)}
>
{label}
</button>
))}
</div>
{tab === 'catalog' && (
<div className="card section-gap">
<div className="card-title" style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span>Parameter-Katalog</span>
@ -248,13 +352,13 @@ export default function AdminActivityAttributeProfilesPage() {
checked={includeInactive}
onChange={(e) => setIncludeInactive(e.target.checked)}
/>
Inaktive anzeigen
Inaktive
</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
<Plus size={14} /> Neu
</button>
</div>
@ -283,13 +387,11 @@ export default function AdminActivityAttributeProfilesPage() {
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">
@ -321,13 +423,11 @@ export default function AdminActivityAttributeProfilesPage() {
<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 }))}
/>
@ -343,6 +443,88 @@ export default function AdminActivityAttributeProfilesPage() {
</div>
)}
{editParam && (
<div
style={{
border: '1px solid var(--accent)',
borderRadius: 8,
padding: 12,
marginBottom: 12,
}}
>
<div className="card-title" style={{ fontSize: 14 }}>
Bearbeiten: <code>{editParam.key}</code>
</div>
<div className="form-row">
<label className="form-label">name_de / name_en</label>
<input
className="form-input"
value={editParam.name_de || ''}
onChange={(e) => setEditParam((p) => ({ ...p, name_de: e.target.value }))}
/>
<input
className="form-input"
value={editParam.name_en || ''}
onChange={(e) => setEditParam((p) => ({ ...p, name_en: e.target.value }))}
/>
</div>
<div className="form-row">
<label className="form-label">Gruppe / Typ</label>
<select
className="form-input"
value={editParam.category}
onChange={(e) => setEditParam((p) => ({ ...p, category: e.target.value }))}
>
{PARAM_GROUP.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
<select
className="form-input"
value={editParam.data_type}
onChange={(e) => setEditParam((p) => ({ ...p, 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"
value={editParam.unit || ''}
onChange={(e) => setEditParam((p) => ({ ...p, unit: e.target.value }))}
/>
<input
className="form-input"
value={editParam.source_field || ''}
onChange={(e) => setEditParam((p) => ({ ...p, source_field: e.target.value }))}
/>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, marginBottom: 8 }}>
<input
type="checkbox"
checked={!!editParam.is_active}
onChange={(e) => setEditParam((p) => ({ ...p, is_active: e.target.checked }))}
/>
aktiv
</label>
<div style={{ display: 'flex', gap: 8 }}>
<button type="button" className="btn btn-primary" onClick={saveEditedParameter}>
<Save size={14} /> Speichern
</button>
<button type="button" className="btn btn-secondary" onClick={() => setEditParam(null)}>
Abbrechen
</button>
</div>
</div>
)}
<div style={{ overflowX: 'auto' }}>
<table className="data-table" style={{ width: '100%', fontSize: 13 }}>
<thead>
@ -368,6 +550,15 @@ export default function AdminActivityAttributeProfilesPage() {
</td>
<td>{r.is_active === false ? 'nein' : 'ja'}</td>
<td>
<div style={{ display: 'flex', gap: 4 }}>
<button
type="button"
className="btn btn-secondary"
style={{ padding: '4px 8px' }}
onClick={() => setEditParam({ ...r })}
>
<Pencil size={14} />
</button>
{r.is_active !== false && (
<button
type="button"
@ -378,6 +569,7 @@ export default function AdminActivityAttributeProfilesPage() {
<Trash2 size={14} />
</button>
)}
</div>
</td>
</tr>
))}
@ -385,7 +577,9 @@ export default function AdminActivityAttributeProfilesPage() {
</table>
</div>
</div>
)}
{tab === 'category' && (
<div className="card section-gap">
<div className="card-title">Zuordnung: Trainings-Kategorie</div>
<div className="form-row">
@ -455,19 +649,71 @@ export default function AdminActivityAttributeProfilesPage() {
<li
key={l.id}
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '8px 0',
padding: '10px 0',
borderBottom: '1px solid var(--border)',
fontSize: 13,
}}
>
{editingCatId === l.id ? (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'flex-end' }}>
<span style={{ flex: '1 1 200px' }}>
<strong>{l.parameter_key}</strong> · {l.parameter_name_de}
</span>
<div>
<label className="form-label">sort</label>
<input
type="number"
className="form-input"
style={{ width: 72 }}
value={catDraft.sort_order}
onChange={(e) => setCatDraft((d) => ({ ...d, sort_order: e.target.value }))}
/>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<input
type="checkbox"
checked={!!catDraft.required}
onChange={(e) => setCatDraft((d) => ({ ...d, required: e.target.checked }))}
/>
Pflicht
</label>
<input
className="form-input"
style={{ width: 100 }}
placeholder="ui_group"
value={catDraft.ui_group}
onChange={(e) => setCatDraft((d) => ({ ...d, ui_group: e.target.value }))}
/>
<button type="button" className="btn btn-primary" onClick={() => saveCatLink(l.id)}>
Speichern
</button>
<button type="button" className="btn btn-secondary" onClick={() => setEditingCatId(null)}>
Abbrechen
</button>
</div>
) : (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8 }}>
<span>
<strong>{l.parameter_key}</strong> · {l.parameter_name_de} · sort {l.sort_order}
{l.required ? ' · Pflicht' : ''}
{l.ui_group ? ` · ${l.ui_group}` : ''}
</span>
<div style={{ display: 'flex', gap: 4 }}>
<button
type="button"
className="btn btn-secondary"
style={{ padding: '4px 8px' }}
onClick={() => {
setEditingCatId(l.id)
setCatDraft({
sort_order: l.sort_order,
required: !!l.required,
ui_group: l.ui_group || '',
})
}}
>
<Pencil size={14} />
</button>
<button
type="button"
className="btn btn-danger"
@ -481,11 +727,16 @@ export default function AdminActivityAttributeProfilesPage() {
>
<Trash2 size={14} />
</button>
</div>
</div>
)}
</li>
))}
</ul>
</div>
)}
{tab === 'type' && (
<div className="card section-gap">
<div className="card-title">Zuordnung: Trainingstyp (Zusatz / Override)</div>
<div className="form-row">
@ -560,18 +811,81 @@ export default function AdminActivityAttributeProfilesPage() {
<li
key={l.id}
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '8px 0',
padding: '10px 0',
borderBottom: '1px solid var(--border)',
fontSize: 13,
}}
>
{editingTypeId === l.id ? (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'flex-end' }}>
<span style={{ flex: '1 1 200px' }}>
<strong>{l.parameter_key}</strong>
</span>
<div>
<label className="form-label">sort</label>
<input
type="number"
className="form-input"
style={{ width: 72 }}
value={typeDraft.sort_order}
onChange={(e) => setTypeDraft((d) => ({ ...d, sort_order: e.target.value }))}
/>
</div>
<select
className="form-input"
style={{ width: 100 }}
value={typeDraft.required}
onChange={(e) => setTypeDraft((d) => ({ ...d, required: e.target.value }))}
>
<option value="">Erben</option>
<option value="true">ja</option>
<option value="false">nein</option>
</select>
<input
className="form-input"
style={{ width: 100 }}
placeholder="ui_group"
value={typeDraft.ui_group}
onChange={(e) => setTypeDraft((d) => ({ ...d, ui_group: e.target.value }))}
/>
<button type="button" className="btn btn-primary" onClick={() => saveTypeLink(l.id)}>
Speichern
</button>
<button type="button" className="btn btn-secondary" onClick={() => setEditingTypeId(null)}>
Abbrechen
</button>
</div>
) : (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8 }}>
<span>
<strong>{l.parameter_key}</strong> · sort {l.sort_order ?? '—'} · Pflicht{' '}
{l.required === null || l.required === undefined ? '—' : l.required ? 'ja' : 'nein'}
{l.required === null || l.required === undefined
? '—'
: l.required
? 'ja'
: 'nein'}
</span>
<div style={{ display: 'flex', gap: 4 }}>
<button
type="button"
className="btn btn-secondary"
style={{ padding: '4px 8px' }}
onClick={() => {
setEditingTypeId(l.id)
setTypeDraft({
sort_order: l.sort_order ?? '',
required:
l.required === null || l.required === undefined
? ''
: l.required
? 'true'
: 'false',
ui_group: l.ui_group || '',
})
}}
>
<Pencil size={14} />
</button>
<button
type="button"
className="btn btn-danger"
@ -585,10 +899,14 @@ export default function AdminActivityAttributeProfilesPage() {
>
<Trash2 size={14} />
</button>
</div>
</div>
)}
</li>
))}
</ul>
</div>
)}
</div>
)
}

View File

@ -326,11 +326,15 @@ export const api = {
`/admin/training-category-parameters${category ? `?category=${encodeURIComponent(category)}` : ''}`,
),
adminAddTrainingCategoryParameter: (d) => req('/admin/training-category-parameters', json(d)),
adminUpdateTrainingCategoryParameter: (id, d) =>
req(`/admin/training-category-parameters/${id}`, jput(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)),
adminUpdateTrainingTypeParameter: (id, d) =>
req(`/admin/training-type-parameters/${id}`, jput(d)),
adminDeleteTrainingTypeParameter: (id) =>
req(`/admin/training-type-parameters/${id}`, { method: 'DELETE' }),