diff --git a/.claude/docs/working/FRAMEWORK_PROGRAM_FILTER_AND_SKILLS_ROADMAP.md b/.claude/docs/working/FRAMEWORK_PROGRAM_FILTER_AND_SKILLS_ROADMAP.md new file mode 100644 index 0000000..d9d8663 --- /dev/null +++ b/.claude/docs/working/FRAMEWORK_PROGRAM_FILTER_AND_SKILLS_ROADMAP.md @@ -0,0 +1,65 @@ +# Rahmenprogramm: Filter, Dauer, Fähigkeiten-Schwerpunkte (Roadmap) + +**Stand:** 2026-05-20 +**Status:** Phase 1 umgesetzt (Listen + Import-Filter); Phase 2–3 offen + +## Phase 1 (umgesetzt) + +### Listen-Anzeige Session-Dauer + +- **GET `/api/training-framework-programs`:** `session_duration_min`, `session_duration_max` (aus Blueprint-`training_units.planned_duration_min`), `goal_titles_agg`, ID-Arrays für Katalog-M:N. +- **UI:** Rahmenprogramm-Liste, Trainingsplanung (Einheiten-Liste/Kalender), Import-Dialog (Programm + pro Slot). + +### Import-Filter (clientseitig) + +- Textsuche (Titel, Beschreibung, Ziele, Katalog-Namen) +- Fokusbereich, Trainingsart, Zielgruppe (Checkboxen, Katalog-API) +- Ziel-Session-Dauer in Minuten (±10 Min Toleranz gegen Min/Max der Slots) + +**Grenze:** Entwicklungsziele sind **freie Texte** pro Rahmen (`training_framework_goals.title`), keine kontrollierte Taxonomie → Filter nur Volltext, keine homogene „Ziel-Tags“-Liste. + +## Phase 2 (empfohlen, ohne KI) + +| Kriterium | Datenquelle heute | Verbesserung | +|-----------|-------------------|--------------| +| Fokusbereich / Stil / Trainingsart / Zielgruppe | M:N am Rahmenkopf | bereits filterbar | +| Entwicklungsziele | Freitext-Ziele | Optional: Ziel-Vorlagen-Katalog oder Tags (Migration) | +| Session-Dauer | `planned_duration_min` pro Slot | erledigt | +| Fähigkeiten-Schwerpunkt | noch nicht | siehe Phase 3 | + +**API-Erweiterung (optional):** `GET /api/training-framework-programs?focus_area_id=&training_type_id=&duration_min=` serverseitig — sinnvoll ab >50 Rahmen in der Bibliothek. + +## Phase 3 — Fähigkeiten aus Übungen (Schwerpunkte dynamisch) + +### Ziel + +Aus allen Übungen in allen Slots eines Rahmenprogramms die verknüpften **Fähigkeiten** (`exercise_skills` → `skills`, ggf. Fokusbereich der Fähigkeit) aggregieren, gewichten und als **Vorschlags-Schwerpunkte** oder Metadaten am Rahmen anzeigen (nicht zwingend automatisch in den Kopf schreiben). + +### Variante A — Regelbasiert (ohne KI) + +1. Pro Blueprint-Unit alle `exercise_id` aus `training_unit_section_items` sammeln. +2. Join `exercise_skills` (optional Gewicht: `planned_duration_min` der Zeile, Anzahl Vorkommen, Primär-Fähigkeit). +3. Top-N Fähigkeiten / Fokusbereiche nach Summe oder Anteil an Gesamtminuten. +4. Ergebnis cachen in `training_framework_programs.skill_profile_json` (Migration) oder nur on-the-fly bei GET Detail. + +**Vorteil:** reproduzierbar, offline, Governance-konform. +**Aufwand:** ca. 1–2 Tage Backend + kleine UI-Karte „Fähigkeiten-Profil (aus Übungen)“. + +### Variante B — KI-Zusammenfassung (OpenRouter, optional) + +1. Input: Titel Rahmen, Ziele (Text), Liste Übungstitel + Dauer + vorhandene Skill-Namen. +2. Prompt: strukturiertes JSON (`suggested_focus_areas[]`, `skill_emphasis[]`, `rationale_de`). +3. Speichern als `ai_context_summary` (Version, Modell, Timestamp) — **nur Vorschlag**, manuelle Bestätigung vor Übernahme in Stammdaten. + +**Vorteil:** natürliche Schwerpunkte auch bei unvollständigen Skill-Links. +**Risiko:** Halluzination, Kosten, Datenschutz (Vereinsdaten in Prompt). + +### Empfehlung + +Zuerst **Variante A** für Listen/Filter und Abgleich mit manuell gesetzten Fokusbereichen; KI nur als **„Vorschlag generieren“-Button** im Rahmen-Editor, wenn Regelwerk und Katalog-Zuordnung zu dünn sind. + +## Offene Produktfragen + +1. Soll Filter **UND** (alle Kriterien) oder **ODER** (mindestens eines) sein? — Import aktuell **UND**. +2. Rahmen mit **unterschiedlichen** Slot-Dauern: Liste zeigt Min–Max; Filter „90 Min“ trifft Range. +3. Sollen homogenisierte **Entwicklungsziel-Tags** ein eigener Katalog werden (Admin), analog `target_groups`? diff --git a/backend/routers/training_framework_programs.py b/backend/routers/training_framework_programs.py index b2d2eaa..f817e15 100644 --- a/backend/routers/training_framework_programs.py +++ b/backend/routers/training_framework_programs.py @@ -444,7 +444,46 @@ def list_training_framework_programs(tenant: TenantContext = Depends(get_tenant_ FROM training_framework_program_target_groups j JOIN target_groups tg ON tg.id = j.target_group_id WHERE j.framework_program_id = fp.id - ) AS target_group_names_agg + ) AS target_group_names_agg, + ( + SELECT STRING_AGG(g.title::text, ' | ' ORDER BY g.sort_order) + FROM training_framework_goals g + WHERE g.framework_program_id = fp.id + ) AS goal_titles_agg, + ( + SELECT MIN(tu.planned_duration_min)::int + FROM training_framework_slots fs + INNER JOIN training_units tu ON tu.framework_slot_id = fs.id + WHERE fs.framework_program_id = fp.id + AND tu.planned_duration_min IS NOT NULL + ) AS session_duration_min, + ( + SELECT MAX(tu.planned_duration_min)::int + FROM training_framework_slots fs + INNER JOIN training_units tu ON tu.framework_slot_id = fs.id + WHERE fs.framework_program_id = fp.id + AND tu.planned_duration_min IS NOT NULL + ) AS session_duration_max, + ( + SELECT COALESCE(json_agg(j.focus_area_id ORDER BY j.focus_area_id), '[]'::json) + FROM training_framework_program_focus_areas j + WHERE j.framework_program_id = fp.id + ) AS focus_area_ids, + ( + SELECT COALESCE(json_agg(j.style_direction_id ORDER BY j.style_direction_id), '[]'::json) + FROM training_framework_program_style_directions j + WHERE j.framework_program_id = fp.id + ) AS style_direction_ids, + ( + SELECT COALESCE(json_agg(j.training_type_id ORDER BY j.training_type_id), '[]'::json) + FROM training_framework_program_training_types j + WHERE j.framework_program_id = fp.id + ) AS training_type_ids, + ( + SELECT COALESCE(json_agg(j.target_group_id ORDER BY j.target_group_id), '[]'::json) + FROM training_framework_program_target_groups j + WHERE j.framework_program_id = fp.id + ) AS target_group_ids FROM training_framework_programs fp """ vis_clause, vis_params = library_content_visibility_sql( diff --git a/frontend/src/components/planning/TrainingPlanningFrameworkImportModal.jsx b/frontend/src/components/planning/TrainingPlanningFrameworkImportModal.jsx index 3a499e9..248832b 100644 --- a/frontend/src/components/planning/TrainingPlanningFrameworkImportModal.jsx +++ b/frontend/src/components/planning/TrainingPlanningFrameworkImportModal.jsx @@ -1,4 +1,10 @@ -import React from 'react' +import React, { useMemo, useState } from 'react' +import { + filterFrameworkPrograms, + frameworkProgramOptionLabel, + frameworkSessionDurationLabel, +} from '../../utils/frameworkProgramListHelpers' +import { formatDurationDisplay } from '../../utils/trainingDurationUtils' /** * Modal: geplante Einheiten aus einem Trainingsrahmenprogramm (Blueprint-Slots) erzeugen. @@ -6,6 +12,9 @@ import React from 'react' export default function TrainingPlanningFrameworkImportModal({ open, frameworkProgramsList, + catalogFocusAreas = [], + catalogTrainingTypes = [], + catalogTargetGroups = [], fwImportProgramId, onProgramChange, fwImportLoading, @@ -23,6 +32,41 @@ export default function TrainingPlanningFrameworkImportModal({ onSubmit, onClose, }) { + const [filterQuery, setFilterQuery] = useState('') + const [filterFocusIds, setFilterFocusIds] = useState([]) + const [filterTypeIds, setFilterTypeIds] = useState([]) + const [filterTargetGroupIds, setFilterTargetGroupIds] = useState([]) + const [filterDurationMin, setFilterDurationMin] = useState('') + + const filteredPrograms = useMemo( + () => + filterFrameworkPrograms(frameworkProgramsList, { + query: filterQuery, + focusAreaIds: filterFocusIds, + trainingTypeIds: filterTypeIds, + targetGroupIds: filterTargetGroupIds, + durationTargetMin: filterDurationMin === '' ? null : parseInt(filterDurationMin, 10), + }), + [ + frameworkProgramsList, + filterQuery, + filterFocusIds, + filterTypeIds, + filterTargetGroupIds, + filterDurationMin, + ] + ) + + const selectedProgramSummary = useMemo(() => { + if (!fwImportProgramId) return null + return (frameworkProgramsList || []).find((p) => String(p.id) === String(fwImportProgramId)) + }, [frameworkProgramsList, fwImportProgramId]) + + const toggleId = (list, setList, id) => { + const s = String(id) + setList((prev) => (prev.includes(s) ? prev.filter((x) => x !== s) : [...prev, s])) + } + if (!open) return null return ( @@ -48,7 +92,7 @@ export default function TrainingPlanningFrameworkImportModal({ background: 'var(--surface)', borderRadius: '12px', padding: 'clamp(14px, 3vw, 1.75rem)', - maxWidth: 'min(620px, 100%)', + maxWidth: 'min(680px, 100%)', width: '100%', maxHeight: '90vh', overflowY: 'auto', @@ -60,9 +104,104 @@ export default function TrainingPlanningFrameworkImportModal({

