Enhance Training Framework Programs with Session Duration and Filtering Features
All checks were successful
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 39s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m29s
All checks were successful
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 39s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m29s
- Added SQL aggregations for session duration (min/max) and goal titles in the training framework programs query. - Updated the TrainingPlanningFrameworkImportModal component to include filtering options for focus areas, training types, and target groups. - Implemented session duration display in the TrainingFrameworkProgramsListPage, improving user visibility of program details. - Introduced utility functions for formatting session duration ranges, enhancing the overall user experience in training planning.
This commit is contained in:
parent
5a8a212f40
commit
9353909fda
|
|
@ -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`?
|
||||||
|
|
@ -444,7 +444,46 @@ def list_training_framework_programs(tenant: TenantContext = Depends(get_tenant_
|
||||||
FROM training_framework_program_target_groups j
|
FROM training_framework_program_target_groups j
|
||||||
JOIN target_groups tg ON tg.id = j.target_group_id
|
JOIN target_groups tg ON tg.id = j.target_group_id
|
||||||
WHERE j.framework_program_id = fp.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
|
FROM training_framework_programs fp
|
||||||
"""
|
"""
|
||||||
vis_clause, vis_params = library_content_visibility_sql(
|
vis_clause, vis_params = library_content_visibility_sql(
|
||||||
|
|
|
||||||
|
|
@ -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.
|
* Modal: geplante Einheiten aus einem Trainingsrahmenprogramm (Blueprint-Slots) erzeugen.
|
||||||
|
|
@ -6,6 +12,9 @@ import React from 'react'
|
||||||
export default function TrainingPlanningFrameworkImportModal({
|
export default function TrainingPlanningFrameworkImportModal({
|
||||||
open,
|
open,
|
||||||
frameworkProgramsList,
|
frameworkProgramsList,
|
||||||
|
catalogFocusAreas = [],
|
||||||
|
catalogTrainingTypes = [],
|
||||||
|
catalogTargetGroups = [],
|
||||||
fwImportProgramId,
|
fwImportProgramId,
|
||||||
onProgramChange,
|
onProgramChange,
|
||||||
fwImportLoading,
|
fwImportLoading,
|
||||||
|
|
@ -23,6 +32,41 @@ export default function TrainingPlanningFrameworkImportModal({
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onClose,
|
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
|
if (!open) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -48,7 +92,7 @@ export default function TrainingPlanningFrameworkImportModal({
|
||||||
background: 'var(--surface)',
|
background: 'var(--surface)',
|
||||||
borderRadius: '12px',
|
borderRadius: '12px',
|
||||||
padding: 'clamp(14px, 3vw, 1.75rem)',
|
padding: 'clamp(14px, 3vw, 1.75rem)',
|
||||||
maxWidth: 'min(620px, 100%)',
|
maxWidth: 'min(680px, 100%)',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
maxHeight: '90vh',
|
maxHeight: '90vh',
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
|
|
@ -60,9 +104,104 @@ export default function TrainingPlanningFrameworkImportModal({
|
||||||
<p style={{ color: 'var(--text2)', fontSize: '0.9rem', marginBottom: '1rem', lineHeight: 1.5 }}>
|
<p style={{ color: 'var(--text2)', fontSize: '0.9rem', marginBottom: '1rem', lineHeight: 1.5 }}>
|
||||||
Wähle ein Trainingsrahmenprogramm und eine oder mehrere Sessions. Pro Session entsteht eine{' '}
|
Wähle ein Trainingsrahmenprogramm und eine oder mehrere Sessions. Pro Session entsteht eine{' '}
|
||||||
<strong>eigene geplante Einheit</strong> in der aktuellen Gruppe (Kopie des Ablaufs). Die{' '}
|
<strong>eigene geplante Einheit</strong> in der aktuellen Gruppe (Kopie des Ablaufs). Die{' '}
|
||||||
<strong>Verknüpfung zum Rahmen-Slot</strong> wird gespeichert, damit die Herkunft sichtbar bleibt.
|
<strong>Verknüpfung zum Rahmen-Slot</strong> bleibt sichtbar.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<details className="planning-filter-help" style={{ marginBottom: '1rem' }}>
|
||||||
|
<summary className="planning-filter-help__summary">Rahmen filtern (optional)</summary>
|
||||||
|
<div className="planning-filter-help__body" style={{ display: 'grid', gap: '10px' }}>
|
||||||
|
<div className="form-row" style={{ marginBottom: 0 }}>
|
||||||
|
<label className="form-label">Suche (Titel, Ziele, Katalog)</label>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
value={filterQuery}
|
||||||
|
onChange={(e) => setFilterQuery(e.target.value)}
|
||||||
|
placeholder="z. B. Gürtel, Koordination …"
|
||||||
|
disabled={fwImportSubmitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-row" style={{ marginBottom: 0 }}>
|
||||||
|
<label className="form-label">Ziel-Session-Dauer (Minuten)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
className="form-input"
|
||||||
|
value={filterDurationMin}
|
||||||
|
onChange={(e) => setFilterDurationMin(e.target.value)}
|
||||||
|
placeholder="z. B. 90"
|
||||||
|
disabled={fwImportSubmitting}
|
||||||
|
/>
|
||||||
|
<p className="form-sub" style={{ marginTop: '4px' }}>
|
||||||
|
Zeigt Programme, deren hinterlegte Session-Dauer in etwa passt (±10 Min).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{catalogFocusAreas.length > 0 ? (
|
||||||
|
<div>
|
||||||
|
<span className="form-label" style={{ display: 'block', marginBottom: '6px' }}>
|
||||||
|
Fokusbereich
|
||||||
|
</span>
|
||||||
|
<div className="framework-catalog-checkgrid">
|
||||||
|
{catalogFocusAreas.map((fa) => (
|
||||||
|
<label key={fa.id} className="framework-catalog-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={filterFocusIds.includes(String(fa.id))}
|
||||||
|
onChange={() => toggleId(filterFocusIds, setFilterFocusIds, fa.id)}
|
||||||
|
disabled={fwImportSubmitting}
|
||||||
|
/>
|
||||||
|
<span>{fa.name}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{catalogTrainingTypes.length > 0 ? (
|
||||||
|
<div>
|
||||||
|
<span className="form-label" style={{ display: 'block', marginBottom: '6px' }}>
|
||||||
|
Trainingsart
|
||||||
|
</span>
|
||||||
|
<div className="framework-catalog-checkgrid">
|
||||||
|
{catalogTrainingTypes.map((t) => (
|
||||||
|
<label key={t.id} className="framework-catalog-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={filterTypeIds.includes(String(t.id))}
|
||||||
|
onChange={() => toggleId(filterTypeIds, setFilterTypeIds, t.id)}
|
||||||
|
disabled={fwImportSubmitting}
|
||||||
|
/>
|
||||||
|
<span>{t.name}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{catalogTargetGroups.length > 0 ? (
|
||||||
|
<div>
|
||||||
|
<span className="form-label" style={{ display: 'block', marginBottom: '6px' }}>
|
||||||
|
Zielgruppe
|
||||||
|
</span>
|
||||||
|
<div className="framework-catalog-checkgrid">
|
||||||
|
{catalogTargetGroups.map((tg) => (
|
||||||
|
<label key={tg.id} className="framework-catalog-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={filterTargetGroupIds.includes(String(tg.id))}
|
||||||
|
onChange={() => toggleId(filterTargetGroupIds, setFilterTargetGroupIds, tg.id)}
|
||||||
|
disabled={fwImportSubmitting}
|
||||||
|
/>
|
||||||
|
<span>{tg.name}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<p className="form-sub" style={{ margin: 0 }}>
|
||||||
|
{filteredPrograms.length} von {frameworkProgramsList.length} Rahmenprogramm(en) sichtbar.
|
||||||
|
Entwicklungsziele sind freie Texte — die Suche durchsucht auch Ziel-Titel.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label className="form-label">Rahmenprogramm</label>
|
<label className="form-label">Rahmenprogramm</label>
|
||||||
<select
|
<select
|
||||||
|
|
@ -72,12 +211,23 @@ export default function TrainingPlanningFrameworkImportModal({
|
||||||
disabled={fwImportLoading || fwImportSubmitting}
|
disabled={fwImportLoading || fwImportSubmitting}
|
||||||
>
|
>
|
||||||
<option value="">Bitte wählen…</option>
|
<option value="">Bitte wählen…</option>
|
||||||
{frameworkProgramsList.map((fp) => (
|
{filteredPrograms.map((fp) => (
|
||||||
<option key={fp.id} value={String(fp.id)}>
|
<option key={fp.id} value={String(fp.id)}>
|
||||||
{(fp.title || '').trim() || `Rahmen #${fp.id}`}
|
{frameworkProgramOptionLabel(fp)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
{selectedProgramSummary ? (
|
||||||
|
<p className="form-sub" style={{ marginTop: '6px' }}>
|
||||||
|
Session-Dauer: <strong>{frameworkSessionDurationLabel(selectedProgramSummary)}</strong>
|
||||||
|
{selectedProgramSummary.goal_titles_agg ? (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
· Ziele: {selectedProgramSummary.goal_titles_agg}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{fwImportLoading ? (
|
{fwImportLoading ? (
|
||||||
|
|
@ -96,6 +246,10 @@ export default function TrainingPlanningFrameworkImportModal({
|
||||||
const checked = fwImportSelectedSlots.has(slot.id)
|
const checked = fwImportSelectedSlots.has(slot.id)
|
||||||
const label =
|
const label =
|
||||||
(slot.title || '').trim() || `Session ${(slot.sort_order ?? 0) + 1}`
|
(slot.title || '').trim() || `Session ${(slot.sort_order ?? 0) + 1}`
|
||||||
|
const slotDur =
|
||||||
|
slot.planned_duration_min != null
|
||||||
|
? formatDurationDisplay(slot.planned_duration_min)
|
||||||
|
: null
|
||||||
return (
|
return (
|
||||||
<li key={slot.id} style={{ marginBottom: '10px' }}>
|
<li key={slot.id} style={{ marginBottom: '10px' }}>
|
||||||
<label
|
<label
|
||||||
|
|
@ -116,6 +270,18 @@ export default function TrainingPlanningFrameworkImportModal({
|
||||||
/>
|
/>
|
||||||
<span style={{ flex: 1, minWidth: 0 }}>
|
<span style={{ flex: 1, minWidth: 0 }}>
|
||||||
<strong>{label}</strong>
|
<strong>{label}</strong>
|
||||||
|
{slotDur ? (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
marginLeft: '8px',
|
||||||
|
fontSize: '0.82rem',
|
||||||
|
color: 'var(--text2)',
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
· {slotDur}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
{!hasBp ? (
|
{!hasBp ? (
|
||||||
<span style={{ display: 'block', fontSize: '0.82rem', color: 'var(--danger)' }}>
|
<span style={{ display: 'block', fontSize: '0.82rem', color: 'var(--danger)' }}>
|
||||||
Ohne Session-Ablauf — Übernahme nicht möglich.
|
Ohne Session-Ablauf — Übernahme nicht möglich.
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ import {
|
||||||
legacyPlanningUnitDeepLinkTarget,
|
legacyPlanningUnitDeepLinkTarget,
|
||||||
parsePlanningHubQuery,
|
parsePlanningHubQuery,
|
||||||
} from '../../utils/planningUnitRoutes'
|
} from '../../utils/planningUnitRoutes'
|
||||||
|
import { formatDurationDisplay } from '../../utils/trainingDurationUtils'
|
||||||
|
|
||||||
function TrainingPlanningPageRoot() {
|
function TrainingPlanningPageRoot() {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
|
|
@ -55,6 +56,9 @@ function TrainingPlanningPageRoot() {
|
||||||
|
|
||||||
const [frameworkImportOpen, setFrameworkImportOpen] = useState(false)
|
const [frameworkImportOpen, setFrameworkImportOpen] = useState(false)
|
||||||
const [frameworkProgramsList, setFrameworkProgramsList] = useState([])
|
const [frameworkProgramsList, setFrameworkProgramsList] = useState([])
|
||||||
|
const [fwImportCatalogFocus, setFwImportCatalogFocus] = useState([])
|
||||||
|
const [fwImportCatalogTypes, setFwImportCatalogTypes] = useState([])
|
||||||
|
const [fwImportCatalogTargetGroups, setFwImportCatalogTargetGroups] = useState([])
|
||||||
const [fwImportProgramId, setFwImportProgramId] = useState('')
|
const [fwImportProgramId, setFwImportProgramId] = useState('')
|
||||||
const [fwImportDetail, setFwImportDetail] = useState(null)
|
const [fwImportDetail, setFwImportDetail] = useState(null)
|
||||||
const [fwImportLoading, setFwImportLoading] = useState(false)
|
const [fwImportLoading, setFwImportLoading] = useState(false)
|
||||||
|
|
@ -271,12 +275,25 @@ function TrainingPlanningPageRoot() {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
;(async () => {
|
;(async () => {
|
||||||
try {
|
try {
|
||||||
const list = await api.listTrainingFrameworkPrograms()
|
const [list, fa, tt, tg] = await Promise.all([
|
||||||
if (!cancelled) setFrameworkProgramsList(Array.isArray(list) ? list : [])
|
api.listTrainingFrameworkPrograms(),
|
||||||
|
api.listFocusAreas({ status: 'active' }),
|
||||||
|
api.listTrainingTypes({ status: 'active' }),
|
||||||
|
api.listTargetGroups({ status: 'active' }),
|
||||||
|
])
|
||||||
|
if (!cancelled) {
|
||||||
|
setFrameworkProgramsList(Array.isArray(list) ? list : [])
|
||||||
|
setFwImportCatalogFocus(Array.isArray(fa) ? fa : [])
|
||||||
|
setFwImportCatalogTypes(Array.isArray(tt) ? tt : [])
|
||||||
|
setFwImportCatalogTargetGroups(Array.isArray(tg) ? tg : [])
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
console.error('Rahmenprogramme laden:', e)
|
console.error('Rahmenprogramme laden:', e)
|
||||||
setFrameworkProgramsList([])
|
setFrameworkProgramsList([])
|
||||||
|
setFwImportCatalogFocus([])
|
||||||
|
setFwImportCatalogTypes([])
|
||||||
|
setFwImportCatalogTargetGroups([])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
@ -1033,7 +1050,9 @@ function TrainingPlanningPageRoot() {
|
||||||
onClick={() => handleEdit(unit)}
|
onClick={() => handleEdit(unit)}
|
||||||
title={[
|
title={[
|
||||||
planScope === 'club' && unit.group_name ? unit.group_name : '',
|
planScope === 'club' && unit.group_name ? unit.group_name : '',
|
||||||
unit.planned_time_start?.slice(0, 5) || '',
|
unit.planned_duration_min
|
||||||
|
? formatDurationDisplay(unit.planned_duration_min)
|
||||||
|
: unit.planned_time_start?.slice(0, 5) || '',
|
||||||
unit.lead_trainer_name?.trim(),
|
unit.lead_trainer_name?.trim(),
|
||||||
unit.planned_focus?.trim(),
|
unit.planned_focus?.trim(),
|
||||||
unit.status === 'completed'
|
unit.status === 'completed'
|
||||||
|
|
@ -1066,9 +1085,11 @@ function TrainingPlanningPageRoot() {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ fontWeight: 600 }}>
|
<span style={{ fontWeight: 600 }}>
|
||||||
{unit.planned_time_start
|
{unit.planned_duration_min
|
||||||
? `${unit.planned_time_start.slice(0, 5)}`
|
? formatDurationDisplay(unit.planned_duration_min)
|
||||||
: 'Ganztags'}
|
: unit.planned_time_start
|
||||||
|
? `${unit.planned_time_start.slice(0, 5)}`
|
||||||
|
: 'Ganztags'}
|
||||||
</span>
|
</span>
|
||||||
{planScope === 'club' && (unit.group_name || '').trim() ? (
|
{planScope === 'club' && (unit.group_name || '').trim() ? (
|
||||||
<span style={{ display: 'block', fontWeight: 500, color: 'var(--text1)' }}>
|
<span style={{ display: 'block', fontWeight: 500, color: 'var(--text1)' }}>
|
||||||
|
|
@ -1158,11 +1179,17 @@ function TrainingPlanningPageRoot() {
|
||||||
<h3 style={{ marginBottom: '0.25rem' }}>
|
<h3 style={{ marginBottom: '0.25rem' }}>
|
||||||
{unit.planned_date}
|
{unit.planned_date}
|
||||||
{unit.planned_duration_min
|
{unit.planned_duration_min
|
||||||
? ` · ${unit.planned_duration_min >= 60 && unit.planned_duration_min % 60 === 0 ? `${unit.planned_duration_min / 60} h` : `${unit.planned_duration_min} Min`}`
|
? ` · ${formatDurationDisplay(unit.planned_duration_min)}`
|
||||||
: unit.planned_time_start
|
: unit.planned_time_start
|
||||||
? ` · ${unit.planned_time_start.slice(0, 5)} - ${unit.planned_time_end?.slice(0, 5)}`
|
? ` · ${unit.planned_time_start.slice(0, 5)}${unit.planned_time_end ? ` – ${unit.planned_time_end.slice(0, 5)}` : ''}`
|
||||||
: ''}
|
: ''}
|
||||||
</h3>
|
</h3>
|
||||||
|
{unit.planned_duration_min && unit.planned_time_start ? (
|
||||||
|
<p style={{ fontSize: '0.78rem', color: 'var(--text3)', margin: '0 0 0.35rem' }}>
|
||||||
|
Uhrzeit: {unit.planned_time_start.slice(0, 5)}
|
||||||
|
{unit.planned_time_end ? ` – ${unit.planned_time_end.slice(0, 5)}` : ''}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
{planScope === 'club' && (unit.group_name || '').trim() ? (
|
{planScope === 'club' && (unit.group_name || '').trim() ? (
|
||||||
<p
|
<p
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -1378,6 +1405,9 @@ function TrainingPlanningPageRoot() {
|
||||||
<TrainingPlanningFrameworkImportModal
|
<TrainingPlanningFrameworkImportModal
|
||||||
open={frameworkImportOpen}
|
open={frameworkImportOpen}
|
||||||
frameworkProgramsList={frameworkProgramsList}
|
frameworkProgramsList={frameworkProgramsList}
|
||||||
|
catalogFocusAreas={fwImportCatalogFocus}
|
||||||
|
catalogTrainingTypes={fwImportCatalogTypes}
|
||||||
|
catalogTargetGroups={fwImportCatalogTargetGroups}
|
||||||
fwImportProgramId={fwImportProgramId}
|
fwImportProgramId={fwImportProgramId}
|
||||||
onProgramChange={onFwImportProgramChange}
|
onProgramChange={onFwImportProgramChange}
|
||||||
fwImportLoading={fwImportLoading}
|
fwImportLoading={fwImportLoading}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import NavStateLink from '../components/NavStateLink'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { getTenantClubDependencyKey } from '../utils/activeClub'
|
import { getTenantClubDependencyKey } from '../utils/activeClub'
|
||||||
import { buildFrameworkProgramsListReturnContext } from '../utils/navReturnContext'
|
import { buildFrameworkProgramsListReturnContext } from '../utils/navReturnContext'
|
||||||
|
import { frameworkSessionDurationLabel } from '../utils/frameworkProgramListHelpers'
|
||||||
|
|
||||||
function dashIfEmpty(val) {
|
function dashIfEmpty(val) {
|
||||||
const s = (val ?? '').toString().trim()
|
const s = (val ?? '').toString().trim()
|
||||||
|
|
@ -37,8 +38,22 @@ function FrameworkSummaryMeta({ r }) {
|
||||||
lineHeight: 1.45,
|
lineHeight: 1.45,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const durationLabel = frameworkSessionDurationLabel(r)
|
||||||
|
const goals =
|
||||||
|
typeof r.goal_titles_agg === 'string' ? r.goal_titles_agg.trim() : ''
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<dl style={{ margin: '0.5rem 0 0', padding: 0, fontSize: '0.875rem', color: 'var(--text2)' }}>
|
<dl style={{ margin: '0.5rem 0 0', padding: 0, fontSize: '0.875rem', color: 'var(--text2)' }}>
|
||||||
|
<div style={rowStyle}>
|
||||||
|
<dt style={{ margin: 0, fontWeight: 600, color: 'var(--text3)' }}>Session-Dauer</dt>
|
||||||
|
<dd style={{ margin: 0 }}>{durationLabel}</dd>
|
||||||
|
</div>
|
||||||
|
{goals ? (
|
||||||
|
<div style={rowStyle}>
|
||||||
|
<dt style={{ margin: 0, fontWeight: 600, color: 'var(--text3)' }}>Entwicklungsziele</dt>
|
||||||
|
<dd style={{ margin: 0 }}>{goals}</dd>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div style={rowStyle}>
|
<div style={rowStyle}>
|
||||||
<dt style={{ margin: 0, fontWeight: 600, color: 'var(--text3)' }}>Fokusbereich</dt>
|
<dt style={{ margin: 0, fontWeight: 600, color: 'var(--text3)' }}>Fokusbereich</dt>
|
||||||
<dd style={{ margin: 0 }}>{dashIfEmpty(focus)}</dd>
|
<dd style={{ margin: 0 }}>{dashIfEmpty(focus)}</dd>
|
||||||
|
|
@ -187,6 +202,19 @@ export default function TrainingFrameworkProgramsListPage() {
|
||||||
{r.title || `Rahmen #${r.id}`}
|
{r.title || `Rahmen #${r.id}`}
|
||||||
</NavStateLink>
|
</NavStateLink>
|
||||||
<div style={{ fontSize: '0.85rem', color: 'var(--text2)', marginTop: '0.35rem' }}>
|
<div style={{ fontSize: '0.85rem', color: 'var(--text2)', marginTop: '0.35rem' }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
marginRight: '8px',
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
background: 'var(--surface2)',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--text1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{frameworkSessionDurationLabel(r)}
|
||||||
|
</span>
|
||||||
<span>
|
<span>
|
||||||
{(r.goals_count ?? '—') + ' Ziele · '}
|
{(r.goals_count ?? '—') + ' Ziele · '}
|
||||||
{(r.slots_count ?? '—') + ' Slots'}
|
{(r.slots_count ?? '—') + ' Slots'}
|
||||||
|
|
|
||||||
88
frontend/src/utils/frameworkProgramListHelpers.js
Normal file
88
frontend/src/utils/frameworkProgramListHelpers.js
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
import { formatSessionDurationRange } from './trainingDurationUtils'
|
||||||
|
|
||||||
|
export function frameworkSessionDurationLabel(row) {
|
||||||
|
return formatSessionDurationRange(row?.session_duration_min, row?.session_duration_max, {
|
||||||
|
empty: 'Dauer nicht angegeben',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIdList(raw) {
|
||||||
|
if (Array.isArray(raw)) {
|
||||||
|
return raw.map((x) => String(x)).filter(Boolean)
|
||||||
|
}
|
||||||
|
if (typeof raw === 'string' && raw.trim().startsWith('[')) {
|
||||||
|
try {
|
||||||
|
const arr = JSON.parse(raw)
|
||||||
|
if (Array.isArray(arr)) return arr.map((x) => String(x))
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowMatchesDuration(row, targetMin, toleranceMin = 10) {
|
||||||
|
if (targetMin == null || targetMin === '' || Number.isNaN(Number(targetMin))) return true
|
||||||
|
const t = Number(targetMin)
|
||||||
|
const lo = row.session_duration_min != null ? Number(row.session_duration_min) : null
|
||||||
|
const hi = row.session_duration_max != null ? Number(row.session_duration_max) : null
|
||||||
|
if (lo == null && hi == null) return true
|
||||||
|
if (lo != null && hi != null) {
|
||||||
|
return t >= lo - toleranceMin && t <= hi + toleranceMin
|
||||||
|
}
|
||||||
|
const single = lo ?? hi
|
||||||
|
return single != null && Math.abs(single - t) <= toleranceMin
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-Filter für Rahmenprogramm-Auswahl (Liste / Import-Dialog).
|
||||||
|
*/
|
||||||
|
export function filterFrameworkPrograms(rows, filters = {}) {
|
||||||
|
const q = (filters.query || '').trim().toLowerCase()
|
||||||
|
const focusIds = new Set((filters.focusAreaIds || []).map(String))
|
||||||
|
const typeIds = new Set((filters.trainingTypeIds || []).map(String))
|
||||||
|
const tgIds = new Set((filters.targetGroupIds || []).map(String))
|
||||||
|
const durationTarget = filters.durationTargetMin
|
||||||
|
|
||||||
|
return (rows || []).filter((r) => {
|
||||||
|
if (q) {
|
||||||
|
const blob = [
|
||||||
|
r.title,
|
||||||
|
r.description,
|
||||||
|
r.goal_titles_agg,
|
||||||
|
r.focus_area_names_agg,
|
||||||
|
r.style_direction_names_agg,
|
||||||
|
r.training_type_names_agg,
|
||||||
|
r.target_group_names_agg,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase()
|
||||||
|
if (!blob.includes(q)) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (focusIds.size) {
|
||||||
|
const fa = parseIdList(r.focus_area_ids)
|
||||||
|
if (!fa.some((id) => focusIds.has(id))) return false
|
||||||
|
}
|
||||||
|
if (typeIds.size) {
|
||||||
|
const tt = parseIdList(r.training_type_ids)
|
||||||
|
if (!tt.some((id) => typeIds.has(id))) return false
|
||||||
|
}
|
||||||
|
if (tgIds.size) {
|
||||||
|
const tg = parseIdList(r.target_group_ids)
|
||||||
|
if (!tg.some((id) => tgIds.has(id))) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rowMatchesDuration(r, durationTarget)) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function frameworkProgramOptionLabel(row) {
|
||||||
|
const title = (row?.title || '').trim() || `Rahmen #${row?.id}`
|
||||||
|
const dur = frameworkSessionDurationLabel(row)
|
||||||
|
const slots = row?.slots_count != null ? `${row.slots_count} Slot(s)` : ''
|
||||||
|
const bits = [dur !== 'Dauer nicht angegeben' ? dur : null, slots].filter(Boolean)
|
||||||
|
return bits.length ? `${title} · ${bits.join(' · ')}` : title
|
||||||
|
}
|
||||||
|
|
@ -80,3 +80,23 @@ export function sumSectionPlannedMinutes(sections) {
|
||||||
}
|
}
|
||||||
return sum
|
return sum
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anzeige für Session-Dauer (ein Slot oder Min/Max über mehrere Sessions).
|
||||||
|
* @param {number|null|undefined} minMinutes
|
||||||
|
* @param {number|null|undefined} maxMinutes
|
||||||
|
*/
|
||||||
|
export function formatSessionDurationRange(minMinutes, maxMinutes, { empty = '—' } = {}) {
|
||||||
|
const lo = minMinutes != null && minMinutes !== '' ? Number(minMinutes) : null
|
||||||
|
const hi = maxMinutes != null && maxMinutes !== '' ? Number(maxMinutes) : null
|
||||||
|
if (lo != null && Number.isFinite(lo) && lo > 0) {
|
||||||
|
if (hi != null && Number.isFinite(hi) && hi > 0 && hi !== lo) {
|
||||||
|
return `${formatDurationDisplay(lo, { empty: '' })} – ${formatDurationDisplay(hi, { empty: '' })}`
|
||||||
|
}
|
||||||
|
return formatDurationDisplay(lo, { empty })
|
||||||
|
}
|
||||||
|
if (hi != null && Number.isFinite(hi) && hi > 0) {
|
||||||
|
return formatDurationDisplay(hi, { empty })
|
||||||
|
}
|
||||||
|
return empty
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user