Trainingsplanung und Rahmenplanung #9

Merged
Lars merged 29 commits from develop into main 2026-05-05 16:05:01 +02:00
7 changed files with 416 additions and 249 deletions
Showing only changes of commit b4495e39c1 - Show all commits

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). Trainingsrahmenprogramm wiederverwendbare Vorlage (Bibliothek) über mehrere Session-Slots.
AuthZ wie PlanungsVorlagen: Admin sieht alle, sonst nur eigene Artefakte; Schreibzugriff mit Planungsrolle.
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 from fastapi import APIRouter, Depends, HTTPException
@ -10,17 +13,12 @@ from auth import require_auth
from db import get_db, get_cursor, r2d from db import get_db, get_cursor, r2d
from routers.training_planning import ( from routers.training_planning import (
_assert_training_unit_permission,
_can_access_group_for_create,
_has_planning_role, _has_planning_role,
_optional_positive_int, _optional_positive_int,
_training_unit_guard_row,
_validate_variant_for_exercise, _validate_variant_for_exercise,
) )
router = APIRouter(prefix="/api", tags=["training_framework_programs"]) router = APIRouter(prefix="/api", tags=["training_framework_programs"])
_VALID_PLAN_MODE = frozenset({"concrete", "library"})
_VALID_VISIBILITY = frozenset({"private", "club", "official"}) _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()] 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]: def _hydrate_framework(cur, row: Dict[str, Any]) -> Dict[str, Any]:
fid = row["id"] fid = row["id"]
cur.execute( 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()] row["goals"] = [r2d(g) for g in cur.fetchall()]
cur.execute( 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 FROM training_framework_slots
WHERE framework_program_id = %s WHERE framework_program_id = %s
ORDER BY sort_order ORDER BY sort_order
@ -79,6 +103,8 @@ def _hydrate_framework(cur, row: Dict[str, Any]) -> Dict[str, Any]:
for s in slots: for s in slots:
s["exercises"] = _fetch_slot_exercises(cur, s["id"]) s["exercises"] = _fetch_slot_exercises(cur, s["id"])
row["slots"] = slots row["slots"] = slots
row["training_type_ids"] = _training_type_ids(cur, fid)
row["target_group_ids"] = _target_group_ids(cur, fid)
return row return row
@ -93,36 +119,55 @@ def _assert_visibility(val: Optional[str]) -> Optional[str]:
return val return val
def _assert_framework_invariants(plan_mode: str, group_id: Optional[int]) -> None: def _parse_positive_int_ids(raw: Any, label: str) -> List[int]:
if plan_mode == "library" and group_id is not None: if raw is None:
raise HTTPException( return []
status_code=400, if not isinstance(raw, list):
detail="plan_mode library erlaubt kein group_id", 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( def _replace_target_groups(cur, framework_id: int, ids: Sequence[int]) -> None:
cur, cur.execute(
plan_mode: str, "DELETE FROM training_framework_program_target_groups WHERE framework_program_id = %s",
framework_group_id: Optional[int], (framework_id,),
training_unit_id: Optional[int], )
profile_id: int, for gid in ids:
role: str, cur.execute(
) -> None: """
if plan_mode == "library" and training_unit_id: INSERT INTO training_framework_program_target_groups (framework_program_id, target_group_id)
raise HTTPException( VALUES (%s, %s)
status_code=400, ON CONFLICT DO NOTHING
detail="Im Bibliotheksmodus (library) keine Verknüpfung von Slots zu Trainingseinheiten", """,
) (framework_id, gid),
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",
) )
@ -149,11 +194,7 @@ def _insert_goal_rows(cur, framework_id: int, goals_in: List[Any]) -> None:
def _insert_slots_and_exercises( def _insert_slots_and_exercises(
cur, cur,
framework_id: int, framework_id: int,
plan_mode: str,
framework_group_id: Optional[int],
slots_in: Optional[List[Any]], slots_in: Optional[List[Any]],
profile_id: int,
role: str,
) -> None: ) -> None:
if slots_in is None: if slots_in is None:
return return
@ -164,14 +205,12 @@ def _insert_slots_and_exercises(
title_s = slot.get("title") title_s = slot.get("title")
if title_s is not None: if title_s is not None:
title_s = title_s.strip() or 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( cur.execute(
""" """
INSERT INTO training_framework_slots ( INSERT INTO training_framework_slots (
framework_program_id, sort_order, title, notes, training_unit_id framework_program_id, sort_order, title, notes, training_unit_id
) VALUES (%s, %s, %s, %s, %s) ) VALUES (%s, %s, %s, %s, NULL)
RETURNING id RETURNING id
""", """,
( (
@ -179,7 +218,6 @@ def _insert_slots_and_exercises(
int(order_ix), int(order_ix),
title_s, title_s,
slot.get("notes"), slot.get("notes"),
unit_sid,
), ),
) )
sid = cur.fetchone()["id"] sid = cur.fetchone()["id"]
@ -212,11 +250,19 @@ def list_training_framework_programs(session=Depends(require_auth)):
cur = get_cursor(conn) cur = get_cursor(conn)
base_sel = """ base_sel = """
SELECT fp.*, 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) (SELECT COUNT(*)::int FROM training_framework_goals g WHERE g.framework_program_id = fp.id)
AS goals_count, AS goals_count,
(SELECT COUNT(*)::int FROM training_framework_slots s WHERE s.framework_program_id = fp.id) (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 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"): if role in ("admin", "superadmin"):
cur.execute(base_sel + " ORDER BY fp.updated_at DESC NULLS LAST, fp.title") 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: if not title:
raise HTTPException(status_code=400, detail="title ist Pflicht") 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 = data.get("visibility") or "private"
vis = _assert_visibility(vis) vis = _assert_visibility(vis)
club_id = data.get("club_id") club_id = data.get("club_id")
goals_in = data.get("goals") goals_in = data.get("goals")
slots_in = data.get("slots") slots_in = data.get("slots")
if not isinstance(goals_in, list) or not goals_in: 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") 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: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
if gid is not None:
_can_access_group_for_create(cur, gid, profile_id, role)
cur.execute( cur.execute(
""" """
INSERT INTO training_framework_programs ( INSERT INTO training_framework_programs (
title, description, plan_mode, group_id, title, description,
planned_period_start, planned_period_end, 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) ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id RETURNING id
""", """,
( (
title[:200], title[:200],
data.get("description"), data.get("description"),
plan_mode,
gid,
data.get("planned_period_start"), data.get("planned_period_start"),
data.get("planned_period_end"), data.get("planned_period_end"),
vis, vis,
club_id, club_id,
profile_id, profile_id,
fa_id,
sd_id,
), ),
) )
fid = cur.fetchone()["id"] fid = cur.fetchone()["id"]
_insert_goal_rows(cur, fid, goals_in) _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() conn.commit()
return get_training_framework_program(fid, session) 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: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
existing = _framework_access(cur, framework_id, profile_id, role) _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)
header_fields = [] header_fields = []
header_params: List[Any] = [] header_params: List[Any] = []
if "title" in data: if "title" in data:
tit = (data.get("title") or "").strip() tit = (data.get("title") or "").strip()
if not tit: if not tit:
raise HTTPException(status_code=400, detail="title ist Pflicht") raise HTTPException(status_code=400, detail="title ist Pflicht")
header_fields.append("title = %s") header_fields.append("title = %s")
header_params.append(tit[:200]) header_params.append(tit[:200])
if "description" in data: if "description" in data:
header_fields.append("description = %s") header_fields.append("description = %s")
header_params.append(data.get("description")) 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: if "planned_period_start" in data:
header_fields.append("planned_period_start = %s") header_fields.append("planned_period_start = %s")
header_params.append(data.get("planned_period_start")) header_params.append(data.get("planned_period_start"))
if "planned_period_end" in data: if "planned_period_end" in data:
header_fields.append("planned_period_end = %s") header_fields.append("planned_period_end = %s")
header_params.append(data.get("planned_period_end")) header_params.append(data.get("planned_period_end"))
if "visibility" in data: if "visibility" in data:
v = _assert_visibility(data.get("visibility")) v = _assert_visibility(data.get("visibility"))
if v is None: 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_fields.append("club_id = %s")
header_params.append(data.get("club_id")) header_params.append(data.get("club_id"))
if group_id_eff is not None and ( if "focus_area_id" in data:
("group_id" in data) fidv = data.get("focus_area_id")
or (plan_mode_new == "concrete" and plan_mode_new != existing.get("plan_mode")) header_fields.append("focus_area_id = %s")
): header_params.append(
_can_access_group_for_create(cur, group_id_eff, profile_id, role) 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: if header_fields:
header_fields.append("updated_at = NOW()") 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), 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: if "goals" in data:
goals_in = data["goals"] goals_in = data["goals"]
if not isinstance(goals_in, list) or not goals_in: 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", "DELETE FROM training_framework_slots WHERE framework_program_id = %s",
(framework_id,), (framework_id,),
) )
_insert_slots_and_exercises( _insert_slots_and_exercises(cur, framework_id, data.get("slots") or [])
cur,
framework_id,
plan_mode_new,
group_id_eff,
data.get("slots") or [],
profile_id,
role,
)
if plan_mode_new == "library": 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_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:
cur.execute( cur.execute(
"UPDATE training_framework_programs SET updated_at = NOW() WHERE id = %s", "UPDATE training_framework_programs SET updated_at = NOW() WHERE id = %s",
(framework_id,), (framework_id,),

View File

@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.8" APP_VERSION = "0.8.9"
BUILD_DATE = "2026-05-05" BUILD_DATE = "2026-05-05"
DB_SCHEMA_VERSION = "20260505035" DB_SCHEMA_VERSION = "20260505036"
MODULE_VERSIONS = { MODULE_VERSIONS = {
"auth": "1.0.0", "auth": "1.0.0",
@ -23,6 +23,14 @@ MODULE_VERSIONS = {
} }
CHANGELOG = [ 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", "version": "0.8.8",
"date": "2026-05-05", "date": "2026-05-05",

View File

@ -2934,6 +2934,31 @@ a.analysis-split__nav-item {
background: var(--surface2); 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 { .framework-popmenu {
position: absolute; position: absolute;
top: calc(100% + 4px); 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 { Link, useNavigate, useParams, useLocation } from 'react-router-dom'
import api from '../utils/api' import api from '../utils/api'
import ExercisePickerModal from '../components/ExercisePickerModal' import ExercisePickerModal from '../components/ExercisePickerModal'
@ -28,7 +28,7 @@ function emptyExercise() {
} }
function emptySlot() { 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 */ /** Native-Tooltip für Ziel-Chips (Hover); kurz halten für OS-Tooltip-Limits */
@ -44,8 +44,10 @@ function defaultForm() {
return { return {
title: '', title: '',
description: '', description: '',
plan_mode: 'library', focus_area_id: '',
group_id: '', style_direction_id: '',
training_type_ids: [],
target_group_ids: [],
planned_period_start: '', planned_period_start: '',
planned_period_end: '', planned_period_end: '',
visibility: 'private', visibility: 'private',
@ -60,8 +62,14 @@ function serverFrameworkToForm(fw) {
return { return {
title: fw.title || '', title: fw.title || '',
description: fw.description || '', description: fw.description || '',
plan_mode: fw.plan_mode || 'library', focus_area_id: fw.focus_area_id != null ? String(fw.focus_area_id) : '',
group_id: fw.group_id != null ? String(fw.group_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_start: fw.planned_period_start || '',
planned_period_end: fw.planned_period_end || '', planned_period_end: fw.planned_period_end || '',
visibility: fw.visibility || 'private', visibility: fw.visibility || 'private',
@ -73,7 +81,6 @@ function serverFrameworkToForm(fw) {
slots: (fw.slots || []).map((s) => ({ slots: (fw.slots || []).map((s) => ({
title: s.title || '', title: s.title || '',
notes: s.notes || '', notes: s.notes || '',
training_unit_id: s.training_unit_id != null ? String(s.training_unit_id) : '',
exercises: (s.exercises || []).map((ex) => ({ exercises: (s.exercises || []).map((ex) => ({
exercise_id: ex.exercise_id, exercise_id: ex.exercise_id,
exercise_variant_id: ex.exercise_variant_id != null ? String(ex.exercise_variant_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 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 || []) const exercises = (s.exercises || [])
.map((ex, j) => { .map((ex, j) => {
if (!ex.exercise_id) return null if (!ex.exercise_id) return null
@ -153,17 +156,25 @@ function buildApiPayload(form) {
sort_order: si, sort_order: si,
title: (s.title || '').trim() || null, title: (s.title || '').trim() || null,
notes: (s.notes || '').trim() || null, notes: (s.notes || '').trim() || null,
training_unit_id: tu,
exercises, exercises,
} }
}) })
const groupId = const focusAreaId =
form.plan_mode === 'library' form.focus_area_id && !Number.isNaN(parseInt(form.focus_area_id, 10))
? null ? parseInt(form.focus_area_id, 10)
: form.group_id && !Number.isNaN(parseInt(form.group_id, 10)) : null
? parseInt(form.group_id, 10) const styleDirectionId =
: null 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 = const clubId =
form.club_id && !Number.isNaN(parseInt(form.club_id, 10)) form.club_id && !Number.isNaN(parseInt(form.club_id, 10))
@ -173,8 +184,10 @@ function buildApiPayload(form) {
return { return {
title: (form.title || '').trim(), title: (form.title || '').trim(),
description: (form.description || '').trim() || null, description: (form.description || '').trim() || null,
plan_mode: form.plan_mode, focus_area_id: focusAreaId,
group_id: groupId, style_direction_id: styleDirectionId,
training_type_ids,
target_group_ids,
planned_period_start: form.planned_period_start || null, planned_period_start: form.planned_period_start || null,
planned_period_end: form.planned_period_end || null, planned_period_end: form.planned_period_end || null,
visibility: form.visibility || 'private', visibility: form.visibility || 'private',
@ -194,9 +207,11 @@ export default function TrainingFrameworkProgramEditPage() {
const [loading, setLoading] = useState(!isNew) const [loading, setLoading] = useState(!isNew)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [form, setForm] = useState(defaultForm()) const [form, setForm] = useState(defaultForm())
const [groups, setGroups] = useState([])
const [clubs, setClubs] = 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 [pickerSlotIdx, setPickerSlotIdx] = useState(null)
const [peekId, setPeekId] = useState(null) const [peekId, setPeekId] = useState(null)
const [editingGoalIdx, setEditingGoalIdx] = useState(null) const [editingGoalIdx, setEditingGoalIdx] = useState(null)
@ -231,15 +246,24 @@ export default function TrainingFrameworkProgramEditPage() {
const loadMeta = useCallback(async () => { const loadMeta = useCallback(async () => {
try { try {
const [gr, cl] = await Promise.all([ const [cl, fa, sd, tt, tg] = await Promise.all([
api.listTrainingGroups({ status: 'active' }),
api.listClubs(), 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 : []) 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 { } catch {
setGroups([])
setClubs([]) setClubs([])
setFocusAreas([])
setStyleDirections([])
setTrainingTypesCatalog([])
setTargetGroupsCatalog([])
} }
}, []) }, [])
@ -247,34 +271,6 @@ export default function TrainingFrameworkProgramEditPage() {
loadMeta() loadMeta()
}, [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(() => { useEffect(() => {
if (isNew) { if (isNew) {
setForm(defaultForm()) setForm(defaultForm())
@ -308,13 +304,7 @@ export default function TrainingFrameworkProgramEditPage() {
}, [isNew, idParam, navigate, location.pathname]) }, [isNew, idParam, navigate, location.pathname])
const updateField = (key, val) => { const updateField = (key, val) => {
setForm((prev) => { setForm((prev) => ({ ...prev, [key]: val }))
const n = { ...prev, [key]: val }
if (key === 'plan_mode' && val === 'library') {
n.group_id = ''
}
return n
})
} }
const moveGoal = (idx, dir) => { const moveGoal = (idx, dir) => {
@ -574,6 +564,44 @@ export default function TrainingFrameworkProgramEditPage() {
const panelVisibilityStyle = (key) => const panelVisibilityStyle = (key) =>
desktopLayout ? undefined : { display: panelActive(key) ? 'block' : 'none' } 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) { if (loading) {
return ( return (
<div className="app-page" style={{ padding: '2rem 0', textAlign: 'center' }}> <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' }}> <div className="card" style={{ marginBottom: '1rem', background: 'var(--surface2)', borderStyle: 'dashed' }}>
<p style={{ fontSize: '0.88rem', color: 'var(--text2)', lineHeight: 1.55, margin: 0 }}> <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 <strong style={{ color: 'var(--text1)' }}>Rahmenprogramm (Bibliothek):</strong> Wiederverwendbare Vorlage mit
und pro Slot eine <strong>Übungsliste</strong> (Stückliste). Eine <strong>volle EinheitenStruktur</strong> wie Zielen und SessionSlots. <strong>Zuordnung zu einer Trainingsgruppe</strong> oder zu{' '}
in der Trainingsplanung (Abschnitte, Notizen, Mikrovorlage pro Slot) ist im Konzept optional ( <strong>konkreten Einheiten</strong> erfolgt aus der <strong>GruppenPlanung</strong> (Übernahme mit Link /
<strong>CURR010</strong>: <code>training_plan_template_id</code> pro Slot) in der DB derzeit{' '} Lineage) nicht mehr direkt an diesem Datensatz. Pro Slot ist derzeit eine Übungsliste (Stückliste) hinterlegt;
<strong>noch nicht</strong> umgesetzt.{' '} die strukturierte Einheitenplanung (Abschnitte wie in der Trainingsplanung) folgt über{' '}
<strong>Übernahme</strong> in konkrete Trainingseinheiten mit Referenz auf den Rahmen (Kopie, editierbar){' '} <strong>CURR010</strong> (VorlagenModell pro Slot).
ist <strong>Stufe3 / Lineage</strong> vorgesehen. Die SlotSpalten unten sind die geplanten
SessionPositionen <strong>ohne feste Termine</strong> am Rahmen.
</p> </p>
</div> </div>
@ -673,38 +699,78 @@ export default function TrainingFrameworkProgramEditPage() {
/> />
</div> </div>
<div className="form-row"> <div className="form-row">
<label className="form-label">Modus</label> <label className="form-label">Fokusbereich (optional)</label>
<select <select
className="form-input" className="form-input"
value={form.plan_mode} value={form.focus_area_id}
onChange={(e) => updateField('plan_mode', e.target.value)} onChange={(e) => updateField('focus_area_id', e.target.value)}
> >
<option value="library">Bibliothek (zeitlos, ohne Gruppe)</option> <option value=""> keiner </option>
<option value="concrete">Konkret (optional Gruppe & Verknüpfung zu Einheiten)</option> {focusAreas.map((fa) => (
<option key={fa.id} value={String(fa.id)}>
{fa.name}
</option>
))}
</select> </select>
<p style={{ fontSize: '0.85rem', color: 'var(--text2)', marginTop: '0.35rem' }}> <p className="form-sub">Hilft beim Filtern der Trainingsarten und bei der späteren Zuordnung in der Planung.</p>
Bibliothek: keine Trainingsgruppe und keine SlotZuordnung zu Terminen. Konkret: optional Gruppe wählen und
Slots mit geplanten Trainingseinheiten verknüpfen.
</p>
</div> </div>
<div className="form-row">
{form.plan_mode === 'concrete' && ( <label className="form-label">Stilrichtung (optional)</label>
<div className="form-row"> <select
<label className="form-label">Trainingsgruppe (optional)</label> className="form-input"
<select value={form.style_direction_id}
className="form-input" onChange={(e) => updateField('style_direction_id', e.target.value)}
value={form.group_id} >
onChange={(e) => updateField('group_id', e.target.value)} <option value=""> keine </option>
> {styleDirections.map((sd) => (
<option value=""> keine </option> <option key={sd.id} value={String(sd.id)}>
{groups.map((g) => ( {sd.name}
<option key={g.id} value={String(g.id)}> </option>
{g.name} ({g.club_name || 'Verein'}) ))}
</option> </select>
))} </div>
</select> <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>
<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 className="responsive-grid-2" style={{ marginBottom: '16px' }}>
<div> <div>
@ -1001,7 +1067,7 @@ export default function TrainingFrameworkProgramEditPage() {
</div> </div>
<details className="framework-slot-details"> <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"> <div className="form-row">
<label className="form-label">Notizen</label> <label className="form-label">Notizen</label>
<textarea <textarea
@ -1011,31 +1077,6 @@ export default function TrainingFrameworkProgramEditPage() {
onChange={(e) => slotField(si, 'notes', e.target.value)} onChange={(e) => slotField(si, 'notes', e.target.value)}
/> />
</div> </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> </details>
<div className="framework-slot-card__exercises"> <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 { Link } from 'react-router-dom'
import api from '../utils/api' import api from '../utils/api'
const MODE_LABELS = { const TYPE_COUNT = (r) =>
concrete: 'Konkret (Gruppe / Einheiten)', typeof r.training_types_count === 'number' ? r.training_types_count : null
library: 'Bibliothek', 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() { export default function TrainingFrameworkProgramsListPage() {
const [rows, setRows] = useState([]) const [rows, setRows] = useState([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@ -55,12 +65,9 @@ export default function TrainingFrameworkProgramsListPage() {
<div> <div>
<h1 style={{ marginBottom: '0.35rem' }}>Trainingsrahmenprogramme</h1> <h1 style={{ marginBottom: '0.35rem' }}>Trainingsrahmenprogramme</h1>
<p style={{ color: 'var(--text2)', fontSize: '0.95rem', maxWidth: '36rem' }}> <p style={{ color: 'var(--text2)', fontSize: '0.95rem', maxWidth: '36rem' }}>
Mehrere Entwicklungsziele und Übungen über SessionSlots verteilen als Vorlage in der Bibliothek oder Wiederverwendbare Vorlagen für Ziele und Sessions. Die Verknüpfung mit{' '}
im Kontext einer Gruppe.{' '} <strong>konkreten Gruppeneinheiten</strong> erfolgt aus der <strong>Planung der Gruppe</strong> (Übernahme
<span style={{ color: 'var(--text3)', fontSize: '0.88rem' }}> mit Bezug zum Rahmen).
In der <strong>Bearbeitungsansicht</strong> gibt es auf schmalen Fenstern Registerkarten, auf breiten
Bildschirmen zwei Spalten (Ziele | Slots).
</span>
</p> </p>
</div> </div>
<Link <Link
@ -124,11 +131,12 @@ export default function TrainingFrameworkProgramsListPage() {
{r.title || `Rahmen #${r.id}`} {r.title || `Rahmen #${r.id}`}
</Link> </Link>
<div style={{ fontSize: '0.85rem', color: 'var(--text2)', marginTop: '0.35rem' }}> <div style={{ fontSize: '0.85rem', color: 'var(--text2)', marginTop: '0.35rem' }}>
<span>{MODE_LABELS[r.plan_mode] || r.plan_mode}</span> <span>
{typeof r.goals_count === 'number' || typeof r.slots_count === 'number' ? ( {r.goals_count ?? '—'} Ziele · {r.slots_count ?? '—'} Slots
<span> </span>
{' '} {contextTeaser(r) ? (
· {r.goals_count ?? '—'} Ziele · {r.slots_count ?? '—'} Slots <span style={{ display: 'block', marginTop: '0.25rem', color: 'var(--text3)' }}>
{contextTeaser(r)}
</span> </span>
) : null} ) : null}
</div> </div>

View File

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