diff --git a/backend/migrations/036_framework_program_context_only_library.sql b/backend/migrations/036_framework_program_context_only_library.sql new file mode 100644 index 0000000..5a2b257 --- /dev/null +++ b/backend/migrations/036_framework_program_context_only_library.sql @@ -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; diff --git a/backend/routers/training_framework_programs.py b/backend/routers/training_framework_programs.py index 1371cf4..82ed4fe 100644 --- a/backend/routers/training_framework_programs.py +++ b/backend/routers/training_framework_programs.py @@ -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,), diff --git a/backend/version.py b/backend/version.py index 8c6e7d7..7ba8959 100644 --- a/backend/version.py +++ b/backend/version.py @@ -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", diff --git a/frontend/src/app.css b/frontend/src/app.css index 52c525e..92b4f80 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -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); diff --git a/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx b/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx index 9787937..b5627b2 100644 --- a/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx +++ b/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx @@ -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 (
- Stand dieser Funktion: Der Rahmen speichert Ziele, Slots
- und pro Slot eine Übungsliste (Stückliste). Eine volle Einheiten‑Struktur wie
- in der Trainingsplanung (Abschnitte, Notizen, Mikrovorlage pro Slot) ist im Konzept optional (
- CURR‑010: training_plan_template_id pro Slot) — in der DB derzeit{' '}
- noch nicht umgesetzt.{' '}
- Übernahme in konkrete Trainingseinheiten mit Referenz auf den Rahmen (Kopie, editierbar){' '}
- ist Stufe 3 / Lineage vorgesehen. Die Slot‑Spalten unten sind die geplanten
- Session‑Positionen ohne feste Termine am Rahmen.
+ Rahmenprogramm (Bibliothek): Wiederverwendbare Vorlage mit
+ Zielen und Session‑Slots. Zuordnung zu einer Trainingsgruppe oder zu{' '}
+ konkreten Einheiten erfolgt aus der Gruppen‑Planung (Ü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{' '}
+ CURR‑010 (Vorlagen‑Modell pro Slot).
- Bibliothek: keine Trainingsgruppe und keine Slot‑Zuordnung zu Terminen. Konkret: optional Gruppe wählen und - Slots mit geplanten Trainingseinheiten verknüpfen. -
+Hilft beim Filtern der Trainingsarten und bei der späteren Zuordnung in der Planung.
+ Keine Einträge im Katalog — oder Fokusbereich wählen, um zu filtern. +
+ ) : ( + trainingTypesFiltered.map((t) => ( + + )) + )}+ Keine Zielgruppen im Katalog. +
+ ) : ( + targetGroupsCatalog.map((tg) => ( + + )) + )} +- Wähle in den Stammdaten eine Trainingsgruppe, um geplante Einheiten zu laden. -
- ) : null} -- Mehrere Entwicklungsziele und Übungen über Session‑Slots verteilen — als Vorlage in der Bibliothek oder - im Kontext einer Gruppe.{' '} - - In der Bearbeitungsansicht gibt es auf schmalen Fenstern Registerkarten, auf breiten - Bildschirmen zwei Spalten (Ziele | Slots). - + Wiederverwendbare Vorlagen für Ziele und Sessions. Die Verknüpfung mit{' '} + konkreten Gruppeneinheiten erfolgt aus der Planung der Gruppe (Übernahme + mit Bezug zum Rahmen).