Erste Version - Universal CSV Importer für EAV und activity_log #85
|
|
@ -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 $$;
|
||||||
|
|
@ -35,18 +35,18 @@ logger = logging.getLogger(__name__)
|
||||||
def list_activity(
|
def list_activity(
|
||||||
limit: int = Query(200, ge=1, le=50_000),
|
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)"),
|
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),
|
session: dict = Depends(require_auth),
|
||||||
):
|
):
|
||||||
"""Get activity entries for current profile. Optional *days* filter by calendar window (not the same as *limit*)."""
|
"""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:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
|
|
||||||
# Issue #31: Apply global quality filter (profile from DB = saved level)
|
# Issue #31: Apply global quality filter (profile from DB = saved level)
|
||||||
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
|
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
|
||||||
profile = r2d(cur.fetchone())
|
profile = r2d(cur.fetchone())
|
||||||
quality_filter = get_quality_filter_sql(profile)
|
quality_filter = get_quality_filter_sql(profile or {})
|
||||||
|
|
||||||
if days is not None:
|
if days is not None:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
|
|
@ -229,13 +229,23 @@ def get_activity_session(
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stats")
|
@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)."""
|
"""Get activity statistics (last 30 entries)."""
|
||||||
pid = get_pid(x_profile_id)
|
pid = str(session["profile_id"])
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(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(
|
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()]
|
rows = [r2d(r) for r in cur.fetchall()]
|
||||||
if not rows: return {"count":0,"total_kcal":0,"total_min":0,"by_type":{}}
|
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)
|
total_kcal=sum(float(r.get('kcal_active') or 0) for r in rows)
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,18 @@ class TypeParameterCreate(BaseModel):
|
||||||
ui_group: Optional[str] = Field(None, max_length=50)
|
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")
|
@router.get("/training-category-parameters")
|
||||||
def admin_list_category_parameters(
|
def admin_list_category_parameters(
|
||||||
category: Optional[str] = Query(None, description="Filter: training_types.category"),
|
category: Optional[str] = Query(None, description="Filter: training_types.category"),
|
||||||
|
|
@ -91,6 +103,41 @@ def admin_add_category_parameter(
|
||||||
return {"id": new_id}
|
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}")
|
@router.delete("/training-category-parameters/{link_id}")
|
||||||
def admin_delete_category_parameter(
|
def admin_delete_category_parameter(
|
||||||
link_id: int,
|
link_id: int,
|
||||||
|
|
@ -167,6 +214,41 @@ def admin_add_type_parameter(
|
||||||
return {"id": new_id}
|
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}")
|
@router.delete("/training-type-parameters/{link_id}")
|
||||||
def admin_delete_type_parameter(
|
def admin_delete_type_parameter(
|
||||||
link_id: int,
|
link_id: int,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
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'
|
import { api } from '../utils/api'
|
||||||
|
|
||||||
const PARAM_GROUP = ['physical', 'physiological', 'subjective', 'environmental', 'performance']
|
const PARAM_GROUP = ['physical', 'physiological', 'subjective', 'environmental', 'performance']
|
||||||
|
|
@ -18,6 +18,7 @@ const emptyParamForm = () => ({
|
||||||
})
|
})
|
||||||
|
|
||||||
export default function AdminActivityAttributeProfilesPage() {
|
export default function AdminActivityAttributeProfilesPage() {
|
||||||
|
const [tab, setTab] = useState('catalog')
|
||||||
const [params, setParams] = useState([])
|
const [params, setParams] = useState([])
|
||||||
const [includeInactive, setIncludeInactive] = useState(false)
|
const [includeInactive, setIncludeInactive] = useState(false)
|
||||||
const [catMeta, setCatMeta] = useState({})
|
const [catMeta, setCatMeta] = useState({})
|
||||||
|
|
@ -28,6 +29,7 @@ export default function AdminActivityAttributeProfilesPage() {
|
||||||
|
|
||||||
const [showParamForm, setShowParamForm] = useState(false)
|
const [showParamForm, setShowParamForm] = useState(false)
|
||||||
const [paramForm, setParamForm] = useState(emptyParamForm())
|
const [paramForm, setParamForm] = useState(emptyParamForm())
|
||||||
|
const [editParam, setEditParam] = useState(null)
|
||||||
|
|
||||||
const [selCategory, setSelCategory] = useState('cardio')
|
const [selCategory, setSelCategory] = useState('cardio')
|
||||||
const [catLinks, setCatLinks] = useState([])
|
const [catLinks, setCatLinks] = useState([])
|
||||||
|
|
@ -37,6 +39,8 @@ export default function AdminActivityAttributeProfilesPage() {
|
||||||
required: false,
|
required: false,
|
||||||
ui_group: '',
|
ui_group: '',
|
||||||
})
|
})
|
||||||
|
const [editingCatId, setEditingCatId] = useState(null)
|
||||||
|
const [catDraft, setCatDraft] = useState({ sort_order: 0, required: false, ui_group: '' })
|
||||||
|
|
||||||
const [selTypeId, setSelTypeId] = useState('')
|
const [selTypeId, setSelTypeId] = useState('')
|
||||||
const [typeLinks, setTypeLinks] = useState([])
|
const [typeLinks, setTypeLinks] = useState([])
|
||||||
|
|
@ -46,6 +50,8 @@ export default function AdminActivityAttributeProfilesPage() {
|
||||||
required: '',
|
required: '',
|
||||||
ui_group: '',
|
ui_group: '',
|
||||||
})
|
})
|
||||||
|
const [editingTypeId, setEditingTypeId] = useState(null)
|
||||||
|
const [typeDraft, setTypeDraft] = useState({ sort_order: '', required: '', ui_group: '' })
|
||||||
|
|
||||||
const showToast = (msg) => {
|
const showToast = (msg) => {
|
||||||
setToast(msg)
|
setToast(msg)
|
||||||
|
|
@ -108,6 +114,11 @@ export default function AdminActivityAttributeProfilesPage() {
|
||||||
|
|
||||||
const activeParams = params.filter((p) => p.is_active !== false)
|
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 () => {
|
const saveNewParameter = async () => {
|
||||||
setError(null)
|
setError(null)
|
||||||
if (!paramForm.key.trim() || !paramForm.name_de.trim() || !paramForm.name_en.trim()) {
|
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) => {
|
const deactivateParameter = async (id) => {
|
||||||
if (!confirm('Parameter deaktivieren? (Bestehende EAV-Zeilen bleiben erhalten.)')) return
|
if (!confirm('Parameter deaktivieren? (Bestehende EAV-Zeilen bleiben erhalten.)')) return
|
||||||
try {
|
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 addTypeLink = async () => {
|
||||||
const tid = Number(selTypeId)
|
const tid = Number(selTypeId)
|
||||||
const pid = parseInt(typeAdd.training_parameter_id, 10)
|
const pid = parseInt(typeAdd.training_parameter_id, 10)
|
||||||
|
|
@ -179,7 +227,8 @@ export default function AdminActivityAttributeProfilesPage() {
|
||||||
training_type_id: tid,
|
training_type_id: tid,
|
||||||
training_parameter_id: pid,
|
training_parameter_id: pid,
|
||||||
sort_order: typeAdd.sort_order === '' ? null : Number(typeAdd.sort_order),
|
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,
|
ui_group: typeAdd.ui_group.trim() || null,
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|
@ -192,10 +241,28 @@ export default function AdminActivityAttributeProfilesPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const categoryKeys =
|
const saveTypeLink = async (linkId) => {
|
||||||
Object.keys(catMeta).length > 0
|
try {
|
||||||
? Object.keys(catMeta).sort()
|
const payload = {}
|
||||||
: ['cardio', 'strength', 'hiit', 'martial_arts', 'mobility', 'recovery', 'other']
|
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) {
|
if (loading && !params.length) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -213,11 +280,30 @@ export default function AdminActivityAttributeProfilesPage() {
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="page-title">Session-Metriken (EAV)</h1>
|
<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.{' '}
|
<div
|
||||||
<strong>Trainingstyp</strong> (Zusatz/Override). Nutzer sehen die Felder beim Bearbeiten einer
|
className="card section-gap"
|
||||||
Aktivität, wenn der Eintrag passend kategorisiert ist.
|
style={{ background: 'var(--surface2)', border: '1px solid var(--border)', fontSize: 13, lineHeight: 1.55 }}
|
||||||
</p>
|
>
|
||||||
|
<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 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 && (
|
{toast && (
|
||||||
<div
|
<div
|
||||||
|
|
@ -239,6 +325,24 @@ export default function AdminActivityAttributeProfilesPage() {
|
||||||
</div>
|
</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 section-gap">
|
||||||
<div className="card-title" style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
<div className="card-title" style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||||
<span>Parameter-Katalog</span>
|
<span>Parameter-Katalog</span>
|
||||||
|
|
@ -248,13 +352,13 @@ export default function AdminActivityAttributeProfilesPage() {
|
||||||
checked={includeInactive}
|
checked={includeInactive}
|
||||||
onChange={(e) => setIncludeInactive(e.target.checked)}
|
onChange={(e) => setIncludeInactive(e.target.checked)}
|
||||||
/>
|
/>
|
||||||
Inaktive anzeigen
|
Inaktive
|
||||||
</label>
|
</label>
|
||||||
<button type="button" className="btn btn-secondary" onClick={() => refreshCatalog()}>
|
<button type="button" className="btn btn-secondary" onClick={() => refreshCatalog()}>
|
||||||
<RefreshCw size={14} /> Neu laden
|
<RefreshCw size={14} /> Neu laden
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="btn btn-primary" onClick={() => setShowParamForm((v) => !v)}>
|
<button type="button" className="btn btn-primary" onClick={() => setShowParamForm((v) => !v)}>
|
||||||
<Plus size={14} /> Neuer Parameter
|
<Plus size={14} /> Neu
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -283,13 +387,11 @@ export default function AdminActivityAttributeProfilesPage() {
|
||||||
className="form-input"
|
className="form-input"
|
||||||
value={paramForm.name_de}
|
value={paramForm.name_de}
|
||||||
onChange={(e) => setParamForm((f) => ({ ...f, name_de: e.target.value }))}
|
onChange={(e) => setParamForm((f) => ({ ...f, name_de: e.target.value }))}
|
||||||
placeholder="DE"
|
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
className="form-input"
|
className="form-input"
|
||||||
value={paramForm.name_en}
|
value={paramForm.name_en}
|
||||||
onChange={(e) => setParamForm((f) => ({ ...f, name_en: e.target.value }))}
|
onChange={(e) => setParamForm((f) => ({ ...f, name_en: e.target.value }))}
|
||||||
placeholder="EN"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
|
|
@ -321,13 +423,11 @@ export default function AdminActivityAttributeProfilesPage() {
|
||||||
<label className="form-label">Einheit / source_field</label>
|
<label className="form-label">Einheit / source_field</label>
|
||||||
<input
|
<input
|
||||||
className="form-input"
|
className="form-input"
|
||||||
placeholder="z. B. W"
|
|
||||||
value={paramForm.unit}
|
value={paramForm.unit}
|
||||||
onChange={(e) => setParamForm((f) => ({ ...f, unit: e.target.value }))}
|
onChange={(e) => setParamForm((f) => ({ ...f, unit: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
className="form-input"
|
className="form-input"
|
||||||
placeholder="activity_log-Spalte (optional)"
|
|
||||||
value={paramForm.source_field}
|
value={paramForm.source_field}
|
||||||
onChange={(e) => setParamForm((f) => ({ ...f, source_field: e.target.value }))}
|
onChange={(e) => setParamForm((f) => ({ ...f, source_field: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
|
|
@ -343,6 +443,88 @@ export default function AdminActivityAttributeProfilesPage() {
|
||||||
</div>
|
</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' }}>
|
<div style={{ overflowX: 'auto' }}>
|
||||||
<table className="data-table" style={{ width: '100%', fontSize: 13 }}>
|
<table className="data-table" style={{ width: '100%', fontSize: 13 }}>
|
||||||
<thead>
|
<thead>
|
||||||
|
|
@ -368,6 +550,15 @@ export default function AdminActivityAttributeProfilesPage() {
|
||||||
</td>
|
</td>
|
||||||
<td>{r.is_active === false ? 'nein' : 'ja'}</td>
|
<td>{r.is_active === false ? 'nein' : 'ja'}</td>
|
||||||
<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 && (
|
{r.is_active !== false && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -378,6 +569,7 @@ export default function AdminActivityAttributeProfilesPage() {
|
||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|
@ -385,7 +577,9 @@ export default function AdminActivityAttributeProfilesPage() {
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'category' && (
|
||||||
<div className="card section-gap">
|
<div className="card section-gap">
|
||||||
<div className="card-title">Zuordnung: Trainings-Kategorie</div>
|
<div className="card-title">Zuordnung: Trainings-Kategorie</div>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
|
|
@ -455,19 +649,71 @@ export default function AdminActivityAttributeProfilesPage() {
|
||||||
<li
|
<li
|
||||||
key={l.id}
|
key={l.id}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
padding: '10px 0',
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
padding: '8px 0',
|
|
||||||
borderBottom: '1px solid var(--border)',
|
borderBottom: '1px solid var(--border)',
|
||||||
fontSize: 13,
|
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>
|
<span>
|
||||||
<strong>{l.parameter_key}</strong> · {l.parameter_name_de} · sort {l.sort_order}
|
<strong>{l.parameter_key}</strong> · {l.parameter_name_de} · sort {l.sort_order}
|
||||||
{l.required ? ' · Pflicht' : ''}
|
{l.required ? ' · Pflicht' : ''}
|
||||||
{l.ui_group ? ` · ${l.ui_group}` : ''}
|
{l.ui_group ? ` · ${l.ui_group}` : ''}
|
||||||
</span>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-danger"
|
className="btn btn-danger"
|
||||||
|
|
@ -481,11 +727,16 @@ export default function AdminActivityAttributeProfilesPage() {
|
||||||
>
|
>
|
||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'type' && (
|
||||||
<div className="card section-gap">
|
<div className="card section-gap">
|
||||||
<div className="card-title">Zuordnung: Trainingstyp (Zusatz / Override)</div>
|
<div className="card-title">Zuordnung: Trainingstyp (Zusatz / Override)</div>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
|
|
@ -560,18 +811,81 @@ export default function AdminActivityAttributeProfilesPage() {
|
||||||
<li
|
<li
|
||||||
key={l.id}
|
key={l.id}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
padding: '10px 0',
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
padding: '8px 0',
|
|
||||||
borderBottom: '1px solid var(--border)',
|
borderBottom: '1px solid var(--border)',
|
||||||
fontSize: 13,
|
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>
|
<span>
|
||||||
<strong>{l.parameter_key}</strong> · sort {l.sort_order ?? '—'} · Pflicht{' '}
|
<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>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-danger"
|
className="btn btn-danger"
|
||||||
|
|
@ -585,10 +899,14 @@ export default function AdminActivityAttributeProfilesPage() {
|
||||||
>
|
>
|
||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -326,11 +326,15 @@ export const api = {
|
||||||
`/admin/training-category-parameters${category ? `?category=${encodeURIComponent(category)}` : ''}`,
|
`/admin/training-category-parameters${category ? `?category=${encodeURIComponent(category)}` : ''}`,
|
||||||
),
|
),
|
||||||
adminAddTrainingCategoryParameter: (d) => req('/admin/training-category-parameters', json(d)),
|
adminAddTrainingCategoryParameter: (d) => req('/admin/training-category-parameters', json(d)),
|
||||||
|
adminUpdateTrainingCategoryParameter: (id, d) =>
|
||||||
|
req(`/admin/training-category-parameters/${id}`, jput(d)),
|
||||||
adminDeleteTrainingCategoryParameter: (id) =>
|
adminDeleteTrainingCategoryParameter: (id) =>
|
||||||
req(`/admin/training-category-parameters/${id}`, { method: 'DELETE' }),
|
req(`/admin/training-category-parameters/${id}`, { method: 'DELETE' }),
|
||||||
adminListTrainingTypeParameters: (trainingTypeId) =>
|
adminListTrainingTypeParameters: (trainingTypeId) =>
|
||||||
req(`/admin/training-type-parameters?training_type_id=${encodeURIComponent(trainingTypeId)}`),
|
req(`/admin/training-type-parameters?training_type_id=${encodeURIComponent(trainingTypeId)}`),
|
||||||
adminAddTrainingTypeParameter: (d) => req('/admin/training-type-parameters', json(d)),
|
adminAddTrainingTypeParameter: (d) => req('/admin/training-type-parameters', json(d)),
|
||||||
|
adminUpdateTrainingTypeParameter: (id, d) =>
|
||||||
|
req(`/admin/training-type-parameters/${id}`, jput(d)),
|
||||||
adminDeleteTrainingTypeParameter: (id) =>
|
adminDeleteTrainingTypeParameter: (id) =>
|
||||||
req(`/admin/training-type-parameters/${id}`, { method: 'DELETE' }),
|
req(`/admin/training-type-parameters/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user