Wähle ein Trainingsrahmenprogramm und eine oder mehrere Sessions. Pro Session entsteht eine{' '} eigene geplante Einheit in der aktuellen Gruppe (Kopie des Ablaufs). Die{' '} - Verknüpfung zum Rahmen-Slot wird gespeichert, damit die Herkunft sichtbar bleibt. + Verknüpfung zum Rahmen-Slot bleibt sichtbar.

+
+ Rahmen filtern (optional) +
+
+ + setFilterQuery(e.target.value)} + placeholder="z. B. Gürtel, Koordination …" + disabled={fwImportSubmitting} + /> +
+
+ + setFilterDurationMin(e.target.value)} + placeholder="z. B. 90" + disabled={fwImportSubmitting} + /> +

+ Zeigt Programme, deren hinterlegte Session-Dauer in etwa passt (±10 Min). +

+
+ {catalogFocusAreas.length > 0 ? ( +
+ + Fokusbereich + +
+ {catalogFocusAreas.map((fa) => ( + + ))} +
+
+ ) : null} + {catalogTrainingTypes.length > 0 ? ( +
+ + Trainingsart + +
+ {catalogTrainingTypes.map((t) => ( + + ))} +
+
+ ) : null} + {catalogTargetGroups.length > 0 ? ( +
+ + Zielgruppe + +
+ {catalogTargetGroups.map((tg) => ( + + ))} +
+
+ ) : null} +

+ {filteredPrograms.length} von {frameworkProgramsList.length} Rahmenprogramm(en) sichtbar. + Entwicklungsziele sind freie Texte — die Suche durchsucht auch Ziel-Titel. +

+
+
+
+ {selectedProgramSummary ? ( +

+ Session-Dauer: {frameworkSessionDurationLabel(selectedProgramSummary)} + {selectedProgramSummary.goal_titles_agg ? ( + <> + {' '} + · Ziele: {selectedProgramSummary.goal_titles_agg} + + ) : null} +

+ ) : null}
{fwImportLoading ? ( @@ -96,6 +246,10 @@ export default function TrainingPlanningFrameworkImportModal({ const checked = fwImportSelectedSlots.has(slot.id) const label = (slot.title || '').trim() || `Session ${(slot.sort_order ?? 0) + 1}` + const slotDur = + slot.planned_duration_min != null + ? formatDurationDisplay(slot.planned_duration_min) + : null return (