Verbesserung Darstellung Rahmenprogramme #42

Merged
Lars merged 6 commits from develop into main 2026-05-20 16:29:53 +02:00
4 changed files with 376 additions and 260 deletions
Showing only changes of commit 9d122d4808 - Show all commits

View File

@ -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;

View File

@ -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 (
<div className={'fw-prog-filter-block' + (className ? ` ${className}` : '')}>
<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} Rahmenprogramm{totalCount === 1 ? '' : 'en'}
</span>
{matchCount === 0 && totalCount > 0 ? (
<span className="fw-import-results-bar__warn"> kein Treffer</span>
) : null}
</div>
<div className="fw-import-results-bar__actions">
{filterActive ? (
<span className="fw-import-filter-badge" title={filterSummaryParts.join(' · ')}>
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}
</div>
</div>
{!panelOpen && filterActive && filterSummaryParts.length > 0 ? (
<ul className="fw-import-filter-chips" aria-label="Aktive Filter">
{filterSummaryParts.map((part) => (
<li key={part} className="fw-import-filter-chip">
{part}
</li>
))}
</ul>
) : null}
{panelOpen ? (
<div className="fw-import-filter-panel">
<div className="fw-import-filter-panel__grid">
<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}
/>
</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}
</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>
)
}

View File

@ -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({
<strong>eigene geplante Einheit</strong> in der aktuellen Gruppe (Kopie des Ablaufs).
</p>
<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} Rahmenprogramm{totalCount === 1 ? '' : 'en'}
</span>
{matchCount === 0 && totalCount > 0 ? (
<span className="fw-import-results-bar__warn"> kein Treffer</span>
) : null}
</div>
<div className="fw-import-results-bar__actions">
{filterActive ? (
<span className="fw-import-filter-badge" title={filterSummaryParts.join(' · ')}>
Filter aktiv
</span>
) : null}
{filterActive ? (
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
disabled={fwImportSubmitting}
onClick={clearFilters}
>
Filter zurücksetzen
</button>
) : null}
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
disabled={fwImportSubmitting}
onClick={() => setFilterPanelOpen((v) => !v)}
aria-expanded={filterPanelOpen}
>
{filterPanelOpen ? 'Filter einklappen' : 'Filter anzeigen'}
</button>
</div>
</div>
{!filterPanelOpen && filterActive && filterSummaryParts.length > 0 ? (
<ul className="fw-import-filter-chips" aria-label="Aktive Filter">
{filterSummaryParts.map((part) => (
<li key={part} className="fw-import-filter-chip">
{part}
</li>
))}
</ul>
) : null}
{filterPanelOpen ? (
<div className="fw-import-filter-panel">
<div className="fw-import-filter-panel__grid">
<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={fwImportSubmitting}
/>
</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="fw-duration-mode"
checked={filters.durationMode === opt.id}
disabled={fwImportSubmitting || (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={fwImportSubmitting}
/>
</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={fwImportSubmitting}
/>
</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={fwImportSubmitting}
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={fwImportSubmitting}
/>
<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={fwImportSubmitting}
/>
<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={fwImportSubmitting}
/>
<span>{tg.name}</span>
</label>
))}
</div>
</div>
) : null}
</div>
<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>
</div>
) : null}
<FrameworkProgramsFilterBlock
programs={frameworkProgramsList}
filters={filters}
onFiltersChange={setFilters}
panelOpen={filterPanelOpen}
onPanelOpenChange={setFilterPanelOpen}
catalogFocusAreas={catalogFocusAreas}
catalogTrainingTypes={catalogTrainingTypes}
catalogTargetGroups={catalogTargetGroups}
disabled={fwImportSubmitting}
durationRadioName="fw-duration-mode"
/>
<div className="form-row fw-import-program-select">
<label className="form-label">

View File

@ -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() {
</NavStateLink>
</div>
) : (
<>
<FrameworkProgramsFilterBlock
programs={rows}
filters={filters}
onFiltersChange={setFilters}
panelOpen={filterPanelOpen}
onPanelOpenChange={setFilterPanelOpen}
catalogFocusAreas={catalogFocusAreas}
catalogTrainingTypes={catalogTrainingTypes}
catalogTargetGroups={catalogTargetGroups}
durationRadioName="fw-list-duration-mode"
className="fw-prog-filter-block--list"
/>
{filteredRows.length === 0 ? (
<div className="card" style={{ marginTop: '1rem' }}>
<p style={{ color: 'var(--text2)', margin: 0 }}>
{filterActive
? 'Kein Rahmenprogramm passt zu den gewählten Filtern. Passe die Kriterien an oder setze den Filter zurück.'
: 'Keine Einträge.'}
</p>
</div>
) : (
<ul className="framework-programs-list">
{rows.map((r) => (
{filteredRows.map((r) => (
<li key={r.id} className="card">
<div
style={{
@ -239,6 +290,8 @@ export default function TrainingFrameworkProgramsListPage() {
</li>
))}
</ul>
)}
</>
)}
</>
)