Enhance Framework Programs Filtering and UI Components
All checks were successful
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 38s
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 1m14s
All checks were successful
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 38s
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 1m14s
- 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.
This commit is contained in:
parent
2de4c0b7c9
commit
2d187447bb
|
|
@ -5854,12 +5854,16 @@ html.modal-scroll-locked .app-main {
|
||||||
border-color: var(--border2);
|
border-color: var(--border2);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Rahmenprogramm-Filter auf Übersichtsseite */
|
/* Rahmenprogramm-/Modul-Filter auf Übersichtsseite */
|
||||||
.fw-prog-filter-block--list {
|
.fw-prog-filter-block--list,
|
||||||
|
.planning-list-filter-bar {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
.fw-prog-filter-block--list .fw-import-filter-panel {
|
.planning-list-filter-bar__search {
|
||||||
margin-top: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.planning-list-filter-bar .fw-import-results-bar {
|
||||||
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* —— Rahmenprogramm-Bibliothek (Liste) —— */
|
/* —— Rahmenprogramm-Bibliothek (Liste) —— */
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,20 @@
|
||||||
import React, { useMemo } from 'react'
|
import React, { useEffect, useMemo, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
collectDistinctSessionDurationsMinutes,
|
collectDistinctSessionDurationsMinutes,
|
||||||
EMPTY_FRAMEWORK_IMPORT_FILTERS,
|
EMPTY_FRAMEWORK_IMPORT_FILTERS,
|
||||||
filterFrameworkPrograms,
|
filterFrameworkPrograms,
|
||||||
hasActiveFrameworkImportFilters,
|
hasActiveFrameworkImportFilters,
|
||||||
summarizeFrameworkImportFilters,
|
|
||||||
} from '../../utils/frameworkProgramListHelpers'
|
} 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({
|
export default function FrameworkProgramsFilterBlock({
|
||||||
programs = [],
|
programs = [],
|
||||||
filters,
|
filters,
|
||||||
onFiltersChange,
|
onFiltersChange,
|
||||||
panelOpen = true,
|
|
||||||
onPanelOpenChange,
|
|
||||||
catalogFocusAreas = [],
|
catalogFocusAreas = [],
|
||||||
catalogTrainingTypes = [],
|
catalogTrainingTypes = [],
|
||||||
catalogTargetGroups = [],
|
catalogTargetGroups = [],
|
||||||
|
|
@ -26,11 +24,11 @@ export default function FrameworkProgramsFilterBlock({
|
||||||
durationRadioName = 'fw-duration-mode',
|
durationRadioName = 'fw-duration-mode',
|
||||||
showHint = true,
|
showHint = true,
|
||||||
className = '',
|
className = '',
|
||||||
|
searchPlaceholder = 'Suche (Titel, Ziele, Katalog) …',
|
||||||
|
filterModalTitle = 'Rahmenprogramme filtern',
|
||||||
|
resultLabel = 'Rahmenprogramm',
|
||||||
}) {
|
}) {
|
||||||
const distinctDurations = useMemo(
|
const [filterModalOpen, setFilterModalOpen] = useState(false)
|
||||||
() => collectDistinctSessionDurationsMinutes(programs),
|
|
||||||
[programs]
|
|
||||||
)
|
|
||||||
|
|
||||||
const matchCount = useMemo(
|
const matchCount = useMemo(
|
||||||
() => filterFrameworkPrograms(programs, filters, skillSummaries).length,
|
() => filterFrameworkPrograms(programs, filters, skillSummaries).length,
|
||||||
|
|
@ -39,319 +37,143 @@ export default function FrameworkProgramsFilterBlock({
|
||||||
|
|
||||||
const totalCount = (programs || []).length
|
const totalCount = (programs || []).length
|
||||||
const filterActive = hasActiveFrameworkImportFilters(filters)
|
const filterActive = hasActiveFrameworkImportFilters(filters)
|
||||||
const filterSummaryParts = useMemo(
|
|
||||||
|
const filterChips = useMemo(
|
||||||
() =>
|
() =>
|
||||||
summarizeFrameworkImportFilters(filters, {
|
buildPlanningArtifactFilterChips({
|
||||||
|
filters,
|
||||||
|
setFilters: onFiltersChange,
|
||||||
|
catalogs: {
|
||||||
focusAreas: catalogFocusAreas,
|
focusAreas: catalogFocusAreas,
|
||||||
trainingTypes: catalogTrainingTypes,
|
trainingTypes: catalogTrainingTypes,
|
||||||
targetGroups: catalogTargetGroups,
|
targetGroups: catalogTargetGroups,
|
||||||
skills: catalogSkills,
|
skills: catalogSkills,
|
||||||
|
},
|
||||||
|
artifactType: 'framework_program',
|
||||||
|
emptyFilters: EMPTY_FRAMEWORK_IMPORT_FILTERS,
|
||||||
}),
|
}),
|
||||||
[filters, catalogFocusAreas, catalogTrainingTypes, catalogTargetGroups, catalogSkills]
|
[
|
||||||
|
filters,
|
||||||
|
onFiltersChange,
|
||||||
|
catalogFocusAreas,
|
||||||
|
catalogTrainingTypes,
|
||||||
|
catalogTargetGroups,
|
||||||
|
catalogSkills,
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
const updateFilter = (patch) => onFiltersChange((prev) => ({ ...prev, ...patch }))
|
|
||||||
|
|
||||||
const clearFilters = () => onFiltersChange({ ...EMPTY_FRAMEWORK_IMPORT_FILTERS })
|
const clearFilters = () => onFiltersChange({ ...EMPTY_FRAMEWORK_IMPORT_FILTERS })
|
||||||
|
|
||||||
const toggleId = (key, id) => {
|
useEffect(() => {
|
||||||
const s = String(id)
|
if (!filterModalOpen) return undefined
|
||||||
onFiltersChange((prev) => {
|
const onKey = (e) => {
|
||||||
const cur = prev[key] || []
|
if (e.key === 'Escape') setFilterModalOpen(false)
|
||||||
const next = cur.includes(s) ? cur.filter((x) => x !== s) : [...cur, s]
|
|
||||||
return { ...prev, [key]: next }
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
document.addEventListener('keydown', onKey)
|
||||||
|
return () => document.removeEventListener('keydown', onKey)
|
||||||
|
}, [filterModalOpen])
|
||||||
|
|
||||||
const togglePanel = () => {
|
const resultPlural = totalCount === 1 ? resultLabel : `${resultLabel}e`
|
||||||
if (onPanelOpenChange) onPanelOpenChange(!panelOpen)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={'fw-prog-filter-block' + (className ? ` ${className}` : '')}>
|
<div className={'fw-prog-filter-block planning-list-filter-bar' + (className ? ` ${className}` : '')}>
|
||||||
|
<div className="card exercise-search-bar planning-list-filter-bar__search">
|
||||||
|
<label className="form-label">Suche</label>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
className="form-input exercise-search-bar__primary"
|
||||||
|
value={filters.query}
|
||||||
|
onChange={(e) => onFiltersChange((prev) => ({ ...prev, query: e.target.value }))}
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
disabled={disabled}
|
||||||
|
enterKeyHint="search"
|
||||||
|
/>
|
||||||
|
<div className="exercise-search-bar__actions exercise-search-bar__actions--split">
|
||||||
|
<div className="exercise-search-bar__actions-main">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary exercise-filter-trigger"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => setFilterModalOpen(true)}
|
||||||
|
>
|
||||||
|
Filter
|
||||||
|
{filterChips.length > 0 ? (
|
||||||
|
<span className="exercise-filter-badge" aria-hidden>
|
||||||
|
{filterChips.length}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
{filterChips.length > 0 ? (
|
||||||
|
<button type="button" className="btn" disabled={disabled} onClick={clearFilters}>
|
||||||
|
Alle entfernen
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{filterChips.length > 0 ? (
|
||||||
|
<div className="exercise-filter-chips-row" role="list" aria-label="Aktive Filter">
|
||||||
|
{filterChips.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.key}
|
||||||
|
type="button"
|
||||||
|
role="listitem"
|
||||||
|
className="exercise-filter-chip"
|
||||||
|
title="Filter entfernen"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => c.onRemove()}
|
||||||
|
>
|
||||||
|
<span className="exercise-filter-chip__text">{c.label}</span>
|
||||||
|
<span className="exercise-filter-chip__x" aria-hidden>
|
||||||
|
×
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{showHint ? (
|
||||||
|
<p className="exercise-search-hint form-sub" style={{ marginBottom: 0 }}>
|
||||||
|
Fachliche Filter über „Filter“ — zwischen Feldern UND. Fähigkeiten vergleichen nur unter
|
||||||
|
Rahmenprogrammen.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="fw-import-results-bar">
|
<div className="fw-import-results-bar">
|
||||||
<div className="fw-import-results-bar__count">
|
<div className="fw-import-results-bar__count">
|
||||||
<strong className="fw-import-results-bar__num">{matchCount}</strong>
|
<strong className="fw-import-results-bar__num">{matchCount}</strong>
|
||||||
<span>
|
<span>
|
||||||
{' '}
|
{' '}
|
||||||
von {totalCount} Rahmenprogramm{totalCount === 1 ? '' : 'en'}
|
von {totalCount} {resultPlural}
|
||||||
</span>
|
</span>
|
||||||
{matchCount === 0 && totalCount > 0 ? (
|
{matchCount === 0 && totalCount > 0 ? (
|
||||||
<span className="fw-import-results-bar__warn"> — kein Treffer</span>
|
<span className="fw-import-results-bar__warn"> — kein Treffer</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
{filterActive ? (
|
||||||
<div className="fw-import-results-bar__actions">
|
<div className="fw-import-results-bar__actions">
|
||||||
{filterActive ? (
|
<span className="fw-import-filter-badge">Filter aktiv</span>
|
||||||
<span className="fw-import-filter-badge" title={filterSummaryParts.join(' · ')}>
|
</div>
|
||||||
Filter aktiv
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
{filterActive ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={clearFilters}
|
|
||||||
>
|
|
||||||
Filter zurücksetzen
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
{onPanelOpenChange ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={togglePanel}
|
|
||||||
aria-expanded={panelOpen}
|
|
||||||
>
|
|
||||||
{panelOpen ? 'Filter einklappen' : 'Filter anzeigen'}
|
|
||||||
</button>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{!panelOpen && filterActive && filterSummaryParts.length > 0 ? (
|
<PlanningArtifactFilterModal
|
||||||
<ul className="fw-import-filter-chips" aria-label="Aktive Filter">
|
open={filterModalOpen}
|
||||||
{filterSummaryParts.map((part) => (
|
onClose={() => setFilterModalOpen(false)}
|
||||||
<li key={part} className="fw-import-filter-chip">
|
filters={filters}
|
||||||
{part}
|
onFiltersChange={onFiltersChange}
|
||||||
</li>
|
artifactType="framework_program"
|
||||||
))}
|
programs={programs}
|
||||||
</ul>
|
catalogFocusAreas={catalogFocusAreas}
|
||||||
) : null}
|
catalogTrainingTypes={catalogTrainingTypes}
|
||||||
|
catalogTargetGroups={catalogTargetGroups}
|
||||||
{panelOpen ? (
|
catalogSkills={catalogSkills}
|
||||||
<div className="fw-import-filter-panel">
|
durationRadioName={durationRadioName}
|
||||||
<div className="fw-import-filter-panel__grid">
|
onResetAll={clearFilters}
|
||||||
<div className="form-row fw-import-filter-panel__search">
|
|
||||||
<label className="form-label">Suche (Titel, Ziele, Katalog)</label>
|
|
||||||
<input
|
|
||||||
className="form-input"
|
|
||||||
value={filters.query}
|
|
||||||
onChange={(e) => updateFilter({ query: e.target.value })}
|
|
||||||
placeholder="z. B. Gürtel, Koordination …"
|
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
title={filterModalTitle}
|
||||||
|
showCatalogFilters
|
||||||
|
showDurationFilters
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<fieldset className="fw-import-duration-fieldset">
|
|
||||||
<legend className="form-label">Ziel-Session-Dauer</legend>
|
|
||||||
<div className="fw-import-duration-mode" role="radiogroup" aria-label="Dauer-Filtermodus">
|
|
||||||
{[
|
|
||||||
{ id: 'any', label: 'Alle' },
|
|
||||||
{ id: 'range', label: 'Zeitspanne' },
|
|
||||||
{ id: 'preset', label: 'Vorhandene Zeiten' },
|
|
||||||
].map((opt) => (
|
|
||||||
<label key={opt.id} className="fw-import-duration-mode__opt">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name={durationRadioName}
|
|
||||||
checked={filters.durationMode === opt.id}
|
|
||||||
disabled={disabled || (opt.id === 'preset' && distinctDurations.length === 0)}
|
|
||||||
onChange={() =>
|
|
||||||
updateFilter({
|
|
||||||
durationMode: opt.id,
|
|
||||||
...(opt.id === 'any'
|
|
||||||
? { durationRangeFrom: '', durationRangeTo: '', durationPresetMin: null }
|
|
||||||
: {}),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<span>{opt.label}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{filters.durationMode === 'range' ? (
|
|
||||||
<div className="responsive-grid-2 fw-import-duration-range">
|
|
||||||
<div className="form-row" style={{ marginBottom: 0 }}>
|
|
||||||
<label className="form-label">Von (Minuten)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
className="form-input"
|
|
||||||
value={filters.durationRangeFrom}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateFilter({ durationMode: 'range', durationRangeFrom: e.target.value })
|
|
||||||
}
|
|
||||||
placeholder="z. B. 60"
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-row" style={{ marginBottom: 0 }}>
|
|
||||||
<label className="form-label">Bis (Minuten)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
className="form-input"
|
|
||||||
value={filters.durationRangeTo}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateFilter({ durationMode: 'range', durationRangeTo: e.target.value })
|
|
||||||
}
|
|
||||||
placeholder="z. B. 90"
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{filters.durationMode === 'preset' ? (
|
|
||||||
distinctDurations.length === 0 ? (
|
|
||||||
<p className="form-sub" style={{ margin: '8px 0 0' }}>
|
|
||||||
In der Bibliothek sind noch keine Session-Dauern hinterlegt. Nutze „Zeitspanne“ oder lege
|
|
||||||
Dauer pro Session im Rahmenprogramm fest.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div className="fw-import-duration-presets">
|
|
||||||
{distinctDurations.map((min) => {
|
|
||||||
const on = filters.durationPresetMin === min
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={min}
|
|
||||||
type="button"
|
|
||||||
className={
|
|
||||||
'btn framework-ctrl framework-ctrl--xs' +
|
|
||||||
(on ? ' fw-import-duration-preset--on' : ' btn-secondary')
|
|
||||||
}
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={() =>
|
|
||||||
updateFilter({
|
|
||||||
durationMode: 'preset',
|
|
||||||
durationPresetMin: on ? null : min,
|
|
||||||
durationRangeFrom: '',
|
|
||||||
durationRangeTo: '',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{formatDurationDisplay(min)}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
) : null}
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
{catalogFocusAreas.length > 0 ? (
|
|
||||||
<div className="fw-import-catalog-block">
|
|
||||||
<span className="form-label">Fokusbereich</span>
|
|
||||||
<div className="framework-catalog-checkgrid">
|
|
||||||
{catalogFocusAreas.map((fa) => (
|
|
||||||
<label key={fa.id} className="framework-catalog-check">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={(filters.focusAreaIds || []).includes(String(fa.id))}
|
|
||||||
onChange={() => toggleId('focusAreaIds', fa.id)}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
<span>{fa.name}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{catalogTrainingTypes.length > 0 ? (
|
|
||||||
<div className="fw-import-catalog-block">
|
|
||||||
<span className="form-label">Trainingsart</span>
|
|
||||||
<div className="framework-catalog-checkgrid">
|
|
||||||
{catalogTrainingTypes.map((t) => (
|
|
||||||
<label key={t.id} className="framework-catalog-check">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={(filters.trainingTypeIds || []).includes(String(t.id))}
|
|
||||||
onChange={() => toggleId('trainingTypeIds', t.id)}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
<span>{t.name}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{catalogTargetGroups.length > 0 ? (
|
|
||||||
<div className="fw-import-catalog-block">
|
|
||||||
<span className="form-label">Zielgruppe</span>
|
|
||||||
<div className="framework-catalog-checkgrid">
|
|
||||||
{catalogTargetGroups.map((tg) => (
|
|
||||||
<label key={tg.id} className="framework-catalog-check">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={(filters.targetGroupIds || []).includes(String(tg.id))}
|
|
||||||
onChange={() => toggleId('targetGroupIds', tg.id)}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
<span>{tg.name}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{catalogSkills.length > 0 ? (
|
|
||||||
<div className="fw-import-catalog-block fw-import-catalog-block--skills">
|
|
||||||
<span className="form-label">Fähigkeiten (Rahmenprogramme)</span>
|
|
||||||
<p className="form-sub" style={{ margin: '0 0 8px' }}>
|
|
||||||
Filtert nach Trainingsgewicht relativ zum stärksten sichtbaren Rahmenprogramm je Fähigkeit — nur
|
|
||||||
unter Rahmenprogrammen, nicht gegen Module.
|
|
||||||
</p>
|
|
||||||
<div className="framework-catalog-checkgrid fw-import-skill-grid">
|
|
||||||
{catalogSkills.map((sk) => (
|
|
||||||
<label key={sk.id} className="framework-catalog-check">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={(filters.skillIds || []).includes(String(sk.id))}
|
|
||||||
onChange={() => toggleId('skillIds', sk.id)}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
<span>{sk.name}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{(filters.skillIds || []).length > 0 ? (
|
|
||||||
<div className="fw-import-skill-options">
|
|
||||||
<div className="form-row" style={{ marginBottom: 8 }}>
|
|
||||||
<label className="form-label">Mindest-Anteil am Rahmenprogramm-Maximum</label>
|
|
||||||
<select
|
|
||||||
className="form-input"
|
|
||||||
value={String(filters.skillMinClubPercent ?? 0)}
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateFilter({ skillMinClubPercent: Number(e.target.value) || 0 })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="0">Kein Minimum (nur markieren)</option>
|
|
||||||
<option value="25">mind. 25%</option>
|
|
||||||
<option value="50">mind. 50%</option>
|
|
||||||
<option value="75">mind. 75%</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="form-row" style={{ marginBottom: 0 }}>
|
|
||||||
<label className="form-label">Sortierung</label>
|
|
||||||
<select
|
|
||||||
className="form-input"
|
|
||||||
value={filters.skillSort || 'title'}
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={(e) => updateFilter({ skillSort: e.target.value })}
|
|
||||||
>
|
|
||||||
<option value="title">Bibliotheks-Reihenfolge</option>
|
|
||||||
<option value="skill_strength">Stärkste gewählte Fähigkeit zuerst</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
{showHint ? (
|
|
||||||
<p className="form-sub fw-import-filter-panel__hint">
|
|
||||||
Entwicklungsziele sind freie Texte — die Suche durchsucht auch Ziel-Titel. Bei der Dauer werden nur
|
|
||||||
Programme mit hinterlegter Session-Dauer berücksichtigt.
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
275
frontend/src/components/planning/PlanningArtifactFilterModal.jsx
Normal file
275
frontend/src/components/planning/PlanningArtifactFilterModal.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<div
|
||||||
|
className="admin-modal-backdrop"
|
||||||
|
role="presentation"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) onClose()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="admin-modal-sheet exercise-filter-modal"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="planning-filter-modal-title"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="admin-modal-sheet__header">
|
||||||
|
<h3 id="planning-filter-modal-title" className="admin-modal-sheet__title">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
<button type="button" className="btn btn-secondary admin-modal-sheet__close" onClick={onClose}>
|
||||||
|
Schließen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="admin-modal-sheet__body exercise-filter-modal__scroll">
|
||||||
|
<p className="muted" style={{ marginTop: 0, marginBottom: '14px' }}>
|
||||||
|
Zwischen den Bereichen gilt <strong>UND</strong>. Mehrere Katalog-Werte innerhalb eines Feldes
|
||||||
|
bedeuten <strong>ODER</strong>. Fähigkeiten filtern nach Trainingsgewicht — nur unter sichtbaren{' '}
|
||||||
|
{artifactLabel}.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{showCatalogFilters ? (
|
||||||
|
<section className="exercise-filter-section">
|
||||||
|
<h4 className="exercise-filter-section-title">Katalog</h4>
|
||||||
|
<div className="exercise-filters-modal-grid exercise-filters-modal-grid--catalog">
|
||||||
|
{catalogFocusAreas.length > 0 ? (
|
||||||
|
<div className="fw-import-catalog-block">
|
||||||
|
<span className="form-label">Fokusbereich</span>
|
||||||
|
<div className="framework-catalog-checkgrid">
|
||||||
|
{catalogFocusAreas.map((fa) => (
|
||||||
|
<label key={fa.id} className="framework-catalog-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={(filters.focusAreaIds || []).includes(String(fa.id))}
|
||||||
|
onChange={() => toggleId('focusAreaIds', fa.id)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<span>{fa.name}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{catalogTrainingTypes.length > 0 ? (
|
||||||
|
<div className="fw-import-catalog-block">
|
||||||
|
<span className="form-label">Trainingsart</span>
|
||||||
|
<div className="framework-catalog-checkgrid">
|
||||||
|
{catalogTrainingTypes.map((t) => (
|
||||||
|
<label key={t.id} className="framework-catalog-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={(filters.trainingTypeIds || []).includes(String(t.id))}
|
||||||
|
onChange={() => toggleId('trainingTypeIds', t.id)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<span>{t.name}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{catalogTargetGroups.length > 0 ? (
|
||||||
|
<div className="fw-import-catalog-block">
|
||||||
|
<span className="form-label">Zielgruppe</span>
|
||||||
|
<div className="framework-catalog-checkgrid">
|
||||||
|
{catalogTargetGroups.map((tg) => (
|
||||||
|
<label key={tg.id} className="framework-catalog-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={(filters.targetGroupIds || []).includes(String(tg.id))}
|
||||||
|
onChange={() => toggleId('targetGroupIds', tg.id)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<span>{tg.name}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{showDurationFilters ? (
|
||||||
|
<section className="exercise-filter-section">
|
||||||
|
<h4 className="exercise-filter-section-title">Session-Dauer</h4>
|
||||||
|
<fieldset className="fw-import-duration-fieldset" style={{ border: 0, padding: 0, margin: 0 }}>
|
||||||
|
<div className="fw-import-duration-mode" role="radiogroup" aria-label="Dauer-Filtermodus">
|
||||||
|
{[
|
||||||
|
{ id: 'any', label: 'Alle' },
|
||||||
|
{ id: 'range', label: 'Zeitspanne' },
|
||||||
|
{ id: 'preset', label: 'Vorhandene Zeiten' },
|
||||||
|
].map((opt) => (
|
||||||
|
<label key={opt.id} className="fw-import-duration-mode__opt">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={durationRadioName}
|
||||||
|
checked={filters.durationMode === opt.id}
|
||||||
|
disabled={disabled || (opt.id === 'preset' && distinctDurations.length === 0)}
|
||||||
|
onChange={() =>
|
||||||
|
updateFilter({
|
||||||
|
durationMode: opt.id,
|
||||||
|
...(opt.id === 'any'
|
||||||
|
? { durationRangeFrom: '', durationRangeTo: '', durationPresetMin: null }
|
||||||
|
: {}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>{opt.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filters.durationMode === 'range' ? (
|
||||||
|
<div className="responsive-grid-2 fw-import-duration-range" style={{ marginTop: 10 }}>
|
||||||
|
<div className="form-row" style={{ marginBottom: 0 }}>
|
||||||
|
<label className="form-label">Von (Minuten)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
className="form-input"
|
||||||
|
value={filters.durationRangeFrom}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateFilter({ durationMode: 'range', durationRangeFrom: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="z. B. 60"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-row" style={{ marginBottom: 0 }}>
|
||||||
|
<label className="form-label">Bis (Minuten)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
className="form-input"
|
||||||
|
value={filters.durationRangeTo}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateFilter({ durationMode: 'range', durationRangeTo: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="z. B. 90"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{filters.durationMode === 'preset' ? (
|
||||||
|
distinctDurations.length === 0 ? (
|
||||||
|
<p className="form-sub" style={{ margin: '8px 0 0' }}>
|
||||||
|
Noch keine Session-Dauern hinterlegt.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="fw-import-duration-presets" style={{ marginTop: 10 }}>
|
||||||
|
{distinctDurations.map((min) => {
|
||||||
|
const on = filters.durationPresetMin === min
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={min}
|
||||||
|
type="button"
|
||||||
|
className={
|
||||||
|
'btn framework-ctrl framework-ctrl--xs' +
|
||||||
|
(on ? ' fw-import-duration-preset--on' : ' btn-secondary')
|
||||||
|
}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() =>
|
||||||
|
updateFilter({
|
||||||
|
durationMode: 'preset',
|
||||||
|
durationPresetMin: on ? null : min,
|
||||||
|
durationRangeFrom: '',
|
||||||
|
durationRangeTo: '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{formatDurationDisplay(min)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</fieldset>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{catalogSkills.length > 0 ? (
|
||||||
|
<section className="exercise-filter-section exercise-filter-section--last">
|
||||||
|
<h4 className="exercise-filter-section-title">Fähigkeit und Trainingsgewicht</h4>
|
||||||
|
<PlanningSkillFilterSection
|
||||||
|
artifactType={artifactType}
|
||||||
|
skillIds={filters.skillIds || []}
|
||||||
|
onSkillIdsChange={(v) => 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}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="exercise-filter-modal__footer">
|
||||||
|
<button type="button" className="btn" onClick={onResetAll} disabled={disabled}>
|
||||||
|
Alle Filter zurücksetzen
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-primary" onClick={onClose}>
|
||||||
|
Fertig
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
<div className="exercise-filter-skill-block">
|
||||||
|
<label className="form-label">Fähigkeit</label>
|
||||||
|
<SkillTreeMultiSelect
|
||||||
|
value={skillIds}
|
||||||
|
onChange={onSkillIdsChange}
|
||||||
|
skills={skillsCatalog}
|
||||||
|
placeholder="Fähigkeit suchen …"
|
||||||
|
/>
|
||||||
|
<p className="exercise-filter-skill-hint">
|
||||||
|
Trainingsgewicht relativ zum stärksten sichtbaren Eintrag unter {peerLabel} — nicht gemischt mit
|
||||||
|
anderen Planungs-Artefakttypen.
|
||||||
|
</p>
|
||||||
|
{(skillIds || []).length > 0 ? (
|
||||||
|
<div className="fw-import-skill-options" style={{ marginTop: 10 }}>
|
||||||
|
<div className="form-row" style={{ marginBottom: 8 }}>
|
||||||
|
<label className="form-label">Mindest-Anteil am {peerMaxLabel}</label>
|
||||||
|
<select
|
||||||
|
className="form-input"
|
||||||
|
value={String(skillMinClubPercent ?? 0)}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => onSkillMinClubPercentChange(Number(e.target.value) || 0)}
|
||||||
|
>
|
||||||
|
<option value="0">Kein Minimum (nur markieren)</option>
|
||||||
|
<option value="25">mind. 25%</option>
|
||||||
|
<option value="50">mind. 50%</option>
|
||||||
|
<option value="75">mind. 75%</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="form-row" style={{ marginBottom: 0 }}>
|
||||||
|
<label className="form-label">Sortierung</label>
|
||||||
|
<select
|
||||||
|
className="form-input"
|
||||||
|
value={skillSort || 'title'}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => onSkillSortChange(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="title">Bibliotheks-Reihenfolge</option>
|
||||||
|
<option value="skill_strength">Stärkste gewählte Fähigkeit zuerst</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
148
frontend/src/components/planning/TrainingModulesFilterBlock.jsx
Normal file
148
frontend/src/components/planning/TrainingModulesFilterBlock.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className={'planning-list-filter-bar' + (className ? ` ${className}` : '')}>
|
||||||
|
<div className="card exercise-search-bar planning-list-filter-bar__search">
|
||||||
|
<label className="form-label">Suche</label>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
className="form-input exercise-search-bar__primary"
|
||||||
|
value={filters.query}
|
||||||
|
onChange={(e) => onFiltersChange((prev) => ({ ...prev, query: e.target.value }))}
|
||||||
|
placeholder="Titel oder Kurzbeschreibung …"
|
||||||
|
disabled={disabled}
|
||||||
|
enterKeyHint="search"
|
||||||
|
/>
|
||||||
|
<div className="exercise-search-bar__actions exercise-search-bar__actions--split">
|
||||||
|
<div className="exercise-search-bar__actions-main">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary exercise-filter-trigger"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => setFilterModalOpen(true)}
|
||||||
|
>
|
||||||
|
Filter
|
||||||
|
{filterChips.length > 0 ? (
|
||||||
|
<span className="exercise-filter-badge" aria-hidden>
|
||||||
|
{filterChips.length}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
{filterChips.length > 0 ? (
|
||||||
|
<button type="button" className="btn" disabled={disabled} onClick={clearFilters}>
|
||||||
|
Alle entfernen
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{filterChips.length > 0 ? (
|
||||||
|
<div className="exercise-filter-chips-row" role="list" aria-label="Aktive Filter">
|
||||||
|
{filterChips.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.key}
|
||||||
|
type="button"
|
||||||
|
role="listitem"
|
||||||
|
className="exercise-filter-chip"
|
||||||
|
title="Filter entfernen"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => c.onRemove()}
|
||||||
|
>
|
||||||
|
<span className="exercise-filter-chip__text">{c.label}</span>
|
||||||
|
<span className="exercise-filter-chip__x" aria-hidden>
|
||||||
|
×
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<p className="exercise-search-hint form-sub" style={{ marginBottom: 0 }}>
|
||||||
|
Fachliche Filter über „Filter“. Fähigkeiten vergleichen nur unter sichtbaren Modulen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="fw-import-results-bar">
|
||||||
|
<div className="fw-import-results-bar__count">
|
||||||
|
<strong className="fw-import-results-bar__num">{matchCount}</strong>
|
||||||
|
<span>
|
||||||
|
{' '}
|
||||||
|
von {totalCount} Modul{totalCount === 1 ? '' : 'en'}
|
||||||
|
</span>
|
||||||
|
{matchCount === 0 && totalCount > 0 ? (
|
||||||
|
<span className="fw-import-results-bar__warn"> — kein Treffer</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{filterActive ? (
|
||||||
|
<div className="fw-import-results-bar__actions">
|
||||||
|
<span className="fw-import-filter-badge">Filter aktiv</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PlanningArtifactFilterModal
|
||||||
|
open={filterModalOpen}
|
||||||
|
onClose={() => setFilterModalOpen(false)}
|
||||||
|
filters={filters}
|
||||||
|
onFiltersChange={onFiltersChange}
|
||||||
|
artifactType="training_module"
|
||||||
|
catalogSkills={catalogSkills}
|
||||||
|
onResetAll={clearFilters}
|
||||||
|
disabled={disabled}
|
||||||
|
title="Trainingsmodule filtern"
|
||||||
|
showCatalogFilters={false}
|
||||||
|
showDurationFilters={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -35,12 +35,10 @@ export default function TrainingPlanningFrameworkImportModal({
|
||||||
onClose,
|
onClose,
|
||||||
}) {
|
}) {
|
||||||
const [filters, setFilters] = useState(() => ({ ...EMPTY_FRAMEWORK_IMPORT_FILTERS }))
|
const [filters, setFilters] = useState(() => ({ ...EMPTY_FRAMEWORK_IMPORT_FILTERS }))
|
||||||
const [filterPanelOpen, setFilterPanelOpen] = useState(true)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setFilters({ ...EMPTY_FRAMEWORK_IMPORT_FILTERS })
|
setFilters({ ...EMPTY_FRAMEWORK_IMPORT_FILTERS })
|
||||||
setFilterPanelOpen(true)
|
|
||||||
}
|
}
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
|
|
@ -80,13 +78,14 @@ export default function TrainingPlanningFrameworkImportModal({
|
||||||
programs={frameworkProgramsList}
|
programs={frameworkProgramsList}
|
||||||
filters={filters}
|
filters={filters}
|
||||||
onFiltersChange={setFilters}
|
onFiltersChange={setFilters}
|
||||||
panelOpen={filterPanelOpen}
|
|
||||||
onPanelOpenChange={setFilterPanelOpen}
|
|
||||||
catalogFocusAreas={catalogFocusAreas}
|
catalogFocusAreas={catalogFocusAreas}
|
||||||
catalogTrainingTypes={catalogTrainingTypes}
|
catalogTrainingTypes={catalogTrainingTypes}
|
||||||
catalogTargetGroups={catalogTargetGroups}
|
catalogTargetGroups={catalogTargetGroups}
|
||||||
disabled={fwImportSubmitting}
|
disabled={fwImportSubmitting}
|
||||||
durationRadioName="fw-duration-mode"
|
durationRadioName="fw-duration-mode"
|
||||||
|
showHint={false}
|
||||||
|
searchPlaceholder="Rahmenprogramm suchen …"
|
||||||
|
filterModalTitle="Rahmenprogramme filtern"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="form-row fw-import-program-select">
|
<div className="form-row fw-import-program-select">
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,6 @@ export default function TrainingFrameworkProgramsListPage() {
|
||||||
const [catalogTargetGroups, setCatalogTargetGroups] = useState([])
|
const [catalogTargetGroups, setCatalogTargetGroups] = useState([])
|
||||||
const [catalogSkills, setCatalogSkills] = useState([])
|
const [catalogSkills, setCatalogSkills] = useState([])
|
||||||
const [filters, setFilters] = useState(() => ({ ...EMPTY_FRAMEWORK_IMPORT_FILTERS }))
|
const [filters, setFilters] = useState(() => ({ ...EMPTY_FRAMEWORK_IMPORT_FILTERS }))
|
||||||
const [filterPanelOpen, setFilterPanelOpen] = useState(true)
|
|
||||||
const [skillSummaries, setSkillSummaries] = useState({})
|
const [skillSummaries, setSkillSummaries] = useState({})
|
||||||
const [summariesLoading, setSummariesLoading] = useState(false)
|
const [summariesLoading, setSummariesLoading] = useState(false)
|
||||||
const [profileModal, setProfileModal] = useState(null)
|
const [profileModal, setProfileModal] = useState(null)
|
||||||
|
|
@ -45,7 +44,7 @@ export default function TrainingFrameworkProgramsListPage() {
|
||||||
api.listFocusAreas({ status: 'active' }),
|
api.listFocusAreas({ status: 'active' }),
|
||||||
api.listTrainingTypes({ status: 'active' }),
|
api.listTrainingTypes({ status: 'active' }),
|
||||||
api.listTargetGroups({ status: 'active' }),
|
api.listTargetGroups({ status: 'active' }),
|
||||||
api.listSkills({ status: 'active' }),
|
api.listSkillsCatalog({ status: 'active' }),
|
||||||
])
|
])
|
||||||
setRows(Array.isArray(list) ? list : [])
|
setRows(Array.isArray(list) ? list : [])
|
||||||
setCatalogFocusAreas(Array.isArray(fa) ? fa : [])
|
setCatalogFocusAreas(Array.isArray(fa) ? fa : [])
|
||||||
|
|
@ -162,8 +161,6 @@ export default function TrainingFrameworkProgramsListPage() {
|
||||||
programs={rows}
|
programs={rows}
|
||||||
filters={filters}
|
filters={filters}
|
||||||
onFiltersChange={setFilters}
|
onFiltersChange={setFilters}
|
||||||
panelOpen={filterPanelOpen}
|
|
||||||
onPanelOpenChange={setFilterPanelOpen}
|
|
||||||
catalogFocusAreas={catalogFocusAreas}
|
catalogFocusAreas={catalogFocusAreas}
|
||||||
catalogTrainingTypes={catalogTrainingTypes}
|
catalogTrainingTypes={catalogTrainingTypes}
|
||||||
catalogTargetGroups={catalogTargetGroups}
|
catalogTargetGroups={catalogTargetGroups}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,17 @@
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
import NavStateLink from '../components/NavStateLink'
|
import NavStateLink from '../components/NavStateLink'
|
||||||
|
import TrainingModulesFilterBlock from '../components/planning/TrainingModulesFilterBlock'
|
||||||
import SkillProfileCompact from '../components/skills/SkillProfileCompact'
|
import SkillProfileCompact from '../components/skills/SkillProfileCompact'
|
||||||
import SkillProfileFullModal from '../components/skills/SkillProfileFullModal'
|
import SkillProfileFullModal from '../components/skills/SkillProfileFullModal'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { getTenantClubDependencyKey } from '../utils/activeClub'
|
import { getTenantClubDependencyKey } from '../utils/activeClub'
|
||||||
import { buildTrainingModulesListReturnContext } from '../utils/navReturnContext'
|
import { buildTrainingModulesListReturnContext } from '../utils/navReturnContext'
|
||||||
|
import {
|
||||||
|
EMPTY_TRAINING_MODULE_FILTERS,
|
||||||
|
filterTrainingModules,
|
||||||
|
hasActiveTrainingModuleFilters,
|
||||||
|
} from '../utils/trainingModuleListHelpers'
|
||||||
|
|
||||||
export default function TrainingModulesListPage() {
|
export default function TrainingModulesListPage() {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
|
|
@ -14,19 +20,32 @@ export default function TrainingModulesListPage() {
|
||||||
const [rows, setRows] = useState([])
|
const [rows, setRows] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
const [catalogSkills, setCatalogSkills] = useState([])
|
||||||
|
const [filters, setFilters] = useState(() => ({ ...EMPTY_TRAINING_MODULE_FILTERS }))
|
||||||
const [skillSummaries, setSkillSummaries] = useState({})
|
const [skillSummaries, setSkillSummaries] = useState({})
|
||||||
const [summariesLoading, setSummariesLoading] = useState(false)
|
const [summariesLoading, setSummariesLoading] = useState(false)
|
||||||
const [profileModal, setProfileModal] = useState(null)
|
const [profileModal, setProfileModal] = useState(null)
|
||||||
|
|
||||||
|
const filteredRows = useMemo(
|
||||||
|
() => filterTrainingModules(rows, filters, skillSummaries),
|
||||||
|
[rows, filters, skillSummaries]
|
||||||
|
)
|
||||||
|
const filterActive = hasActiveTrainingModuleFilters(filters)
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError('')
|
setError('')
|
||||||
try {
|
try {
|
||||||
const list = await api.listTrainingModules()
|
const [list, skills] = await Promise.all([
|
||||||
|
api.listTrainingModules(),
|
||||||
|
api.listSkillsCatalog({ status: 'active' }),
|
||||||
|
])
|
||||||
setRows(Array.isArray(list) ? list : [])
|
setRows(Array.isArray(list) ? list : [])
|
||||||
|
setCatalogSkills(Array.isArray(skills) ? skills : [])
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e.message || 'Laden fehlgeschlagen')
|
setError(e.message || 'Laden fehlgeschlagen')
|
||||||
setRows([])
|
setRows([])
|
||||||
|
setCatalogSkills([])
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
|
@ -86,7 +105,8 @@ export default function TrainingModulesListPage() {
|
||||||
Trainingsmodule
|
Trainingsmodule
|
||||||
</h1>
|
</h1>
|
||||||
<p style={{ color: 'var(--text2)', fontSize: '0.95rem', maxWidth: '38rem', margin: 0 }}>
|
<p style={{ color: 'var(--text2)', fontSize: '0.95rem', maxWidth: '38rem', margin: 0 }}>
|
||||||
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.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<NavStateLink
|
<NavStateLink
|
||||||
|
|
@ -109,8 +129,30 @@ export default function TrainingModulesListPage() {
|
||||||
<p style={{ margin: 0, color: 'var(--text2)' }}>Noch keine Module angelegt.</p>
|
<p style={{ margin: 0, color: 'var(--text2)' }}>Noch keine Module angelegt.</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
<>
|
||||||
{rows.map((r) => (
|
<TrainingModulesFilterBlock
|
||||||
|
modules={rows}
|
||||||
|
filters={filters}
|
||||||
|
onFiltersChange={setFilters}
|
||||||
|
catalogSkills={catalogSkills}
|
||||||
|
skillSummaries={skillSummaries}
|
||||||
|
className="fw-prog-filter-block--list"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{filteredRows.length === 0 ? (
|
||||||
|
<div className="card" style={{ padding: '1.25rem' }}>
|
||||||
|
<h2 style={{ margin: '0 0 0.5rem', fontSize: '1.05rem' }}>Kein Treffer</h2>
|
||||||
|
<p style={{ margin: 0, color: 'var(--text2)' }}>
|
||||||
|
{filterActive
|
||||||
|
? 'Kein Modul passt zu den gewählten Filtern. Passe die Kriterien an oder setze den Filter zurück.'
|
||||||
|
: 'Keine Einträge.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul
|
||||||
|
style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: '10px' }}
|
||||||
|
>
|
||||||
|
{filteredRows.map((r) => (
|
||||||
<li key={r.id} className="card" style={{ padding: '1rem 1.15rem' }}>
|
<li key={r.id} className="card" style={{ padding: '1rem 1.15rem' }}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -135,7 +177,14 @@ export default function TrainingModulesListPage() {
|
||||||
>
|
>
|
||||||
{(r.title || '').trim() || `Modul #${r.id}`}
|
{(r.title || '').trim() || `Modul #${r.id}`}
|
||||||
</NavStateLink>
|
</NavStateLink>
|
||||||
<p style={{ margin: '0.35rem 0 0', fontSize: '0.88rem', color: 'var(--text2)', lineHeight: 1.45 }}>
|
<p
|
||||||
|
style={{
|
||||||
|
margin: '0.35rem 0 0',
|
||||||
|
fontSize: '0.88rem',
|
||||||
|
color: 'var(--text2)',
|
||||||
|
lineHeight: 1.45,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{(r.summary || '').trim() || '—'}{' '}
|
{(r.summary || '').trim() || '—'}{' '}
|
||||||
<span style={{ color: 'var(--text3)' }}>
|
<span style={{ color: 'var(--text3)' }}>
|
||||||
({Number(r.items_count) || 0} Position{r.items_count === 1 ? '' : 'en'})
|
({Number(r.items_count) || 0} Position{r.items_count === 1 ? '' : 'en'})
|
||||||
|
|
@ -146,7 +195,8 @@ export default function TrainingModulesListPage() {
|
||||||
summary={skillSummaries[`training_module:${r.id}`]}
|
summary={skillSummaries[`training_module:${r.id}`]}
|
||||||
artifactType="training_module"
|
artifactType="training_module"
|
||||||
loading={summariesLoading}
|
loading={summariesLoading}
|
||||||
displayLimit={12}
|
displayLimit={filters.skillDisplayLimit || 12}
|
||||||
|
highlightSkillIds={filters.skillIds || []}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -181,6 +231,8 @@ export default function TrainingModulesListPage() {
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<SkillProfileFullModal
|
<SkillProfileFullModal
|
||||||
open={Boolean(profileModal)}
|
open={Boolean(profileModal)}
|
||||||
|
|
|
||||||
137
frontend/src/utils/planningArtifactFilterChips.js
Normal file
137
frontend/src/utils/planningArtifactFilterChips.js
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
import { formatDurationDisplay } from './trainingDurationUtils'
|
||||||
|
import { EMPTY_FRAMEWORK_IMPORT_FILTERS } from './frameworkProgramListHelpers'
|
||||||
|
import { EMPTY_TRAINING_MODULE_FILTERS } from './trainingModuleListHelpers'
|
||||||
|
import { peerCorpusCountLabel } from './skillProfileListHelpers'
|
||||||
|
|
||||||
|
function nameById(list, id) {
|
||||||
|
return list?.find((x) => 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
|
||||||
|
}
|
||||||
57
frontend/src/utils/trainingModuleListHelpers.js
Normal file
57
frontend/src/utils/trainingModuleListHelpers.js
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user