From 2d187447bbd62f5fc01280d767d0d7c14aa2f8e0 Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 21 May 2026 10:23:05 +0200 Subject: [PATCH] Enhance Framework Programs Filtering and UI Components - Updated the FrameworkProgramsFilterBlock to include a search input and filter modal, improving user interaction and accessibility. - Refactored CSS styles for filter components to ensure consistent layout and spacing. - Removed deprecated panel open state management, streamlining the component logic. - Integrated new filtering capabilities in the TrainingPlanningFrameworkImportModal and TrainingModulesListPage, enhancing the overall filtering experience. - Improved the display of active filters and results count, providing clearer feedback to users. --- frontend/src/app.css | 12 +- .../planning/FrameworkProgramsFilterBlock.jsx | 430 +++++------------- .../planning/PlanningArtifactFilterModal.jsx | 275 +++++++++++ .../planning/PlanningSkillFilterSection.jsx | 69 +++ .../planning/TrainingModulesFilterBlock.jsx | 148 ++++++ .../TrainingPlanningFrameworkImportModal.jsx | 7 +- .../TrainingFrameworkProgramsListPage.jsx | 5 +- .../src/pages/TrainingModulesListPage.jsx | 190 +++++--- .../src/utils/planningArtifactFilterChips.js | 137 ++++++ .../src/utils/trainingModuleListHelpers.js | 57 +++ 10 files changed, 945 insertions(+), 385 deletions(-) create mode 100644 frontend/src/components/planning/PlanningArtifactFilterModal.jsx create mode 100644 frontend/src/components/planning/PlanningSkillFilterSection.jsx create mode 100644 frontend/src/components/planning/TrainingModulesFilterBlock.jsx create mode 100644 frontend/src/utils/planningArtifactFilterChips.js create mode 100644 frontend/src/utils/trainingModuleListHelpers.js diff --git a/frontend/src/app.css b/frontend/src/app.css index 343fbee..bd4581c 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -5854,12 +5854,16 @@ html.modal-scroll-locked .app-main { border-color: var(--border2); } -/* Rahmenprogramm-Filter auf Übersichtsseite */ -.fw-prog-filter-block--list { +/* Rahmenprogramm-/Modul-Filter auf Übersichtsseite */ +.fw-prog-filter-block--list, +.planning-list-filter-bar { margin-bottom: 1rem; } -.fw-prog-filter-block--list .fw-import-filter-panel { - margin-top: 0.75rem; +.planning-list-filter-bar__search { + margin-bottom: 0.75rem; +} +.planning-list-filter-bar .fw-import-results-bar { + margin-top: 0; } /* —— Rahmenprogramm-Bibliothek (Liste) —— */ diff --git a/frontend/src/components/planning/FrameworkProgramsFilterBlock.jsx b/frontend/src/components/planning/FrameworkProgramsFilterBlock.jsx index 983abab..4e77016 100644 --- a/frontend/src/components/planning/FrameworkProgramsFilterBlock.jsx +++ b/frontend/src/components/planning/FrameworkProgramsFilterBlock.jsx @@ -1,22 +1,20 @@ -import React, { useMemo } from 'react' +import React, { useEffect, useMemo, useState } from 'react' import { collectDistinctSessionDurationsMinutes, EMPTY_FRAMEWORK_IMPORT_FILTERS, filterFrameworkPrograms, hasActiveFrameworkImportFilters, - summarizeFrameworkImportFilters, } from '../../utils/frameworkProgramListHelpers' -import { formatDurationDisplay } from '../../utils/trainingDurationUtils' +import { buildPlanningArtifactFilterChips } from '../../utils/planningArtifactFilterChips' +import PlanningArtifactFilterModal from './PlanningArtifactFilterModal' /** - * Gemeinsamer Filter für Rahmenprogramm-Liste und Import-Dialog. + * Filter-Leiste für Rahmenprogramm-Liste und Import-Dialog (UX wie Übungsliste). */ export default function FrameworkProgramsFilterBlock({ programs = [], filters, onFiltersChange, - panelOpen = true, - onPanelOpenChange, catalogFocusAreas = [], catalogTrainingTypes = [], catalogTargetGroups = [], @@ -26,11 +24,11 @@ export default function FrameworkProgramsFilterBlock({ durationRadioName = 'fw-duration-mode', showHint = true, className = '', + searchPlaceholder = 'Suche (Titel, Ziele, Katalog) …', + filterModalTitle = 'Rahmenprogramme filtern', + resultLabel = 'Rahmenprogramm', }) { - const distinctDurations = useMemo( - () => collectDistinctSessionDurationsMinutes(programs), - [programs] - ) + const [filterModalOpen, setFilterModalOpen] = useState(false) const matchCount = useMemo( () => filterFrameworkPrograms(programs, filters, skillSummaries).length, @@ -39,319 +37,143 @@ export default function FrameworkProgramsFilterBlock({ const totalCount = (programs || []).length const filterActive = hasActiveFrameworkImportFilters(filters) - const filterSummaryParts = useMemo( - () => - summarizeFrameworkImportFilters(filters, { - focusAreas: catalogFocusAreas, - trainingTypes: catalogTrainingTypes, - targetGroups: catalogTargetGroups, - skills: catalogSkills, - }), - [filters, catalogFocusAreas, catalogTrainingTypes, catalogTargetGroups, catalogSkills] - ) - const updateFilter = (patch) => onFiltersChange((prev) => ({ ...prev, ...patch })) + const filterChips = useMemo( + () => + buildPlanningArtifactFilterChips({ + filters, + setFilters: onFiltersChange, + catalogs: { + focusAreas: catalogFocusAreas, + trainingTypes: catalogTrainingTypes, + targetGroups: catalogTargetGroups, + skills: catalogSkills, + }, + artifactType: 'framework_program', + emptyFilters: EMPTY_FRAMEWORK_IMPORT_FILTERS, + }), + [ + filters, + onFiltersChange, + catalogFocusAreas, + catalogTrainingTypes, + catalogTargetGroups, + catalogSkills, + ] + ) 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 } - }) - } + useEffect(() => { + if (!filterModalOpen) return undefined + const onKey = (e) => { + if (e.key === 'Escape') setFilterModalOpen(false) + } + document.addEventListener('keydown', onKey) + return () => document.removeEventListener('keydown', onKey) + }, [filterModalOpen]) - const togglePanel = () => { - if (onPanelOpenChange) onPanelOpenChange(!panelOpen) - } + const resultPlural = totalCount === 1 ? resultLabel : `${resultLabel}e` return ( -
+
+
+ + onFiltersChange((prev) => ({ ...prev, query: e.target.value }))} + placeholder={searchPlaceholder} + disabled={disabled} + enterKeyHint="search" + /> +
+
+ + {filterChips.length > 0 ? ( + + ) : null} +
+
+ {filterChips.length > 0 ? ( +
+ {filterChips.map((c) => ( + + ))} +
+ ) : null} + {showHint ? ( +

+ Fachliche Filter über „Filter“ — zwischen Feldern UND. Fähigkeiten vergleichen nur unter + Rahmenprogrammen. +

+ ) : null} +
+
{matchCount} {' '} - von {totalCount} Rahmenprogramm{totalCount === 1 ? '' : 'en'} + von {totalCount} {resultPlural} {matchCount === 0 && totalCount > 0 ? ( — kein Treffer ) : null}
-
- {filterActive ? ( - - Filter aktiv - - ) : null} - {filterActive ? ( - - ) : null} - {onPanelOpenChange ? ( - - ) : null} -
+ {filterActive ? ( +
+ Filter aktiv +
+ ) : null}
- {!panelOpen && filterActive && filterSummaryParts.length > 0 ? ( -
    - {filterSummaryParts.map((part) => ( -
  • - {part} -
  • - ))} -
- ) : null} - - {panelOpen ? ( -
-
-
- - 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) => ( - - ))} -
- - {filters.durationMode === 'range' ? ( -
-
- - - updateFilter({ durationMode: 'range', durationRangeFrom: e.target.value }) - } - placeholder="z. B. 60" - disabled={disabled} - /> -
-
- - - updateFilter({ durationMode: 'range', durationRangeTo: e.target.value }) - } - placeholder="z. B. 90" - disabled={disabled} - /> -
-
- ) : 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 ( - - ) - })} -
- ) - ) : null} -
- - {catalogFocusAreas.length > 0 ? ( -
- Fokusbereich -
- {catalogFocusAreas.map((fa) => ( - - ))} -
-
- ) : null} - - {catalogTrainingTypes.length > 0 ? ( -
- Trainingsart -
- {catalogTrainingTypes.map((t) => ( - - ))} -
-
- ) : null} - - {catalogTargetGroups.length > 0 ? ( -
- Zielgruppe -
- {catalogTargetGroups.map((tg) => ( - - ))} -
-
- ) : null} - - {catalogSkills.length > 0 ? ( -
- Fähigkeiten (Rahmenprogramme) -

- Filtert nach Trainingsgewicht relativ zum stärksten sichtbaren Rahmenprogramm je Fähigkeit — nur - unter Rahmenprogrammen, nicht gegen Module. -

-
- {catalogSkills.map((sk) => ( - - ))} -
- {(filters.skillIds || []).length > 0 ? ( -
-
- - -
-
- - -
-
- ) : null} -
- ) : 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} + setFilterModalOpen(false)} + filters={filters} + onFiltersChange={onFiltersChange} + artifactType="framework_program" + programs={programs} + catalogFocusAreas={catalogFocusAreas} + catalogTrainingTypes={catalogTrainingTypes} + catalogTargetGroups={catalogTargetGroups} + catalogSkills={catalogSkills} + durationRadioName={durationRadioName} + onResetAll={clearFilters} + disabled={disabled} + title={filterModalTitle} + showCatalogFilters + showDurationFilters + />
) } diff --git a/frontend/src/components/planning/PlanningArtifactFilterModal.jsx b/frontend/src/components/planning/PlanningArtifactFilterModal.jsx new file mode 100644 index 0000000..d2eadc5 --- /dev/null +++ b/frontend/src/components/planning/PlanningArtifactFilterModal.jsx @@ -0,0 +1,275 @@ +import React, { useMemo } from 'react' +import { collectDistinctSessionDurationsMinutes } from '../../utils/frameworkProgramListHelpers' +import { formatDurationDisplay } from '../../utils/trainingDurationUtils' +import PlanningSkillFilterSection from './PlanningSkillFilterSection' + +/** + * Filter-Modal für Rahmenprogramme / Trainingsmodule (UX wie ExerciseListFilterModal). + */ +export default function PlanningArtifactFilterModal({ + open, + onClose, + filters, + onFiltersChange, + artifactType = 'framework_program', + programs = [], + catalogFocusAreas = [], + catalogTrainingTypes = [], + catalogTargetGroups = [], + catalogSkills = [], + durationRadioName = 'planning-duration-mode', + onResetAll, + disabled = false, + showCatalogFilters = true, + showDurationFilters = true, + title = 'Filtern', +}) { + const distinctDurations = useMemo( + () => (showDurationFilters ? collectDistinctSessionDurationsMinutes(programs) : []), + [programs, showDurationFilters] + ) + + if (!open) return null + + const updateFilter = (patch) => onFiltersChange((prev) => ({ ...prev, ...patch })) + + 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 artifactLabel = + artifactType === 'training_module' + ? 'Trainingsmodule' + : artifactType === 'framework_program' + ? 'Rahmenprogramme' + : 'Einträge' + + return ( +
{ + if (e.target === e.currentTarget) onClose() + }} + > +
e.stopPropagation()} + > +
+

