From 9d122d4808fe925c33995e47e38049f6906df903 Mon Sep 17 00:00:00 2001
From: Lars
Date: Wed, 20 May 2026 16:05:18 +0200
Subject: [PATCH] Enhance Training Framework Programs List with Filtering and
Styling Improvements
- Introduced a new filter block for training framework programs, allowing users to refine their selections based on focus areas, training types, and target groups.
- Updated the TrainingFrameworkProgramsListPage to integrate the filter block and manage filter states effectively.
- Enhanced CSS styles for the filter block and program list, improving layout and spacing for better user experience.
- Removed unused filter-related logic from the TrainingPlanningFrameworkImportModal, streamlining the component's functionality.
---
frontend/src/app.css | 8 +
.../planning/FrameworkProgramsFilterBlock.jsx | 299 ++++++++++++++++++
.../TrainingPlanningFrameworkImportModal.jsx | 270 +---------------
.../TrainingFrameworkProgramsListPage.jsx | 59 +++-
4 files changed, 376 insertions(+), 260 deletions(-)
create mode 100644 frontend/src/components/planning/FrameworkProgramsFilterBlock.jsx
diff --git a/frontend/src/app.css b/frontend/src/app.css
index 783627b..13e2801 100644
--- a/frontend/src/app.css
+++ b/frontend/src/app.css
@@ -5372,6 +5372,14 @@ html.modal-scroll-locked .app-main {
border-color: var(--border2);
}
+/* Rahmenprogramm-Filter auf Übersichtsseite */
+.fw-prog-filter-block--list {
+ margin-bottom: 1rem;
+}
+.fw-prog-filter-block--list .fw-import-filter-panel {
+ margin-top: 0.75rem;
+}
+
/* Liste Rahmenprogramme: Abstand nur über gap (kein .card+.card zwischen li) */
.framework-programs-list {
list-style: none;
diff --git a/frontend/src/components/planning/FrameworkProgramsFilterBlock.jsx b/frontend/src/components/planning/FrameworkProgramsFilterBlock.jsx
new file mode 100644
index 0000000..0f318c0
--- /dev/null
+++ b/frontend/src/components/planning/FrameworkProgramsFilterBlock.jsx
@@ -0,0 +1,299 @@
+import React, { useMemo } from 'react'
+import {
+ collectDistinctSessionDurationsMinutes,
+ EMPTY_FRAMEWORK_IMPORT_FILTERS,
+ filterFrameworkPrograms,
+ hasActiveFrameworkImportFilters,
+ summarizeFrameworkImportFilters,
+} from '../../utils/frameworkProgramListHelpers'
+import { formatDurationDisplay } from '../../utils/trainingDurationUtils'
+
+/**
+ * Gemeinsamer Filter für Rahmenprogramm-Liste und Import-Dialog.
+ */
+export default function FrameworkProgramsFilterBlock({
+ programs = [],
+ filters,
+ onFiltersChange,
+ panelOpen = true,
+ onPanelOpenChange,
+ catalogFocusAreas = [],
+ catalogTrainingTypes = [],
+ catalogTargetGroups = [],
+ disabled = false,
+ durationRadioName = 'fw-duration-mode',
+ showHint = true,
+ className = '',
+}) {
+ const distinctDurations = useMemo(
+ () => collectDistinctSessionDurationsMinutes(programs),
+ [programs]
+ )
+
+ const matchCount = useMemo(
+ () => filterFrameworkPrograms(programs, filters).length,
+ [programs, filters]
+ )
+
+ const totalCount = (programs || []).length
+ const filterActive = hasActiveFrameworkImportFilters(filters)
+ const filterSummaryParts = useMemo(
+ () =>
+ summarizeFrameworkImportFilters(filters, {
+ focusAreas: catalogFocusAreas,
+ trainingTypes: catalogTrainingTypes,
+ targetGroups: catalogTargetGroups,
+ }),
+ [filters, catalogFocusAreas, catalogTrainingTypes, catalogTargetGroups]
+ )
+
+ const updateFilter = (patch) => onFiltersChange((prev) => ({ ...prev, ...patch }))
+
+ const clearFilters = () => onFiltersChange({ ...EMPTY_FRAMEWORK_IMPORT_FILTERS })
+
+ const toggleId = (key, id) => {
+ const s = String(id)
+ onFiltersChange((prev) => {
+ const cur = prev[key] || []
+ const next = cur.includes(s) ? cur.filter((x) => x !== s) : [...cur, s]
+ return { ...prev, [key]: next }
+ })
+ }
+
+ const togglePanel = () => {
+ if (onPanelOpenChange) onPanelOpenChange(!panelOpen)
+ }
+
+ return (
+
+
+
+ {matchCount}
+
+ {' '}
+ von {totalCount} Rahmenprogramm{totalCount === 1 ? '' : 'en'}
+
+ {matchCount === 0 && totalCount > 0 ? (
+ — kein Treffer
+ ) : null}
+
+
+ {filterActive ? (
+
+ Filter aktiv
+
+ ) : null}
+ {filterActive ? (
+
+ Filter zurücksetzen
+
+ ) : null}
+ {onPanelOpenChange ? (
+
+ {panelOpen ? 'Filter einklappen' : 'Filter anzeigen'}
+
+ ) : null}
+
+
+
+ {!panelOpen && filterActive && filterSummaryParts.length > 0 ? (
+
+ {filterSummaryParts.map((part) => (
+
+ {part}
+
+ ))}
+
+ ) : null}
+
+ {panelOpen ? (
+
+
+
+ Suche (Titel, Ziele, Katalog)
+ updateFilter({ query: e.target.value })}
+ placeholder="z. B. Gürtel, Koordination …"
+ disabled={disabled}
+ />
+
+
+
+ Ziel-Session-Dauer
+
+ {[
+ { id: 'any', label: 'Alle' },
+ { id: 'range', label: 'Zeitspanne' },
+ { id: 'preset', label: 'Vorhandene Zeiten' },
+ ].map((opt) => (
+
+
+ updateFilter({
+ durationMode: opt.id,
+ ...(opt.id === 'any'
+ ? { durationRangeFrom: '', durationRangeTo: '', durationPresetMin: null }
+ : {}),
+ })
+ }
+ />
+ {opt.label}
+
+ ))}
+
+
+ {filters.durationMode === 'range' ? (
+
+ ) : null}
+
+ {filters.durationMode === 'preset' ? (
+ distinctDurations.length === 0 ? (
+
+ In der Bibliothek sind noch keine Session-Dauern hinterlegt. Nutze „Zeitspanne“ oder lege
+ Dauer pro Session im Rahmenprogramm fest.
+
+ ) : (
+
+ {distinctDurations.map((min) => {
+ const on = filters.durationPresetMin === min
+ return (
+
+ updateFilter({
+ durationMode: 'preset',
+ durationPresetMin: on ? null : min,
+ durationRangeFrom: '',
+ durationRangeTo: '',
+ })
+ }
+ >
+ {formatDurationDisplay(min)}
+
+ )
+ })}
+
+ )
+ ) : null}
+
+
+ {catalogFocusAreas.length > 0 ? (
+
+
Fokusbereich
+
+ {catalogFocusAreas.map((fa) => (
+
+ toggleId('focusAreaIds', fa.id)}
+ disabled={disabled}
+ />
+ {fa.name}
+
+ ))}
+
+
+ ) : null}
+
+ {catalogTrainingTypes.length > 0 ? (
+
+
Trainingsart
+
+ {catalogTrainingTypes.map((t) => (
+
+ toggleId('trainingTypeIds', t.id)}
+ disabled={disabled}
+ />
+ {t.name}
+
+ ))}
+
+
+ ) : null}
+
+ {catalogTargetGroups.length > 0 ? (
+
+
Zielgruppe
+
+ {catalogTargetGroups.map((tg) => (
+
+ toggleId('targetGroupIds', tg.id)}
+ disabled={disabled}
+ />
+ {tg.name}
+
+ ))}
+
+
+ ) : null}
+
+ {showHint ? (
+
+ Entwicklungsziele sind freie Texte — die Suche durchsucht auch Ziel-Titel. Bei der Dauer werden nur
+ Programme mit hinterlegter Session-Dauer berücksichtigt.
+
+ ) : null}
+
+ ) : null}
+
+ )
+}
diff --git a/frontend/src/components/planning/TrainingPlanningFrameworkImportModal.jsx b/frontend/src/components/planning/TrainingPlanningFrameworkImportModal.jsx
index 8743478..cbadef2 100644
--- a/frontend/src/components/planning/TrainingPlanningFrameworkImportModal.jsx
+++ b/frontend/src/components/planning/TrainingPlanningFrameworkImportModal.jsx
@@ -1,12 +1,10 @@
import React, { useEffect, useMemo, useState } from 'react'
+import FrameworkProgramsFilterBlock from './FrameworkProgramsFilterBlock'
import {
- collectDistinctSessionDurationsMinutes,
EMPTY_FRAMEWORK_IMPORT_FILTERS,
filterFrameworkPrograms,
frameworkProgramOptionLabel,
frameworkSessionDurationLabel,
- hasActiveFrameworkImportFilters,
- summarizeFrameworkImportFilters,
} from '../../utils/frameworkProgramListHelpers'
import { formatDurationDisplay } from '../../utils/trainingDurationUtils'
@@ -46,28 +44,11 @@ export default function TrainingPlanningFrameworkImportModal({
}
}, [open])
- const distinctDurations = useMemo(
- () => collectDistinctSessionDurationsMinutes(frameworkProgramsList),
- [frameworkProgramsList]
- )
-
const filteredPrograms = useMemo(
() => filterFrameworkPrograms(frameworkProgramsList, filters),
[frameworkProgramsList, filters]
)
- const filterActive = hasActiveFrameworkImportFilters(filters)
- const filterSummaryParts = useMemo(
- () =>
- summarizeFrameworkImportFilters(filters, {
- focusAreas: catalogFocusAreas,
- trainingTypes: catalogTrainingTypes,
- targetGroups: catalogTargetGroups,
- }),
- [filters, catalogFocusAreas, catalogTrainingTypes, catalogTargetGroups]
- )
-
- const totalCount = (frameworkProgramsList || []).length
const matchCount = filteredPrograms.length
useEffect(() => {
@@ -81,19 +62,6 @@ export default function TrainingPlanningFrameworkImportModal({
return (frameworkProgramsList || []).find((p) => String(p.id) === String(fwImportProgramId))
}, [frameworkProgramsList, fwImportProgramId])
- const updateFilter = (patch) => setFilters((prev) => ({ ...prev, ...patch }))
-
- const clearFilters = () => setFilters({ ...EMPTY_FRAMEWORK_IMPORT_FILTERS })
-
- const toggleId = (key, id) => {
- const s = String(id)
- setFilters((prev) => {
- const cur = prev[key] || []
- const next = cur.includes(s) ? cur.filter((x) => x !== s) : [...cur, s]
- return { ...prev, [key]: next }
- })
- }
-
if (!open) return null
return (
@@ -108,230 +76,18 @@ export default function TrainingPlanningFrameworkImportModal({
eigene geplante Einheit in der aktuellen Gruppe (Kopie des Ablaufs).
-
-
- {matchCount}
-
- {' '}
- von {totalCount} Rahmenprogramm{totalCount === 1 ? '' : 'en'}
-
- {matchCount === 0 && totalCount > 0 ? (
- — kein Treffer
- ) : null}
-
-
- {filterActive ? (
-
- Filter aktiv
-
- ) : null}
- {filterActive ? (
-
- Filter zurücksetzen
-
- ) : null}
- setFilterPanelOpen((v) => !v)}
- aria-expanded={filterPanelOpen}
- >
- {filterPanelOpen ? 'Filter einklappen' : 'Filter anzeigen'}
-
-
-
-
- {!filterPanelOpen && filterActive && filterSummaryParts.length > 0 ? (
-
- {filterSummaryParts.map((part) => (
-
- {part}
-
- ))}
-
- ) : null}
-
- {filterPanelOpen ? (
-
-
-
- Suche (Titel, Ziele, Katalog)
- updateFilter({ query: e.target.value })}
- placeholder="z. B. Gürtel, Koordination …"
- disabled={fwImportSubmitting}
- />
-
-
-
- Ziel-Session-Dauer
-
- {[
- { id: 'any', label: 'Alle' },
- { id: 'range', label: 'Zeitspanne' },
- { id: 'preset', label: 'Vorhandene Zeiten' },
- ].map((opt) => (
-
-
- updateFilter({
- durationMode: opt.id,
- ...(opt.id === 'any'
- ? { durationRangeFrom: '', durationRangeTo: '', durationPresetMin: null }
- : {}),
- })
- }
- />
- {opt.label}
-
- ))}
-
-
- {filters.durationMode === 'range' ? (
-
- ) : null}
-
- {filters.durationMode === 'preset' ? (
- distinctDurations.length === 0 ? (
-
- In der Bibliothek sind noch keine Session-Dauern hinterlegt. Nutze „Zeitspanne“ oder lege
- Dauer pro Session im Rahmenprogramm fest.
-
- ) : (
-
- {distinctDurations.map((min) => {
- const on = filters.durationPresetMin === min
- return (
-
- updateFilter({
- durationMode: 'preset',
- durationPresetMin: on ? null : min,
- durationRangeFrom: '',
- durationRangeTo: '',
- })
- }
- >
- {formatDurationDisplay(min)}
-
- )
- })}
-
- )
- ) : null}
-
-
- {catalogFocusAreas.length > 0 ? (
-
-
Fokusbereich
-
- {catalogFocusAreas.map((fa) => (
-
- toggleId('focusAreaIds', fa.id)}
- disabled={fwImportSubmitting}
- />
- {fa.name}
-
- ))}
-
-
- ) : null}
-
- {catalogTrainingTypes.length > 0 ? (
-
-
Trainingsart
-
- {catalogTrainingTypes.map((t) => (
-
- toggleId('trainingTypeIds', t.id)}
- disabled={fwImportSubmitting}
- />
- {t.name}
-
- ))}
-
-
- ) : null}
-
- {catalogTargetGroups.length > 0 ? (
-
-
Zielgruppe
-
- {catalogTargetGroups.map((tg) => (
-
- toggleId('targetGroupIds', tg.id)}
- disabled={fwImportSubmitting}
- />
- {tg.name}
-
- ))}
-
-
- ) : null}
-
-
- Entwicklungsziele sind freie Texte — die Suche durchsucht auch Ziel-Titel. Bei der Dauer werden nur
- Programme mit hinterlegter Session-Dauer berücksichtigt.
-
-
- ) : null}
+
diff --git a/frontend/src/pages/TrainingFrameworkProgramsListPage.jsx b/frontend/src/pages/TrainingFrameworkProgramsListPage.jsx
index 55822d0..e67a4f4 100644
--- a/frontend/src/pages/TrainingFrameworkProgramsListPage.jsx
+++ b/frontend/src/pages/TrainingFrameworkProgramsListPage.jsx
@@ -1,10 +1,16 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import api from '../utils/api'
import NavStateLink from '../components/NavStateLink'
+import FrameworkProgramsFilterBlock from '../components/planning/FrameworkProgramsFilterBlock'
import { useAuth } from '../context/AuthContext'
import { getTenantClubDependencyKey } from '../utils/activeClub'
import { buildFrameworkProgramsListReturnContext } from '../utils/navReturnContext'
-import { frameworkSessionDurationLabel } from '../utils/frameworkProgramListHelpers'
+import {
+ EMPTY_FRAMEWORK_IMPORT_FILTERS,
+ filterFrameworkPrograms,
+ frameworkSessionDurationLabel,
+ hasActiveFrameworkImportFilters,
+} from '../utils/frameworkProgramListHelpers'
function dashIfEmpty(val) {
const s = (val ?? '').toString().trim()
@@ -87,16 +93,38 @@ export default function TrainingFrameworkProgramsListPage() {
const [rows, setRows] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
+ const [catalogFocusAreas, setCatalogFocusAreas] = useState([])
+ const [catalogTrainingTypes, setCatalogTrainingTypes] = useState([])
+ const [catalogTargetGroups, setCatalogTargetGroups] = useState([])
+ const [filters, setFilters] = useState(() => ({ ...EMPTY_FRAMEWORK_IMPORT_FILTERS }))
+ const [filterPanelOpen, setFilterPanelOpen] = useState(true)
+
+ const filteredRows = useMemo(
+ () => filterFrameworkPrograms(rows, filters),
+ [rows, filters]
+ )
+ const filterActive = hasActiveFrameworkImportFilters(filters)
const load = useCallback(async () => {
setLoading(true)
setError('')
try {
- const list = await api.listTrainingFrameworkPrograms()
+ const [list, fa, tt, tg] = await Promise.all([
+ api.listTrainingFrameworkPrograms(),
+ api.listFocusAreas({ status: 'active' }),
+ api.listTrainingTypes({ status: 'active' }),
+ api.listTargetGroups({ status: 'active' }),
+ ])
setRows(Array.isArray(list) ? list : [])
+ setCatalogFocusAreas(Array.isArray(fa) ? fa : [])
+ setCatalogTrainingTypes(Array.isArray(tt) ? tt : [])
+ setCatalogTargetGroups(Array.isArray(tg) ? tg : [])
} catch (e) {
setError(e.message || 'Laden fehlgeschlagen')
setRows([])
+ setCatalogFocusAreas([])
+ setCatalogTrainingTypes([])
+ setCatalogTargetGroups([])
} finally {
setLoading(false)
}
@@ -181,8 +209,31 @@ export default function TrainingFrameworkProgramsListPage() {
) : (
+ <>
+
+
+ {filteredRows.length === 0 ? (
+
+
+ {filterActive
+ ? 'Kein Rahmenprogramm passt zu den gewählten Filtern. Passe die Kriterien an oder setze den Filter zurück.'
+ : 'Keine Einträge.'}
+
+
+ ) : (
- {rows.map((r) => (
+ {filteredRows.map((r) => (
))}
+ )}
+ >
)}
>
)