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
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
<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{' '}
|
||||
<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>
|
||||
|
||||
<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">
|
||||
<label className="form-label">Rahmenprogramm</label>
|
||||
<select
|
||||
|
|
@ -72,12 +211,23 @@ export default function TrainingPlanningFrameworkImportModal({
|
|||
disabled={fwImportLoading || fwImportSubmitting}
|
||||
>
|
||||
<option value="">Bitte wählen…</option>
|
||||
{frameworkProgramsList.map((fp) => (
|
||||
{filteredPrograms.map((fp) => (
|
||||
<option key={fp.id} value={String(fp.id)}>
|
||||
{(fp.title || '').trim() || `Rahmen #${fp.id}`}
|
||||
{frameworkProgramOptionLabel(fp)}
|
||||
</option>
|
||||
))}
|
||||
</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>
|
||||
|
||||
{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 (
|
||||
<li key={slot.id} style={{ marginBottom: '10px' }}>
|
||||
<label
|
||||
|
|
@ -116,6 +270,18 @@ export default function TrainingPlanningFrameworkImportModal({
|
|||
/>
|
||||
<span style={{ flex: 1, minWidth: 0 }}>
|
||||
<strong>{label}</strong>
|
||||
{slotDur ? (
|
||||
<span
|
||||
style={{
|
||||
marginLeft: '8px',
|
||||
fontSize: '0.82rem',
|
||||
color: 'var(--text2)',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
· {slotDur}
|
||||
</span>
|
||||
) : null}
|
||||
{!hasBp ? (
|
||||
<span style={{ display: 'block', fontSize: '0.82rem', color: 'var(--danger)' }}>
|
||||
Ohne Session-Ablauf — Übernahme nicht möglich.
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import {
|
|||
legacyPlanningUnitDeepLinkTarget,
|
||||
parsePlanningHubQuery,
|
||||
} from '../../utils/planningUnitRoutes'
|
||||
import { formatDurationDisplay } from '../../utils/trainingDurationUtils'
|
||||
|
||||
function TrainingPlanningPageRoot() {
|
||||
const { user } = useAuth()
|
||||
|
|
@ -55,6 +56,9 @@ function TrainingPlanningPageRoot() {
|
|||
|
||||
const [frameworkImportOpen, setFrameworkImportOpen] = useState(false)
|
||||
const [frameworkProgramsList, setFrameworkProgramsList] = useState([])
|
||||
const [fwImportCatalogFocus, setFwImportCatalogFocus] = useState([])
|
||||
const [fwImportCatalogTypes, setFwImportCatalogTypes] = useState([])
|
||||
const [fwImportCatalogTargetGroups, setFwImportCatalogTargetGroups] = useState([])
|
||||
const [fwImportProgramId, setFwImportProgramId] = useState('')
|
||||
const [fwImportDetail, setFwImportDetail] = useState(null)
|
||||
const [fwImportLoading, setFwImportLoading] = useState(false)
|
||||
|
|
@ -271,12 +275,25 @@ function TrainingPlanningPageRoot() {
|
|||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const list = await api.listTrainingFrameworkPrograms()
|
||||
if (!cancelled) setFrameworkProgramsList(Array.isArray(list) ? list : [])
|
||||
const [list, fa, tt, tg] = await Promise.all([
|
||||
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) {
|
||||
if (!cancelled) {
|
||||
console.error('Rahmenprogramme laden:', e)
|
||||
setFrameworkProgramsList([])
|
||||
setFwImportCatalogFocus([])
|
||||
setFwImportCatalogTypes([])
|
||||
setFwImportCatalogTargetGroups([])
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
|
@ -1033,7 +1050,9 @@ function TrainingPlanningPageRoot() {
|
|||
onClick={() => handleEdit(unit)}
|
||||
title={[
|
||||
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.planned_focus?.trim(),
|
||||
unit.status === 'completed'
|
||||
|
|
@ -1066,9 +1085,11 @@ function TrainingPlanningPageRoot() {
|
|||
}}
|
||||
>
|
||||
<span style={{ fontWeight: 600 }}>
|
||||
{unit.planned_time_start
|
||||
? `${unit.planned_time_start.slice(0, 5)}`
|
||||
: 'Ganztags'}
|
||||
{unit.planned_duration_min
|
||||
? formatDurationDisplay(unit.planned_duration_min)
|
||||
: unit.planned_time_start
|
||||
? `${unit.planned_time_start.slice(0, 5)}`
|
||||
: 'Ganztags'}
|
||||
</span>
|
||||
{planScope === 'club' && (unit.group_name || '').trim() ? (
|
||||
<span style={{ display: 'block', fontWeight: 500, color: 'var(--text1)' }}>
|
||||
|
|
@ -1158,11 +1179,17 @@ function TrainingPlanningPageRoot() {
|
|||
<h3 style={{ marginBottom: '0.25rem' }}>
|
||||
{unit.planned_date}
|
||||
{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.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>
|
||||
{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() ? (
|
||||
<p
|
||||
style={{
|
||||
|
|
@ -1378,6 +1405,9 @@ function TrainingPlanningPageRoot() {
|
|||
<TrainingPlanningFrameworkImportModal
|
||||
open={frameworkImportOpen}
|
||||
frameworkProgramsList={frameworkProgramsList}
|
||||
catalogFocusAreas={fwImportCatalogFocus}
|
||||
catalogTrainingTypes={fwImportCatalogTypes}
|
||||
catalogTargetGroups={fwImportCatalogTargetGroups}
|
||||
fwImportProgramId={fwImportProgramId}
|
||||
onProgramChange={onFwImportProgramChange}
|
||||
fwImportLoading={fwImportLoading}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import NavStateLink from '../components/NavStateLink'
|
|||
import { useAuth } from '../context/AuthContext'
|
||||
import { getTenantClubDependencyKey } from '../utils/activeClub'
|
||||
import { buildFrameworkProgramsListReturnContext } from '../utils/navReturnContext'
|
||||
import { frameworkSessionDurationLabel } from '../utils/frameworkProgramListHelpers'
|
||||
|
||||
function dashIfEmpty(val) {
|
||||
const s = (val ?? '').toString().trim()
|
||||
|
|
@ -37,8 +38,22 @@ function FrameworkSummaryMeta({ r }) {
|
|||
lineHeight: 1.45,
|
||||
}
|
||||
|
||||
const durationLabel = frameworkSessionDurationLabel(r)
|
||||
const goals =
|
||||
typeof r.goal_titles_agg === 'string' ? r.goal_titles_agg.trim() : ''
|
||||
|
||||
return (
|
||||
<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}>
|
||||
<dt style={{ margin: 0, fontWeight: 600, color: 'var(--text3)' }}>Fokusbereich</dt>
|
||||
<dd style={{ margin: 0 }}>{dashIfEmpty(focus)}</dd>
|
||||
|
|
@ -187,6 +202,19 @@ export default function TrainingFrameworkProgramsListPage() {
|
|||
{r.title || `Rahmen #${r.id}`}
|
||||
</NavStateLink>
|
||||
<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>
|
||||
{(r.goals_count ?? '—') + ' Ziele · '}
|
||||
{(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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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