+ {title} +

+ +
+
+

+ Zwischen den Bereichen gilt UND. Mehrere Katalog-Werte innerhalb eines Feldes + bedeuten ODER. Fähigkeiten filtern nach Trainingsgewicht — nur unter sichtbaren{' '} + {artifactLabel}. +

+ + {showCatalogFilters ? ( +
+

Katalog

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

Session-Dauer

+
+
+ {[ + { id: 'any', label: 'Alle' }, + { id: 'range', label: 'Zeitspanne' }, + { id: 'preset', label: 'Vorhandene Zeiten' }, + ].map((opt) => ( + + ))} +
+ + {filters.durationMode === 'range' ? ( +
+
+ + + updateFilter({ durationMode: 'range', durationRangeFrom: e.target.value }) + } + placeholder="z. B. 60" + disabled={disabled} + /> +
+
+ + + updateFilter({ durationMode: 'range', durationRangeTo: e.target.value }) + } + placeholder="z. B. 90" + disabled={disabled} + /> +
+
+ ) : null} + + {filters.durationMode === 'preset' ? ( + distinctDurations.length === 0 ? ( +

+ Noch keine Session-Dauern hinterlegt. +

+ ) : ( +
+ {distinctDurations.map((min) => { + const on = filters.durationPresetMin === min + return ( + + ) + })} +
+ ) + ) : null} +
+
+ ) : null} + + {catalogSkills.length > 0 ? ( +
+

Fähigkeit und Trainingsgewicht

+ updateFilter({ skillIds: v })} + skillMinClubPercent={filters.skillMinClubPercent ?? 0} + onSkillMinClubPercentChange={(v) => updateFilter({ skillMinClubPercent: v })} + skillSort={filters.skillSort || 'title'} + onSkillSortChange={(v) => updateFilter({ skillSort: v })} + skillsCatalog={catalogSkills} + disabled={disabled} + /> +
+ ) : null} +
+
+ + +
+
+
+ ) +} diff --git a/frontend/src/components/planning/PlanningSkillFilterSection.jsx b/frontend/src/components/planning/PlanningSkillFilterSection.jsx new file mode 100644 index 0000000..16fb796 --- /dev/null +++ b/frontend/src/components/planning/PlanningSkillFilterSection.jsx @@ -0,0 +1,69 @@ +import React from 'react' +import SkillTreeMultiSelect from '../SkillTreeMultiSelect' +import { peerCorpusCountLabel } from '../../utils/skillProfileListHelpers' + +/** + * Fähigkeiten-Filter für Planungsartefakte (Rahmenprogramme, Module). + * Semantik wie Übungsliste (SkillTreeMultiSelect), aber mit Peer-Prozent statt Stufen. + */ +export default function PlanningSkillFilterSection({ + artifactType = 'framework_program', + skillIds = [], + onSkillIdsChange, + skillMinClubPercent = 0, + onSkillMinClubPercentChange, + skillSort = 'title', + onSkillSortChange, + skillsCatalog = [], + disabled = false, +}) { + const peerLabel = peerCorpusCountLabel(artifactType) + const peerMaxLabel = + artifactType === 'framework_program' ? 'Rahmenprogramm-Maximum' : `${peerLabel}-Maximum` + + return ( +
+ + +

+ Trainingsgewicht relativ zum stärksten sichtbaren Eintrag unter {peerLabel} — nicht gemischt mit + anderen Planungs-Artefakttypen. +

+ {(skillIds || []).length > 0 ? ( +
+
+ + +
+
+ + +
+
+ ) : null} +
+ ) +} diff --git a/frontend/src/components/planning/TrainingModulesFilterBlock.jsx b/frontend/src/components/planning/TrainingModulesFilterBlock.jsx new file mode 100644 index 0000000..d5a9daa --- /dev/null +++ b/frontend/src/components/planning/TrainingModulesFilterBlock.jsx @@ -0,0 +1,148 @@ +import React, { useEffect, useMemo, useState } from 'react' +import { + EMPTY_TRAINING_MODULE_FILTERS, + filterTrainingModules, + hasActiveTrainingModuleFilters, +} from '../../utils/trainingModuleListHelpers' +import { buildPlanningArtifactFilterChips } from '../../utils/planningArtifactFilterChips' +import PlanningArtifactFilterModal from './PlanningArtifactFilterModal' + +/** + * Filter-Leiste für Trainingsmodule (UX wie Übungsliste / Rahmenprogramme). + */ +export default function TrainingModulesFilterBlock({ + modules = [], + filters, + onFiltersChange, + catalogSkills = [], + skillSummaries = null, + disabled = false, + className = '', +}) { + const [filterModalOpen, setFilterModalOpen] = useState(false) + + const matchCount = useMemo( + () => filterTrainingModules(modules, filters, skillSummaries).length, + [modules, filters, skillSummaries] + ) + + const totalCount = (modules || []).length + const filterActive = hasActiveTrainingModuleFilters(filters) + + const filterChips = useMemo( + () => + buildPlanningArtifactFilterChips({ + filters, + setFilters: onFiltersChange, + catalogs: { skills: catalogSkills }, + artifactType: 'training_module', + emptyFilters: EMPTY_TRAINING_MODULE_FILTERS, + }), + [filters, onFiltersChange, catalogSkills] + ) + + const clearFilters = () => onFiltersChange({ ...EMPTY_TRAINING_MODULE_FILTERS }) + + useEffect(() => { + if (!filterModalOpen) return undefined + const onKey = (e) => { + if (e.key === 'Escape') setFilterModalOpen(false) + } + document.addEventListener('keydown', onKey) + return () => document.removeEventListener('keydown', onKey) + }, [filterModalOpen]) + + return ( +
+
+ + onFiltersChange((prev) => ({ ...prev, query: e.target.value }))} + placeholder="Titel oder Kurzbeschreibung …" + disabled={disabled} + enterKeyHint="search" + /> +
+
+ + {filterChips.length > 0 ? ( + + ) : null} +
+
+ {filterChips.length > 0 ? ( +
+ {filterChips.map((c) => ( + + ))} +
+ ) : null} +

+ Fachliche Filter über „Filter“. Fähigkeiten vergleichen nur unter sichtbaren Modulen. +

+
+ +
+
+ {matchCount} + + {' '} + von {totalCount} Modul{totalCount === 1 ? '' : 'en'} + + {matchCount === 0 && totalCount > 0 ? ( + — kein Treffer + ) : null} +
+ {filterActive ? ( +
+ Filter aktiv +
+ ) : null} +
+ + setFilterModalOpen(false)} + filters={filters} + onFiltersChange={onFiltersChange} + artifactType="training_module" + catalogSkills={catalogSkills} + onResetAll={clearFilters} + disabled={disabled} + title="Trainingsmodule filtern" + showCatalogFilters={false} + showDurationFilters={false} + /> +
+ ) +} diff --git a/frontend/src/components/planning/TrainingPlanningFrameworkImportModal.jsx b/frontend/src/components/planning/TrainingPlanningFrameworkImportModal.jsx index cbadef2..1eaa060 100644 --- a/frontend/src/components/planning/TrainingPlanningFrameworkImportModal.jsx +++ b/frontend/src/components/planning/TrainingPlanningFrameworkImportModal.jsx @@ -35,12 +35,10 @@ export default function TrainingPlanningFrameworkImportModal({ onClose, }) { const [filters, setFilters] = useState(() => ({ ...EMPTY_FRAMEWORK_IMPORT_FILTERS })) - const [filterPanelOpen, setFilterPanelOpen] = useState(true) useEffect(() => { if (!open) { setFilters({ ...EMPTY_FRAMEWORK_IMPORT_FILTERS }) - setFilterPanelOpen(true) } }, [open]) @@ -80,13 +78,14 @@ export default function TrainingPlanningFrameworkImportModal({ programs={frameworkProgramsList} filters={filters} onFiltersChange={setFilters} - panelOpen={filterPanelOpen} - onPanelOpenChange={setFilterPanelOpen} catalogFocusAreas={catalogFocusAreas} catalogTrainingTypes={catalogTrainingTypes} catalogTargetGroups={catalogTargetGroups} disabled={fwImportSubmitting} durationRadioName="fw-duration-mode" + showHint={false} + searchPlaceholder="Rahmenprogramm suchen …" + filterModalTitle="Rahmenprogramme filtern" />
diff --git a/frontend/src/pages/TrainingFrameworkProgramsListPage.jsx b/frontend/src/pages/TrainingFrameworkProgramsListPage.jsx index 048e2a6..34e5e04 100644 --- a/frontend/src/pages/TrainingFrameworkProgramsListPage.jsx +++ b/frontend/src/pages/TrainingFrameworkProgramsListPage.jsx @@ -25,7 +25,6 @@ export default function TrainingFrameworkProgramsListPage() { const [catalogTargetGroups, setCatalogTargetGroups] = useState([]) const [catalogSkills, setCatalogSkills] = useState([]) const [filters, setFilters] = useState(() => ({ ...EMPTY_FRAMEWORK_IMPORT_FILTERS })) - const [filterPanelOpen, setFilterPanelOpen] = useState(true) const [skillSummaries, setSkillSummaries] = useState({}) const [summariesLoading, setSummariesLoading] = useState(false) const [profileModal, setProfileModal] = useState(null) @@ -45,7 +44,7 @@ export default function TrainingFrameworkProgramsListPage() { api.listFocusAreas({ status: 'active' }), api.listTrainingTypes({ status: 'active' }), api.listTargetGroups({ status: 'active' }), - api.listSkills({ status: 'active' }), + api.listSkillsCatalog({ status: 'active' }), ]) setRows(Array.isArray(list) ? list : []) setCatalogFocusAreas(Array.isArray(fa) ? fa : []) @@ -162,8 +161,6 @@ export default function TrainingFrameworkProgramsListPage() { programs={rows} filters={filters} onFiltersChange={setFilters} - panelOpen={filterPanelOpen} - onPanelOpenChange={setFilterPanelOpen} catalogFocusAreas={catalogFocusAreas} catalogTrainingTypes={catalogTrainingTypes} catalogTargetGroups={catalogTargetGroups} diff --git a/frontend/src/pages/TrainingModulesListPage.jsx b/frontend/src/pages/TrainingModulesListPage.jsx index a5b5e60..4f0492e 100644 --- a/frontend/src/pages/TrainingModulesListPage.jsx +++ b/frontend/src/pages/TrainingModulesListPage.jsx @@ -1,11 +1,17 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react' import api from '../utils/api' import NavStateLink from '../components/NavStateLink' +import TrainingModulesFilterBlock from '../components/planning/TrainingModulesFilterBlock' import SkillProfileCompact from '../components/skills/SkillProfileCompact' import SkillProfileFullModal from '../components/skills/SkillProfileFullModal' import { useAuth } from '../context/AuthContext' import { getTenantClubDependencyKey } from '../utils/activeClub' import { buildTrainingModulesListReturnContext } from '../utils/navReturnContext' +import { + EMPTY_TRAINING_MODULE_FILTERS, + filterTrainingModules, + hasActiveTrainingModuleFilters, +} from '../utils/trainingModuleListHelpers' export default function TrainingModulesListPage() { const { user } = useAuth() @@ -14,19 +20,32 @@ export default function TrainingModulesListPage() { const [rows, setRows] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState('') + const [catalogSkills, setCatalogSkills] = useState([]) + const [filters, setFilters] = useState(() => ({ ...EMPTY_TRAINING_MODULE_FILTERS })) const [skillSummaries, setSkillSummaries] = useState({}) const [summariesLoading, setSummariesLoading] = useState(false) const [profileModal, setProfileModal] = useState(null) + const filteredRows = useMemo( + () => filterTrainingModules(rows, filters, skillSummaries), + [rows, filters, skillSummaries] + ) + const filterActive = hasActiveTrainingModuleFilters(filters) + const load = useCallback(async () => { setLoading(true) setError('') try { - const list = await api.listTrainingModules() + const [list, skills] = await Promise.all([ + api.listTrainingModules(), + api.listSkillsCatalog({ status: 'active' }), + ]) setRows(Array.isArray(list) ? list : []) + setCatalogSkills(Array.isArray(skills) ? skills : []) } catch (e) { setError(e.message || 'Laden fehlgeschlagen') setRows([]) + setCatalogSkills([]) } finally { setLoading(false) } @@ -86,7 +105,8 @@ export default function TrainingModulesListPage() { Trainingsmodule

- Wiederverwendbare Übungsfolgen für die Trainingsplanung. Prozentwerte vergleichen Module nur unter sichtbaren Modulen. + Wiederverwendbare Übungsfolgen für die Trainingsplanung. Prozentwerte vergleichen Module nur unter + sichtbaren Modulen.

Noch keine Module angelegt.

) : ( - + + ))} + + )} + )} String(x.id) === String(id))?.name || id +} + +function peerPercentLabel(artifactType) { + const label = peerCorpusCountLabel(artifactType) + return label === 'Rahmenprogramme' ? 'Rahmenprogramm-Maximum' : `${label}-Maximum` +} + +/** + * Entfernbare Filter-Chips (UX wie Übungsliste). + */ +export function buildPlanningArtifactFilterChips({ + filters, + setFilters, + catalogs = {}, + artifactType = 'framework_program', + emptyFilters = null, +}) { + const base = + emptyFilters || + (artifactType === 'training_module' ? EMPTY_TRAINING_MODULE_FILTERS : EMPTY_FRAMEWORK_IMPORT_FILTERS) + const f = { ...base, ...filters } + const chips = [] + const peerMaxLabel = peerPercentLabel(artifactType) + + const q = (f.query || '').trim() + if (q) { + chips.push({ + key: 'query', + label: `Suche: „${q}"`, + onRemove: () => setFilters((prev) => ({ ...prev, query: '' })), + }) + } + + ;(f.skillIds || []).forEach((id) => { + chips.push({ + key: `skill-${id}`, + label: `Fähigkeit: ${nameById(catalogs.skills, id)}`, + onRemove: () => + setFilters((prev) => ({ + ...prev, + skillIds: (prev.skillIds || []).filter((x) => String(x) !== String(id)), + })), + }) + }) + + if (Number(f.skillMinClubPercent) > 0) { + chips.push({ + key: 'skill-min-pct', + label: `mind. ${f.skillMinClubPercent}% vom ${peerMaxLabel}`, + onRemove: () => setFilters((prev) => ({ ...prev, skillMinClubPercent: 0 })), + }) + } + + if (f.skillSort === 'skill_strength' && (f.skillIds || []).length) { + chips.push({ + key: 'skill-sort', + label: 'Sortierung: Fähigkeiten-Stärke', + onRemove: () => setFilters((prev) => ({ ...prev, skillSort: 'title' })), + }) + } + + ;(f.focusAreaIds || []).forEach((id) => { + chips.push({ + key: `focus-${id}`, + label: `Fokus: ${nameById(catalogs.focusAreas, id)}`, + onRemove: () => + setFilters((prev) => ({ + ...prev, + focusAreaIds: (prev.focusAreaIds || []).filter((x) => String(x) !== String(id)), + })), + }) + }) + + ;(f.trainingTypeIds || []).forEach((id) => { + chips.push({ + key: `type-${id}`, + label: `Trainingsart: ${nameById(catalogs.trainingTypes, id)}`, + onRemove: () => + setFilters((prev) => ({ + ...prev, + trainingTypeIds: (prev.trainingTypeIds || []).filter((x) => String(x) !== String(id)), + })), + }) + }) + + ;(f.targetGroupIds || []).forEach((id) => { + chips.push({ + key: `tg-${id}`, + label: `Zielgruppe: ${nameById(catalogs.targetGroups, id)}`, + onRemove: () => + setFilters((prev) => ({ + ...prev, + targetGroupIds: (prev.targetGroupIds || []).filter((x) => String(x) !== String(id)), + })), + }) + }) + + if (f.durationMode === 'range') { + const a = String(f.durationRangeFrom || '').trim() + const b = String(f.durationRangeTo || '').trim() + if (a || b) { + const fromLbl = a ? formatDurationDisplay(Number(a), { empty: a }) : '—' + const toLbl = b ? formatDurationDisplay(Number(b), { empty: b }) : '—' + chips.push({ + key: 'duration-range', + label: `Dauer: ${fromLbl} – ${toLbl}`, + onRemove: () => + setFilters((prev) => ({ + ...prev, + durationMode: 'any', + durationRangeFrom: '', + durationRangeTo: '', + })), + }) + } + } else if (f.durationMode === 'preset' && f.durationPresetMin != null) { + chips.push({ + key: 'duration-preset', + label: `Dauer: ${formatDurationDisplay(f.durationPresetMin)}`, + onRemove: () => + setFilters((prev) => ({ + ...prev, + durationMode: 'any', + durationPresetMin: null, + })), + }) + } + + return chips +} diff --git a/frontend/src/utils/trainingModuleListHelpers.js b/frontend/src/utils/trainingModuleListHelpers.js new file mode 100644 index 0000000..68a054a --- /dev/null +++ b/frontend/src/utils/trainingModuleListHelpers.js @@ -0,0 +1,57 @@ +import { + maxSelectedSkillClubPercent, + moduleSkillSummaryKey, + summaryHasSkill, +} from './skillProfileListHelpers' + +export const EMPTY_TRAINING_MODULE_FILTERS = { + query: '', + skillIds: [], + skillSort: 'title', + skillMinClubPercent: 0, + skillDisplayLimit: 12, +} + +export function hasActiveTrainingModuleFilters(filters = {}) { + const f = { ...EMPTY_TRAINING_MODULE_FILTERS, ...filters } + if ((f.query || '').trim()) return true + if ((f.skillIds || []).length) return true + if (Number(f.skillMinClubPercent) > 0) return true + if (f.skillSort === 'skill_strength' && (f.skillIds || []).length) return true + return false +} + +export function filterTrainingModules(rows, filters = {}, skillSummaries = null) { + const f = { ...EMPTY_TRAINING_MODULE_FILTERS, ...filters } + const q = (f.query || '').trim().toLowerCase() + const skillIds = f.skillIds || [] + const minClubPct = Number(f.skillMinClubPercent) || 0 + + let list = (rows || []).filter((r) => { + if (q) { + const blob = [r.title, r.summary].filter(Boolean).join(' ').toLowerCase() + if (!blob.includes(q)) return false + } + return true + }) + + if (skillIds.length && skillSummaries) { + list = list.filter((r) => { + const summary = skillSummaries[moduleSkillSummaryKey(r.id)] + if (!summary) return false + return skillIds.some((sid) => summaryHasSkill(summary, sid, minClubPct)) + }) + } + + if (f.skillSort === 'skill_strength' && skillIds.length && skillSummaries) { + list = [...list].sort((a, b) => { + const sa = skillSummaries[moduleSkillSummaryKey(a.id)] + const sb = skillSummaries[moduleSkillSummaryKey(b.id)] + const pa = maxSelectedSkillClubPercent(sa, skillIds) ?? -1 + const pb = maxSelectedSkillClubPercent(sb, skillIds) ?? -1 + return pb - pa + }) + } + + return list +}