chore: update versioning and enhance Training Framework features
Some checks failed
Deploy Development / deploy (push) Successful in 34s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 44s

- Incremented APP_VERSION to 0.8.9 and DB_SCHEMA_VERSION to 20260505036.
- Added changelog entry for version 0.8.9 detailing database and API changes.
- Updated TrainingFrameworkProgramEditPage to manage focus areas, style directions, training types, and target groups.
- Enhanced TrainingFrameworkProgramsListPage with context teasers for better user information.
- Improved CSS styles for framework catalog checkgrid and check components for better layout and usability.
This commit is contained in:
Lars 2026-05-05 13:11:17 +02:00
parent 8f32a6df29
commit b4495e39c1
7 changed files with 416 additions and 249 deletions

View File

@ -0,0 +1,64 @@
-- Migration 036: Rahmenprogramm — nur Bibliothek + Kontext-Stammdaten (Fokus, Stil, Typen, Zielgruppen)
-- Grund: Zuordnung zu Gruppen/Kalender nur aus der Planung (Kopie + Lineage), nicht am Rahmenkopf.
-- ── Kontext am Rahmenkopf (Zuordenbarkeit / Filter) ─────────────────────────
ALTER TABLE training_framework_programs
ADD COLUMN IF NOT EXISTS focus_area_id INT REFERENCES focus_areas(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS style_direction_id INT REFERENCES style_directions(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_training_framework_programs_focus ON training_framework_programs(focus_area_id);
CREATE INDEX IF NOT EXISTS idx_training_framework_programs_style ON training_framework_programs(style_direction_id);
-- ── M:N Trainingsstile (training_types Katalog) ─────────────────────────────
CREATE TABLE IF NOT EXISTS training_framework_program_training_types (
framework_program_id INT NOT NULL REFERENCES training_framework_programs(id) ON DELETE CASCADE,
training_type_id INT NOT NULL REFERENCES training_types(id) ON DELETE CASCADE,
PRIMARY KEY (framework_program_id, training_type_id)
);
CREATE INDEX IF NOT EXISTS idx_tfptt_type ON training_framework_program_training_types(training_type_id);
-- ── M:N Zielgruppen ─────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS training_framework_program_target_groups (
framework_program_id INT NOT NULL REFERENCES training_framework_programs(id) ON DELETE CASCADE,
target_group_id INT NOT NULL REFERENCES target_groups(id) ON DELETE CASCADE,
PRIMARY KEY (framework_program_id, target_group_id)
);
CREATE INDEX IF NOT EXISTS idx_tfptg_tg ON training_framework_program_target_groups(target_group_id);
-- ── Kein „Konkret“ mehr: Slots nicht an Kalender-Einheiten hängen ───────────
UPDATE training_framework_slots SET training_unit_id = NULL WHERE training_unit_id IS NOT NULL;
-- Gruppe/Modus vom Rahmen lösen (Historie: evtl. noch concrete + group_id gesetzt)
UPDATE training_framework_programs SET group_id = NULL;
DROP INDEX IF EXISTS idx_training_framework_programs_group;
ALTER TABLE training_framework_programs DROP CONSTRAINT IF EXISTS training_framework_programs_group_id_fkey;
ALTER TABLE training_framework_programs DROP COLUMN IF EXISTS group_id;
-- Inline-CHECK(s) aus Migration 035 (plan_mode + group-Kombination) entfernen
DO $$
DECLARE
r RECORD;
BEGIN
FOR r IN (
SELECT c.conname AS cn
FROM pg_constraint c
WHERE c.conrelid = 'public.training_framework_programs'::regclass
AND c.contype = 'c'
AND (
pg_get_constraintdef(c.oid) ILIKE '%plan_mode%'
OR pg_get_constraintdef(c.oid) ILIKE '%group_id%'
)
)
LOOP
EXECUTE format('ALTER TABLE training_framework_programs DROP CONSTRAINT %I', r.cn);
END LOOP;
END $$;
ALTER TABLE training_framework_programs DROP COLUMN IF EXISTS plan_mode;
DROP INDEX IF EXISTS idx_training_framework_programs_mode;

View File

@ -1,8 +1,11 @@
"""
Trainingsrahmenprogramm RahmenVorlage über mehrere SessionSlots (CURR002 Stufe 2).
AuthZ wie PlanungsVorlagen: Admin sieht alle, sonst nur eigene Artefakte; Schreibzugriff mit Planungsrolle.
Trainingsrahmenprogramm wiederverwendbare Vorlage (Bibliothek) über mehrere Session-Slots.
Zuordnung zu Trainingsgruppen / konkreten Einheiten erfolgt aus der Planung (Kopie + Lineage),
nicht über group_id oder training_unit_id am Rahmen.
AuthZ wie Planungs-Vorlagen: Admin sieht alle, sonst nur eigene Artefakte; Schreibzugriff mit Planungsrolle.
"""
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List, Optional, Sequence
from fastapi import APIRouter, Depends, HTTPException
@ -10,17 +13,12 @@ from auth import require_auth
from db import get_db, get_cursor, r2d
from routers.training_planning import (
_assert_training_unit_permission,
_can_access_group_for_create,
_has_planning_role,
_optional_positive_int,
_training_unit_guard_row,
_validate_variant_for_exercise,
)
router = APIRouter(prefix="/api", tags=["training_framework_programs"])
_VALID_PLAN_MODE = frozenset({"concrete", "library"})
_VALID_VISIBILITY = frozenset({"private", "club", "official"})
@ -54,6 +52,32 @@ def _fetch_slot_exercises(cur, slot_id: int) -> List[Dict[str, Any]]:
return [r2d(x) for x in cur.fetchall()]
def _training_type_ids(cur, framework_id: int) -> List[int]:
cur.execute(
"""
SELECT training_type_id
FROM training_framework_program_training_types
WHERE framework_program_id = %s
ORDER BY training_type_id
""",
(framework_id,),
)
return [r["training_type_id"] for r in cur.fetchall()]
def _target_group_ids(cur, framework_id: int) -> List[int]:
cur.execute(
"""
SELECT target_group_id
FROM training_framework_program_target_groups
WHERE framework_program_id = %s
ORDER BY target_group_id
""",
(framework_id,),
)
return [r["target_group_id"] for r in cur.fetchall()]
def _hydrate_framework(cur, row: Dict[str, Any]) -> Dict[str, Any]:
fid = row["id"]
cur.execute(
@ -68,7 +92,7 @@ def _hydrate_framework(cur, row: Dict[str, Any]) -> Dict[str, Any]:
row["goals"] = [r2d(g) for g in cur.fetchall()]
cur.execute(
"""
SELECT id, framework_program_id, sort_order, title, notes, training_unit_id
SELECT id, framework_program_id, sort_order, title, notes
FROM training_framework_slots
WHERE framework_program_id = %s
ORDER BY sort_order
@ -79,6 +103,8 @@ def _hydrate_framework(cur, row: Dict[str, Any]) -> Dict[str, Any]:
for s in slots:
s["exercises"] = _fetch_slot_exercises(cur, s["id"])
row["slots"] = slots
row["training_type_ids"] = _training_type_ids(cur, fid)
row["target_group_ids"] = _target_group_ids(cur, fid)
return row
@ -93,36 +119,55 @@ def _assert_visibility(val: Optional[str]) -> Optional[str]:
return val
def _assert_framework_invariants(plan_mode: str, group_id: Optional[int]) -> None:
if plan_mode == "library" and group_id is not None:
raise HTTPException(
status_code=400,
detail="plan_mode library erlaubt kein group_id",
def _parse_positive_int_ids(raw: Any, label: str) -> List[int]:
if raw is None:
return []
if not isinstance(raw, list):
raise HTTPException(status_code=400, detail=f"{label} muss eine Liste von IDs sein")
out: List[int] = []
for item in raw:
if item in (None, ""):
continue
try:
n = int(item)
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail=f"{label}: ungültige ID") from None
if n <= 0:
raise HTTPException(status_code=400, detail=f"{label}: ungültige ID")
if n not in out:
out.append(n)
return out
def _replace_training_types(cur, framework_id: int, ids: Sequence[int]) -> None:
cur.execute(
"DELETE FROM training_framework_program_training_types WHERE framework_program_id = %s",
(framework_id,),
)
for tid in ids:
cur.execute(
"""
INSERT INTO training_framework_program_training_types (framework_program_id, training_type_id)
VALUES (%s, %s)
ON CONFLICT DO NOTHING
""",
(framework_id, tid),
)
def _assert_slot_unit_constraints(
cur,
plan_mode: str,
framework_group_id: Optional[int],
training_unit_id: Optional[int],
profile_id: int,
role: str,
) -> None:
if plan_mode == "library" and training_unit_id:
raise HTTPException(
status_code=400,
detail="Im Bibliotheksmodus (library) keine Verknüpfung von Slots zu Trainingseinheiten",
)
if not training_unit_id:
return
uid = training_unit_id
unit_row = _training_unit_guard_row(cur, uid)
_assert_training_unit_permission(cur, unit_row, profile_id, role)
if framework_group_id is not None and unit_row["group_id"] != framework_group_id:
raise HTTPException(
status_code=400,
detail="training_unit_id muss zur group_id dieses Rahmens gehören",
def _replace_target_groups(cur, framework_id: int, ids: Sequence[int]) -> None:
cur.execute(
"DELETE FROM training_framework_program_target_groups WHERE framework_program_id = %s",
(framework_id,),
)
for gid in ids:
cur.execute(
"""
INSERT INTO training_framework_program_target_groups (framework_program_id, target_group_id)
VALUES (%s, %s)
ON CONFLICT DO NOTHING
""",
(framework_id, gid),
)
@ -149,11 +194,7 @@ def _insert_goal_rows(cur, framework_id: int, goals_in: List[Any]) -> None:
def _insert_slots_and_exercises(
cur,
framework_id: int,
plan_mode: str,
framework_group_id: Optional[int],
slots_in: Optional[List[Any]],
profile_id: int,
role: str,
) -> None:
if slots_in is None:
return
@ -164,14 +205,12 @@ def _insert_slots_and_exercises(
title_s = slot.get("title")
if title_s is not None:
title_s = title_s.strip() or None
unit_sid = _optional_positive_int(slot.get("training_unit_id"), "training_unit_id")
_assert_slot_unit_constraints(cur, plan_mode, framework_group_id, unit_sid, profile_id, role)
cur.execute(
"""
INSERT INTO training_framework_slots (
framework_program_id, sort_order, title, notes, training_unit_id
) VALUES (%s, %s, %s, %s, %s)
) VALUES (%s, %s, %s, %s, NULL)
RETURNING id
""",
(
@ -179,7 +218,6 @@ def _insert_slots_and_exercises(
int(order_ix),
title_s,
slot.get("notes"),
unit_sid,
),
)
sid = cur.fetchone()["id"]
@ -212,11 +250,19 @@ def list_training_framework_programs(session=Depends(require_auth)):
cur = get_cursor(conn)
base_sel = """
SELECT fp.*,
fa.name AS focus_area_name,
sd.name AS style_direction_name,
(SELECT COUNT(*)::int FROM training_framework_goals g WHERE g.framework_program_id = fp.id)
AS goals_count,
(SELECT COUNT(*)::int FROM training_framework_slots s WHERE s.framework_program_id = fp.id)
AS slots_count
AS slots_count,
(SELECT COUNT(*)::int FROM training_framework_program_training_types t
WHERE t.framework_program_id = fp.id) AS training_types_count,
(SELECT COUNT(*)::int FROM training_framework_program_target_groups tg
WHERE tg.framework_program_id = fp.id) AS target_groups_count
FROM training_framework_programs fp
LEFT JOIN focus_areas fa ON fa.id = fp.focus_area_id
LEFT JOIN style_directions sd ON sd.id = fp.style_direction_id
"""
if role in ("admin", "superadmin"):
cur.execute(base_sel + " ORDER BY fp.updated_at DESC NULLS LAST, fp.title")
@ -249,53 +295,48 @@ def create_training_framework_program(data: dict, session=Depends(require_auth))
if not title:
raise HTTPException(status_code=400, detail="title ist Pflicht")
plan_mode = (data.get("plan_mode") or "").strip().lower()
if plan_mode not in _VALID_PLAN_MODE:
raise HTTPException(status_code=400, detail="plan_mode muss concrete oder library sein")
gid = None
if data.get("group_id") not in (None, ""):
gid = _optional_positive_int(data.get("group_id"), "group_id")
_assert_framework_invariants(plan_mode, gid)
vis = data.get("visibility") or "private"
vis = _assert_visibility(vis)
club_id = data.get("club_id")
goals_in = data.get("goals")
slots_in = data.get("slots")
if not isinstance(goals_in, list) or not goals_in:
raise HTTPException(status_code=400, detail="goals als Liste mit mindestens einem Eintrag ist Pflicht")
fa_id = _optional_positive_int(data.get("focus_area_id"), "focus_area_id")
sd_id = _optional_positive_int(data.get("style_direction_id"), "style_direction_id")
tt_ids = _parse_positive_int_ids(data.get("training_type_ids"), "training_type_ids")
tg_ids = _parse_positive_int_ids(data.get("target_group_ids"), "target_group_ids")
with get_db() as conn:
cur = get_cursor(conn)
if gid is not None:
_can_access_group_for_create(cur, gid, profile_id, role)
cur.execute(
"""
INSERT INTO training_framework_programs (
title, description, plan_mode, group_id,
title, description,
planned_period_start, planned_period_end,
visibility, club_id, created_by
visibility, club_id, created_by,
focus_area_id, style_direction_id
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
""",
(
title[:200],
data.get("description"),
plan_mode,
gid,
data.get("planned_period_start"),
data.get("planned_period_end"),
vis,
club_id,
profile_id,
fa_id,
sd_id,
),
)
fid = cur.fetchone()["id"]
_insert_goal_rows(cur, fid, goals_in)
_insert_slots_and_exercises(cur, fid, plan_mode, gid, slots_in, profile_id, role)
_insert_slots_and_exercises(cur, fid, slots_in)
_replace_training_types(cur, fid, tt_ids)
_replace_target_groups(cur, fid, tg_ids)
conn.commit()
return get_training_framework_program(fid, session)
@ -310,46 +351,28 @@ def update_training_framework_program(framework_id: int, data: dict, session=Dep
with get_db() as conn:
cur = get_cursor(conn)
existing = _framework_access(cur, framework_id, profile_id, role)
plan_mode_new = existing["plan_mode"]
if "plan_mode" in data:
pm = (data.get("plan_mode") or "").strip().lower()
if pm not in _VALID_PLAN_MODE:
raise HTTPException(status_code=400, detail="plan_mode muss concrete oder library sein")
plan_mode_new = pm
group_id_eff = existing.get("group_id")
if "group_id" in data:
if data.get("group_id") in (None, ""):
group_id_eff = None
else:
group_id_eff = _optional_positive_int(data.get("group_id"), "group_id")
_assert_framework_invariants(plan_mode_new, group_id_eff)
_framework_access(cur, framework_id, profile_id, role)
header_fields = []
header_params: List[Any] = []
if "title" in data:
tit = (data.get("title") or "").strip()
if not tit:
raise HTTPException(status_code=400, detail="title ist Pflicht")
header_fields.append("title = %s")
header_params.append(tit[:200])
if "description" in data:
header_fields.append("description = %s")
header_params.append(data.get("description"))
if "plan_mode" in data:
header_fields.append("plan_mode = %s")
header_params.append(plan_mode_new)
if "group_id" in data:
header_fields.append("group_id = %s")
header_params.append(group_id_eff)
if "planned_period_start" in data:
header_fields.append("planned_period_start = %s")
header_params.append(data.get("planned_period_start"))
if "planned_period_end" in data:
header_fields.append("planned_period_end = %s")
header_params.append(data.get("planned_period_end"))
if "visibility" in data:
v = _assert_visibility(data.get("visibility"))
if v is None:
@ -360,11 +383,18 @@ def update_training_framework_program(framework_id: int, data: dict, session=Dep
header_fields.append("club_id = %s")
header_params.append(data.get("club_id"))
if group_id_eff is not None and (
("group_id" in data)
or (plan_mode_new == "concrete" and plan_mode_new != existing.get("plan_mode"))
):
_can_access_group_for_create(cur, group_id_eff, profile_id, role)
if "focus_area_id" in data:
fidv = data.get("focus_area_id")
header_fields.append("focus_area_id = %s")
header_params.append(
None if fidv in (None, "") else _optional_positive_int(fidv, "focus_area_id")
)
if "style_direction_id" in data:
sidv = data.get("style_direction_id")
header_fields.append("style_direction_id = %s")
header_params.append(
None if sidv in (None, "") else _optional_positive_int(sidv, "style_direction_id")
)
if header_fields:
header_fields.append("updated_at = NOW()")
@ -378,6 +408,14 @@ def update_training_framework_program(framework_id: int, data: dict, session=Dep
tuple(header_params),
)
if "training_type_ids" in data:
tt_ids = _parse_positive_int_ids(data.get("training_type_ids"), "training_type_ids")
_replace_training_types(cur, framework_id, tt_ids)
if "target_group_ids" in data:
tg_ids = _parse_positive_int_ids(data.get("target_group_ids"), "target_group_ids")
_replace_target_groups(cur, framework_id, tg_ids)
if "goals" in data:
goals_in = data["goals"]
if not isinstance(goals_in, list) or not goals_in:
@ -393,26 +431,9 @@ def update_training_framework_program(framework_id: int, data: dict, session=Dep
"DELETE FROM training_framework_slots WHERE framework_program_id = %s",
(framework_id,),
)
_insert_slots_and_exercises(
cur,
framework_id,
plan_mode_new,
group_id_eff,
data.get("slots") or [],
profile_id,
role,
)
_insert_slots_and_exercises(cur, framework_id, data.get("slots") or [])
if plan_mode_new == "library":
cur.execute(
"""
UPDATE training_framework_slots SET training_unit_id = NULL
WHERE framework_program_id = %s AND training_unit_id IS NOT NULL
""",
(framework_id,),
)
if "goals" in data or "slots" in data or header_fields:
if header_fields or "goals" in data or "slots" in data or "training_type_ids" in data or "target_group_ids" in data:
cur.execute(
"UPDATE training_framework_programs SET updated_at = NOW() WHERE id = %s",
(framework_id,),

View File

@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.8"
APP_VERSION = "0.8.9"
BUILD_DATE = "2026-05-05"
DB_SCHEMA_VERSION = "20260505035"
DB_SCHEMA_VERSION = "20260505036"
MODULE_VERSIONS = {
"auth": "1.0.0",
@ -23,6 +23,14 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.9",
"date": "2026-05-05",
"changes": [
"DB 036: Rahmenprogramm Kontext (Fokusbereich, Stilrichtung, M:N Trainingsarten & Zielgruppen); nur Bibliothek — plan_mode/group_id/Slot-training_unit entfernt.",
"API: /api/training-framework-programs ohne concrete/library; Payload focus_area_id, style_direction_id, training_type_ids, target_group_ids",
],
},
{
"version": "0.8.8",
"date": "2026-05-05",

View File

@ -2934,6 +2934,31 @@ a.analysis-split__nav-item {
background: var(--surface2);
}
.framework-catalog-checkgrid {
display: flex;
flex-wrap: wrap;
gap: 8px 18px;
max-height: 220px;
overflow-y: auto;
padding: 10px;
border: 1px solid var(--border2);
border-radius: 10px;
background: var(--surface2);
}
.framework-catalog-check {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.88rem;
cursor: pointer;
user-select: none;
}
.framework-catalog-check input {
accent-color: var(--accent);
}
.framework-popmenu {
position: absolute;
top: calc(100% + 4px);

View File

@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useState } from 'react'
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Link, useNavigate, useParams, useLocation } from 'react-router-dom'
import api from '../utils/api'
import ExercisePickerModal from '../components/ExercisePickerModal'
@ -28,7 +28,7 @@ function emptyExercise() {
}
function emptySlot() {
return { title: '', notes: '', training_unit_id: '', exercises: [] }
return { title: '', notes: '', exercises: [] }
}
/** Native-Tooltip für Ziel-Chips (Hover); kurz halten für OS-Tooltip-Limits */
@ -44,8 +44,10 @@ function defaultForm() {
return {
title: '',
description: '',
plan_mode: 'library',
group_id: '',
focus_area_id: '',
style_direction_id: '',
training_type_ids: [],
target_group_ids: [],
planned_period_start: '',
planned_period_end: '',
visibility: 'private',
@ -60,8 +62,14 @@ function serverFrameworkToForm(fw) {
return {
title: fw.title || '',
description: fw.description || '',
plan_mode: fw.plan_mode || 'library',
group_id: fw.group_id != null ? String(fw.group_id) : '',
focus_area_id: fw.focus_area_id != null ? String(fw.focus_area_id) : '',
style_direction_id: fw.style_direction_id != null ? String(fw.style_direction_id) : '',
training_type_ids: Array.isArray(fw.training_type_ids)
? fw.training_type_ids.map((x) => String(x))
: [],
target_group_ids: Array.isArray(fw.target_group_ids)
? fw.target_group_ids.map((x) => String(x))
: [],
planned_period_start: fw.planned_period_start || '',
planned_period_end: fw.planned_period_end || '',
visibility: fw.visibility || 'private',
@ -73,7 +81,6 @@ function serverFrameworkToForm(fw) {
slots: (fw.slots || []).map((s) => ({
title: s.title || '',
notes: s.notes || '',
training_unit_id: s.training_unit_id != null ? String(s.training_unit_id) : '',
exercises: (s.exercises || []).map((ex) => ({
exercise_id: ex.exercise_id,
exercise_variant_id: ex.exercise_variant_id != null ? String(ex.exercise_variant_id) : '',
@ -133,10 +140,6 @@ function buildApiPayload(form) {
}
const slots = (form.slots || []).map((s, si) => {
const tu =
form.plan_mode === 'concrete' && s.training_unit_id
? parseInt(s.training_unit_id, 10)
: null
const exercises = (s.exercises || [])
.map((ex, j) => {
if (!ex.exercise_id) return null
@ -153,17 +156,25 @@ function buildApiPayload(form) {
sort_order: si,
title: (s.title || '').trim() || null,
notes: (s.notes || '').trim() || null,
training_unit_id: tu,
exercises,
}
})
const groupId =
form.plan_mode === 'library'
? null
: form.group_id && !Number.isNaN(parseInt(form.group_id, 10))
? parseInt(form.group_id, 10)
: null
const focusAreaId =
form.focus_area_id && !Number.isNaN(parseInt(form.focus_area_id, 10))
? parseInt(form.focus_area_id, 10)
: null
const styleDirectionId =
form.style_direction_id && !Number.isNaN(parseInt(form.style_direction_id, 10))
? parseInt(form.style_direction_id, 10)
: null
const training_type_ids = (form.training_type_ids || [])
.map((x) => parseInt(String(x), 10))
.filter((n) => !Number.isNaN(n) && n > 0)
const target_group_ids = (form.target_group_ids || [])
.map((x) => parseInt(String(x), 10))
.filter((n) => !Number.isNaN(n) && n > 0)
const clubId =
form.club_id && !Number.isNaN(parseInt(form.club_id, 10))
@ -173,8 +184,10 @@ function buildApiPayload(form) {
return {
title: (form.title || '').trim(),
description: (form.description || '').trim() || null,
plan_mode: form.plan_mode,
group_id: groupId,
focus_area_id: focusAreaId,
style_direction_id: styleDirectionId,
training_type_ids,
target_group_ids,
planned_period_start: form.planned_period_start || null,
planned_period_end: form.planned_period_end || null,
visibility: form.visibility || 'private',
@ -194,9 +207,11 @@ export default function TrainingFrameworkProgramEditPage() {
const [loading, setLoading] = useState(!isNew)
const [saving, setSaving] = useState(false)
const [form, setForm] = useState(defaultForm())
const [groups, setGroups] = useState([])
const [clubs, setClubs] = useState([])
const [units, setUnits] = useState([])
const [focusAreas, setFocusAreas] = useState([])
const [styleDirections, setStyleDirections] = useState([])
const [trainingTypesCatalog, setTrainingTypesCatalog] = useState([])
const [targetGroupsCatalog, setTargetGroupsCatalog] = useState([])
const [pickerSlotIdx, setPickerSlotIdx] = useState(null)
const [peekId, setPeekId] = useState(null)
const [editingGoalIdx, setEditingGoalIdx] = useState(null)
@ -231,15 +246,24 @@ export default function TrainingFrameworkProgramEditPage() {
const loadMeta = useCallback(async () => {
try {
const [gr, cl] = await Promise.all([
api.listTrainingGroups({ status: 'active' }),
const [cl, fa, sd, tt, tg] = await Promise.all([
api.listClubs(),
api.listFocusAreas({ status: 'active' }),
api.listStyleDirections({ status: 'active' }),
api.listTrainingTypes({ status: 'active' }),
api.listTargetGroups({ status: 'active' }),
])
setGroups(Array.isArray(gr) ? gr : [])
setClubs(Array.isArray(cl) ? cl : [])
setFocusAreas(Array.isArray(fa) ? fa : [])
setStyleDirections(Array.isArray(sd) ? sd : [])
setTrainingTypesCatalog(Array.isArray(tt) ? tt : [])
setTargetGroupsCatalog(Array.isArray(tg) ? tg : [])
} catch {
setGroups([])
setClubs([])
setFocusAreas([])
setStyleDirections([])
setTrainingTypesCatalog([])
setTargetGroupsCatalog([])
}
}, [])
@ -247,34 +271,6 @@ export default function TrainingFrameworkProgramEditPage() {
loadMeta()
}, [loadMeta])
useEffect(() => {
if (form.plan_mode !== 'concrete' || !form.group_id) {
setUnits([])
return
}
let cancelled = false
;(async () => {
try {
const today = new Date()
const start = new Date(today)
start.setFullYear(start.getFullYear() - 1)
const end = new Date(today)
end.setFullYear(end.getFullYear() + 1)
const u = await api.listTrainingUnits({
group_id: parseInt(form.group_id, 10),
start_date: start.toISOString().slice(0, 10),
end_date: end.toISOString().slice(0, 10),
})
if (!cancelled) setUnits(Array.isArray(u) ? u : [])
} catch {
if (!cancelled) setUnits([])
}
})()
return () => {
cancelled = true
}
}, [form.plan_mode, form.group_id])
useEffect(() => {
if (isNew) {
setForm(defaultForm())
@ -308,13 +304,7 @@ export default function TrainingFrameworkProgramEditPage() {
}, [isNew, idParam, navigate, location.pathname])
const updateField = (key, val) => {
setForm((prev) => {
const n = { ...prev, [key]: val }
if (key === 'plan_mode' && val === 'library') {
n.group_id = ''
}
return n
})
setForm((prev) => ({ ...prev, [key]: val }))
}
const moveGoal = (idx, dir) => {
@ -574,6 +564,44 @@ export default function TrainingFrameworkProgramEditPage() {
const panelVisibilityStyle = (key) =>
desktopLayout ? undefined : { display: panelActive(key) ? 'block' : 'none' }
const trainingTypesFiltered = useMemo(() => {
if (!form.focus_area_id) return trainingTypesCatalog
return trainingTypesCatalog.filter(
(t) => !t.focus_area_id || String(t.focus_area_id) === String(form.focus_area_id)
)
}, [trainingTypesCatalog, form.focus_area_id])
useEffect(() => {
if (!form.focus_area_id || trainingTypesCatalog.length === 0) return
const allowed = new Set(trainingTypesFiltered.map((t) => String(t.id)))
setForm((prev) => {
const cur = prev.training_type_ids || []
const next = cur.filter((id) => allowed.has(String(id)))
if (next.length === cur.length) return prev
return { ...prev, training_type_ids: next }
})
}, [form.focus_area_id, trainingTypesCatalog.length, trainingTypesFiltered])
const toggleTrainingTypeId = (tid) => {
const idStr = String(tid)
setForm((prev) => {
const s = new Set(prev.training_type_ids || [])
if (s.has(idStr)) s.delete(idStr)
else s.add(idStr)
return { ...prev, training_type_ids: [...s].sort((a, b) => Number(a) - Number(b)) }
})
}
const toggleTargetGroupId = (gid) => {
const idStr = String(gid)
setForm((prev) => {
const s = new Set(prev.target_group_ids || [])
if (s.has(idStr)) s.delete(idStr)
else s.add(idStr)
return { ...prev, target_group_ids: [...s].sort((a, b) => Number(a) - Number(b)) }
})
}
if (loading) {
return (
<div className="app-page" style={{ padding: '2rem 0', textAlign: 'center' }}>
@ -596,14 +624,12 @@ export default function TrainingFrameworkProgramEditPage() {
<div className="card" style={{ marginBottom: '1rem', background: 'var(--surface2)', borderStyle: 'dashed' }}>
<p style={{ fontSize: '0.88rem', color: 'var(--text2)', lineHeight: 1.55, margin: 0 }}>
<strong style={{ color: 'var(--text1)' }}>Stand dieser Funktion:</strong> Der Rahmen speichert Ziele, Slots
und pro Slot eine <strong>Übungsliste</strong> (Stückliste). Eine <strong>volle EinheitenStruktur</strong> wie
in der Trainingsplanung (Abschnitte, Notizen, Mikrovorlage pro Slot) ist im Konzept optional (
<strong>CURR010</strong>: <code>training_plan_template_id</code> pro Slot) in der DB derzeit{' '}
<strong>noch nicht</strong> umgesetzt.{' '}
<strong>Übernahme</strong> in konkrete Trainingseinheiten mit Referenz auf den Rahmen (Kopie, editierbar){' '}
ist <strong>Stufe3 / Lineage</strong> vorgesehen. Die SlotSpalten unten sind die geplanten
SessionPositionen <strong>ohne feste Termine</strong> am Rahmen.
<strong style={{ color: 'var(--text1)' }}>Rahmenprogramm (Bibliothek):</strong> Wiederverwendbare Vorlage mit
Zielen und SessionSlots. <strong>Zuordnung zu einer Trainingsgruppe</strong> oder zu{' '}
<strong>konkreten Einheiten</strong> erfolgt aus der <strong>GruppenPlanung</strong> (Übernahme mit Link /
Lineage) nicht mehr direkt an diesem Datensatz. Pro Slot ist derzeit eine Übungsliste (Stückliste) hinterlegt;
die strukturierte Einheitenplanung (Abschnitte wie in der Trainingsplanung) folgt über{' '}
<strong>CURR010</strong> (VorlagenModell pro Slot).
</p>
</div>
@ -673,38 +699,78 @@ export default function TrainingFrameworkProgramEditPage() {
/>
</div>
<div className="form-row">
<label className="form-label">Modus</label>
<label className="form-label">Fokusbereich (optional)</label>
<select
className="form-input"
value={form.plan_mode}
onChange={(e) => updateField('plan_mode', e.target.value)}
value={form.focus_area_id}
onChange={(e) => updateField('focus_area_id', e.target.value)}
>
<option value="library">Bibliothek (zeitlos, ohne Gruppe)</option>
<option value="concrete">Konkret (optional Gruppe & Verknüpfung zu Einheiten)</option>
<option value=""> keiner </option>
{focusAreas.map((fa) => (
<option key={fa.id} value={String(fa.id)}>
{fa.name}
</option>
))}
</select>
<p style={{ fontSize: '0.85rem', color: 'var(--text2)', marginTop: '0.35rem' }}>
Bibliothek: keine Trainingsgruppe und keine SlotZuordnung zu Terminen. Konkret: optional Gruppe wählen und
Slots mit geplanten Trainingseinheiten verknüpfen.
</p>
<p className="form-sub">Hilft beim Filtern der Trainingsarten und bei der späteren Zuordnung in der Planung.</p>
</div>
{form.plan_mode === 'concrete' && (
<div className="form-row">
<label className="form-label">Trainingsgruppe (optional)</label>
<select
className="form-input"
value={form.group_id}
onChange={(e) => updateField('group_id', e.target.value)}
>
<option value=""> keine </option>
{groups.map((g) => (
<option key={g.id} value={String(g.id)}>
{g.name} ({g.club_name || 'Verein'})
</option>
))}
</select>
<div className="form-row">
<label className="form-label">Stilrichtung (optional)</label>
<select
className="form-input"
value={form.style_direction_id}
onChange={(e) => updateField('style_direction_id', e.target.value)}
>
<option value=""> keine </option>
{styleDirections.map((sd) => (
<option key={sd.id} value={String(sd.id)}>
{sd.name}
</option>
))}
</select>
</div>
<div className="form-row">
<label className="form-label">Trainingsarten (optional, Mehrfachwahl)</label>
<div className="framework-catalog-checkgrid">
{trainingTypesFiltered.length === 0 ? (
<p className="form-sub" style={{ marginTop: 0 }}>
Keine Einträge im Katalog oder Fokusbereich wählen, um zu filtern.
</p>
) : (
trainingTypesFiltered.map((t) => (
<label key={t.id} className="framework-catalog-check">
<input
type="checkbox"
checked={(form.training_type_ids || []).includes(String(t.id))}
onChange={() => toggleTrainingTypeId(t.id)}
/>
<span>{t.name}</span>
</label>
))
)}
</div>
)}
</div>
<div className="form-row">
<label className="form-label">Zielgruppen (optional, Mehrfachwahl)</label>
<div className="framework-catalog-checkgrid">
{targetGroupsCatalog.length === 0 ? (
<p className="form-sub" style={{ marginTop: 0 }}>
Keine Zielgruppen im Katalog.
</p>
) : (
targetGroupsCatalog.map((tg) => (
<label key={tg.id} className="framework-catalog-check">
<input
type="checkbox"
checked={(form.target_group_ids || []).includes(String(tg.id))}
onChange={() => toggleTargetGroupId(tg.id)}
/>
<span>{tg.name}</span>
</label>
))
)}
</div>
</div>
<div className="responsive-grid-2" style={{ marginBottom: '16px' }}>
<div>
@ -1001,7 +1067,7 @@ export default function TrainingFrameworkProgramEditPage() {
</div>
<details className="framework-slot-details">
<summary className="framework-slot-details__summary">Notizen &amp; Einheit</summary>
<summary className="framework-slot-details__summary">Notizen (Session)</summary>
<div className="form-row">
<label className="form-label">Notizen</label>
<textarea
@ -1011,31 +1077,6 @@ export default function TrainingFrameworkProgramEditPage() {
onChange={(e) => slotField(si, 'notes', e.target.value)}
/>
</div>
{form.plan_mode === 'concrete' && (
<div className="form-row">
<label className="form-label">Trainingseinheit (optional)</label>
<select
className="form-input"
value={slot.training_unit_id}
onChange={(e) => slotField(si, 'training_unit_id', e.target.value)}
disabled={!form.group_id}
>
<option value=""> keine </option>
{units.map((u) => (
<option key={u.id} value={String(u.id)}>
{u.planned_date}
{u.planned_time_start ? ` ${String(u.planned_time_start).slice(0, 5)}` : ''}
{u.planned_focus ? ` · ${u.planned_focus}` : ''}
</option>
))}
</select>
{!form.group_id ? (
<p style={{ fontSize: '0.82rem', color: 'var(--text2)', marginTop: '0.35rem' }}>
Wähle in den Stammdaten eine Trainingsgruppe, um geplante Einheiten zu laden.
</p>
) : null}
</div>
)}
</details>
<div className="framework-slot-card__exercises">

View File

@ -2,11 +2,21 @@ import React, { useCallback, useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import api from '../utils/api'
const MODE_LABELS = {
concrete: 'Konkret (Gruppe / Einheiten)',
library: 'Bibliothek',
}
const TYPE_COUNT = (r) =>
typeof r.training_types_count === 'number' ? r.training_types_count : null
const TG_COUNT = (r) =>
typeof r.target_groups_count === 'number' ? r.target_groups_count : null
function contextTeaser(r) {
const bits = []
if (r.focus_area_name) bits.push(r.focus_area_name)
if (r.style_direction_name) bits.push(r.style_direction_name)
const tn = TYPE_COUNT(r)
const gn = TG_COUNT(r)
if (tn != null && tn > 0) bits.push(`${tn} Trainingsart${tn === 1 ? '' : 'en'}`)
if (gn != null && gn > 0) bits.push(`${gn} Zielgruppe${gn === 1 ? '' : 'n'}`)
return bits.length ? bits.join(' · ') : null
}
export default function TrainingFrameworkProgramsListPage() {
const [rows, setRows] = useState([])
const [loading, setLoading] = useState(true)
@ -55,12 +65,9 @@ export default function TrainingFrameworkProgramsListPage() {
<div>
<h1 style={{ marginBottom: '0.35rem' }}>Trainingsrahmenprogramme</h1>
<p style={{ color: 'var(--text2)', fontSize: '0.95rem', maxWidth: '36rem' }}>
Mehrere Entwicklungsziele und Übungen über SessionSlots verteilen als Vorlage in der Bibliothek oder
im Kontext einer Gruppe.{' '}
<span style={{ color: 'var(--text3)', fontSize: '0.88rem' }}>
In der <strong>Bearbeitungsansicht</strong> gibt es auf schmalen Fenstern Registerkarten, auf breiten
Bildschirmen zwei Spalten (Ziele | Slots).
</span>
Wiederverwendbare Vorlagen für Ziele und Sessions. Die Verknüpfung mit{' '}
<strong>konkreten Gruppeneinheiten</strong> erfolgt aus der <strong>Planung der Gruppe</strong> (Übernahme
mit Bezug zum Rahmen).
</p>
</div>
<Link
@ -124,11 +131,12 @@ export default function TrainingFrameworkProgramsListPage() {
{r.title || `Rahmen #${r.id}`}
</Link>
<div style={{ fontSize: '0.85rem', color: 'var(--text2)', marginTop: '0.35rem' }}>
<span>{MODE_LABELS[r.plan_mode] || r.plan_mode}</span>
{typeof r.goals_count === 'number' || typeof r.slots_count === 'number' ? (
<span>
{' '}
· {r.goals_count ?? '—'} Ziele · {r.slots_count ?? '—'} Slots
<span>
{r.goals_count ?? '—'} Ziele · {r.slots_count ?? '—'} Slots
</span>
{contextTeaser(r) ? (
<span style={{ display: 'block', marginTop: '0.25rem', color: 'var(--text3)' }}>
{contextTeaser(r)}
</span>
) : null}
</div>

View File

@ -11,8 +11,8 @@ export const PAGE_VERSIONS = {
ClubsPage: "1.0.0",
SkillsPage: "1.0.0",
TrainingPlanningPage: "1.3.1",
TrainingFrameworkProgramsListPage: "1.0.0",
TrainingFrameworkProgramEditPage: "1.4.0",
TrainingFrameworkProgramsListPage: "1.1.0",
TrainingFrameworkProgramEditPage: "1.5.0",
TrainingUnitRunPage: "1.1.0",
TrainingCoachPage: "1.0.0",
AdminCatalogsPage: "2.2.0", // Updated: Frontend API Calls & Field Names für renamed tables