chore: update versioning and enhance Training Framework features
- 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:
parent
8f32a6df29
commit
b4495e39c1
|
|
@ -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;
|
||||
|
|
@ -1,8 +1,11 @@
|
|||
"""
|
||||
Trainingsrahmenprogramm — Rahmen‑Vorlage über mehrere Session‑Slots (CURR‑002 Stufe 2).
|
||||
AuthZ wie Planungs‑Vorlagen: 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,),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 Einheiten‑Struktur</strong> wie
|
||||
in der Trainingsplanung (Abschnitte, Notizen, Mikrovorlage pro Slot) ist im Konzept optional (
|
||||
<strong>CURR‑010</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>Stufe 3 / Lineage</strong> vorgesehen. Die Slot‑Spalten unten sind die geplanten
|
||||
Session‑Positionen <strong>ohne feste Termine</strong> am Rahmen.
|
||||
<strong style={{ color: 'var(--text1)' }}>Rahmenprogramm (Bibliothek):</strong> Wiederverwendbare Vorlage mit
|
||||
Zielen und Session‑Slots. <strong>Zuordnung zu einer Trainingsgruppe</strong> oder zu{' '}
|
||||
<strong>konkreten Einheiten</strong> erfolgt aus der <strong>Gruppen‑Planung</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>CURR‑010</strong> (Vorlagen‑Modell 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 Slot‑Zuordnung 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 & 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">
|
||||
|
|
|
|||
|
|
@ -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 Session‑Slots 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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user