Implement Framework Import Modal with Enhanced Filtering and Styling
All checks were successful
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m27s

- Introduced a new modal for importing training framework sessions, featuring a backdrop and panel for improved user experience.
- Added comprehensive filtering options for focus areas, training types, and target groups, utilizing a structured filter state.
- Enhanced session duration handling with distinct duration collection and display, improving clarity in program selection.
- Updated utility functions to support new filtering capabilities and session duration management, ensuring a cohesive user interface.
This commit is contained in:
Lars 2026-05-20 15:06:03 +02:00
parent 9353909fda
commit 9c3494a7ea
4 changed files with 806 additions and 212 deletions

View File

@ -2143,6 +2143,338 @@ html.modal-scroll-locked .app-main {
} }
} }
/* Rahmen-Import (Planung): großer Dialog + Filter */
.fw-import-modal-backdrop {
position: fixed;
inset: 0;
z-index: 1010;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
overflow-y: auto;
background: rgba(0, 0, 0, 0.5);
}
.fw-import-modal-panel {
width: 100%;
max-width: min(920px, 96vw);
max-height: min(94vh, 1200px);
overflow-y: auto;
box-sizing: border-box;
padding: clamp(18px, 3vw, 2rem);
border-radius: 14px;
background: var(--surface);
}
.fw-import-modal-panel__title {
margin: 0 0 0.5rem;
font-size: 1.35rem;
}
.fw-import-modal-panel__lead {
margin: 0 0 1.1rem;
font-size: 0.92rem;
line-height: 1.55;
color: var(--text2);
max-width: 52rem;
}
.fw-import-results-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px 16px;
margin-bottom: 0.85rem;
padding: 12px 14px;
border-radius: 10px;
background: var(--surface2);
border: 1px solid var(--border);
}
.fw-import-results-bar__count {
font-size: 0.95rem;
color: var(--text1);
}
.fw-import-results-bar__num {
font-size: 1.25rem;
color: var(--accent-dark);
}
.fw-import-results-bar__warn {
color: var(--danger);
font-weight: 600;
}
.fw-import-results-bar__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.fw-import-filter-badge {
display: inline-block;
padding: 4px 10px;
border-radius: 999px;
font-size: 0.78rem;
font-weight: 600;
background: color-mix(in srgb, var(--accent) 18%, var(--surface));
color: var(--accent-dark);
border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
}
.fw-import-filter-chips {
list-style: none;
margin: 0 0 12px;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.fw-import-filter-chip {
font-size: 0.78rem;
padding: 4px 10px;
border-radius: 8px;
background: var(--surface2);
border: 1px solid var(--border);
color: var(--text2);
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
.fw-import-filter-panel {
margin-bottom: 1.15rem;
padding: 16px 18px;
border-radius: 12px;
background: var(--surface2);
border: 1px solid var(--border);
}
.fw-import-filter-panel__grid {
display: grid;
gap: 14px;
}
.fw-import-filter-panel__search {
margin-bottom: 0;
}
.fw-import-filter-panel__hint {
margin: 12px 0 0;
}
.fw-import-duration-fieldset {
border: none;
margin: 0;
padding: 0;
}
.fw-import-duration-mode {
display: flex;
flex-wrap: wrap;
gap: 8px 14px;
margin: 8px 0 10px;
}
.fw-import-duration-mode__opt {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.88rem;
cursor: pointer;
}
.fw-import-duration-range {
margin-top: 4px;
}
.fw-import-duration-presets {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 6px;
}
.fw-import-duration-preset--on {
background: var(--accent-dark) !important;
color: #fff !important;
border-color: var(--accent-dark) !important;
}
.fw-import-catalog-block .form-label {
display: block;
margin-bottom: 6px;
}
.fw-import-program-select__count {
font-weight: 500;
color: var(--text3);
font-size: 0.85em;
}
.fw-import-muted {
color: var(--text2);
margin-top: 0.75rem;
}
.fw-import-sessions {
border: none;
margin: 1rem 0;
padding: 0;
}
.fw-import-sessions__list {
list-style: none;
margin: 0;
padding: 0;
}
.fw-import-sessions__item {
margin-bottom: 10px;
}
.fw-import-sessions__label {
display: flex;
gap: 10px;
align-items: flex-start;
cursor: pointer;
}
.fw-import-sessions__label--disabled {
cursor: not-allowed;
opacity: 0.55;
}
.fw-import-sessions__check {
margin-top: 0.2rem;
flex-shrink: 0;
}
.fw-import-sessions__body {
flex: 1;
min-width: 0;
}
.fw-import-sessions__title-row {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 6px 10px;
}
.fw-import-sessions__dur {
font-size: 0.82rem;
font-weight: 600;
color: var(--accent-dark);
}
.fw-import-sessions__dur--muted {
color: var(--text3);
font-weight: 500;
}
.fw-import-sessions__warn {
display: block;
font-size: 0.82rem;
color: var(--danger);
margin-top: 4px;
}
.fw-import-sessions__date {
display: block;
margin-top: 8px;
}
.fw-import-sessions__date .form-input {
max-width: 220px;
margin-top: 4px;
}
.fw-import-dates-panel {
margin-bottom: 0.75rem;
padding: 14px;
border-radius: 10px;
background: var(--surface2);
}
.fw-import-dates-panel__action {
align-self: end;
}
.fw-import-dates-panel__action .btn {
width: 100%;
}
.fw-import-modal-panel__footer {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 1.25rem;
padding-top: 0.5rem;
border-top: 1px solid var(--border);
}
/* Rahmen-Import: Mobile (Vollbild wie .modal-panel--form) */
@media (max-width: 639px) {
.fw-import-modal-backdrop {
align-items: stretch;
justify-content: stretch;
padding: 0;
}
.fw-import-modal-panel {
max-width: 100%;
width: 100%;
max-height: 100dvh;
height: 100dvh;
border-radius: 0;
padding: 12px;
padding-left: max(12px, env(safe-area-inset-left, 0px));
padding-right: max(12px, env(safe-area-inset-right, 0px));
padding-top: max(12px, env(safe-area-inset-top, 0px));
padding-bottom: max(12px, env(safe-area-inset-bottom, 0px));
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
touch-action: pan-y;
}
.fw-import-modal-panel__title {
font-size: 1.05rem;
padding-right: 4px;
}
.fw-import-modal-panel__lead {
font-size: 0.85rem;
margin-bottom: 0.85rem;
}
.fw-import-results-bar {
flex-direction: column;
align-items: stretch;
gap: 10px;
}
.fw-import-results-bar__actions {
width: 100%;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.fw-import-results-bar__actions .fw-import-filter-badge {
grid-column: 1 / -1;
text-align: center;
}
.fw-import-results-bar__actions .btn {
width: 100%;
min-height: 44px;
justify-content: center;
}
.fw-import-filter-panel {
padding: 12px;
}
.fw-import-duration-mode {
flex-direction: column;
align-items: stretch;
gap: 6px;
}
.fw-import-duration-mode__opt {
min-height: 44px;
padding: 8px 10px;
border-radius: 8px;
background: var(--surface);
border: 1px solid var(--border2);
}
.fw-import-duration-presets .btn {
min-height: 44px;
flex: 1 1 calc(50% - 4px);
min-width: calc(50% - 4px);
}
.fw-import-filter-panel .framework-catalog-checkgrid {
max-height: min(180px, 28vh);
}
.fw-import-sessions__date .form-input {
max-width: 100%;
width: 100%;
}
.fw-import-modal-panel__footer {
flex-direction: column;
gap: 8px;
padding-bottom: max(8px, env(safe-area-inset-bottom, 0px));
}
.fw-import-modal-panel__footer .btn {
width: 100%;
min-height: 48px;
}
.fw-import-modal-panel__footer .btn-primary {
order: -1;
}
}
@media (max-width: 380px) {
.fw-import-results-bar__actions {
grid-template-columns: 1fr;
}
}
/* Rahmenprogramm-Editor: Kurz-Einstieg ausklappbar */ /* Rahmenprogramm-Editor: Kurz-Einstieg ausklappbar */
.framework-edit-intro { .framework-edit-intro {
margin-bottom: 1rem; margin-bottom: 1rem;

View File

@ -1,8 +1,12 @@
import React, { useMemo, useState } from 'react' import React, { useEffect, useMemo, useState } from 'react'
import { import {
collectDistinctSessionDurationsMinutes,
EMPTY_FRAMEWORK_IMPORT_FILTERS,
filterFrameworkPrograms, filterFrameworkPrograms,
frameworkProgramOptionLabel, frameworkProgramOptionLabel,
frameworkSessionDurationLabel, frameworkSessionDurationLabel,
hasActiveFrameworkImportFilters,
summarizeFrameworkImportFilters,
} from '../../utils/frameworkProgramListHelpers' } from '../../utils/frameworkProgramListHelpers'
import { formatDurationDisplay } from '../../utils/trainingDurationUtils' import { formatDurationDisplay } from '../../utils/trainingDurationUtils'
@ -32,185 +36,320 @@ export default function TrainingPlanningFrameworkImportModal({
onSubmit, onSubmit,
onClose, onClose,
}) { }) {
const [filterQuery, setFilterQuery] = useState('') const [filters, setFilters] = useState(() => ({ ...EMPTY_FRAMEWORK_IMPORT_FILTERS }))
const [filterFocusIds, setFilterFocusIds] = useState([]) const [filterPanelOpen, setFilterPanelOpen] = useState(true)
const [filterTypeIds, setFilterTypeIds] = useState([])
const [filterTargetGroupIds, setFilterTargetGroupIds] = useState([]) useEffect(() => {
const [filterDurationMin, setFilterDurationMin] = useState('') if (!open) {
setFilters({ ...EMPTY_FRAMEWORK_IMPORT_FILTERS })
setFilterPanelOpen(true)
}
}, [open])
const distinctDurations = useMemo(
() => collectDistinctSessionDurationsMinutes(frameworkProgramsList),
[frameworkProgramsList]
)
const filteredPrograms = useMemo( const filteredPrograms = useMemo(
() => () => filterFrameworkPrograms(frameworkProgramsList, filters),
filterFrameworkPrograms(frameworkProgramsList, { [frameworkProgramsList, filters]
query: filterQuery,
focusAreaIds: filterFocusIds,
trainingTypeIds: filterTypeIds,
targetGroupIds: filterTargetGroupIds,
durationTargetMin: filterDurationMin === '' ? null : parseInt(filterDurationMin, 10),
}),
[
frameworkProgramsList,
filterQuery,
filterFocusIds,
filterTypeIds,
filterTargetGroupIds,
filterDurationMin,
]
) )
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(() => {
if (!fwImportProgramId) return
const stillVisible = filteredPrograms.some((p) => String(p.id) === String(fwImportProgramId))
if (!stillVisible) onProgramChange('')
}, [filteredPrograms, fwImportProgramId, onProgramChange])
const selectedProgramSummary = useMemo(() => { const selectedProgramSummary = useMemo(() => {
if (!fwImportProgramId) return null if (!fwImportProgramId) return null
return (frameworkProgramsList || []).find((p) => String(p.id) === String(fwImportProgramId)) return (frameworkProgramsList || []).find((p) => String(p.id) === String(fwImportProgramId))
}, [frameworkProgramsList, fwImportProgramId]) }, [frameworkProgramsList, fwImportProgramId])
const toggleId = (list, setList, id) => { const updateFilter = (patch) => setFilters((prev) => ({ ...prev, ...patch }))
const clearFilters = () => setFilters({ ...EMPTY_FRAMEWORK_IMPORT_FILTERS })
const toggleId = (key, id) => {
const s = String(id) const s = String(id)
setList((prev) => (prev.includes(s) ? prev.filter((x) => x !== s) : [...prev, s])) 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 if (!open) return null
return ( return (
<div <div
className="fw-import-modal-backdrop"
data-testid="planning-framework-import-modal" data-testid="planning-framework-import-modal"
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1010,
padding: '1rem',
overflowY: 'auto',
}}
> >
<div <div className="fw-import-modal-panel">
style={{ <h2 className="fw-import-modal-panel__title">Sessions aus Rahmen übernehmen</h2>
background: 'var(--surface)', <p className="fw-import-modal-panel__lead">
borderRadius: '12px',
padding: 'clamp(14px, 3vw, 1.75rem)',
maxWidth: 'min(680px, 100%)',
width: '100%',
maxHeight: '90vh',
overflowY: 'auto',
boxSizing: 'border-box',
minWidth: 0,
}}
>
<h2 style={{ marginBottom: '0.65rem' }}>Sessions aus Rahmen übernehmen</h2>
<p style={{ color: 'var(--text2)', fontSize: '0.9rem', marginBottom: '1rem', lineHeight: 1.5 }}>
Wähle ein Trainingsrahmenprogramm und eine oder mehrere Sessions. Pro Session entsteht eine{' '} Wähle ein Trainingsrahmenprogramm und eine oder mehrere Sessions. Pro Session entsteht eine{' '}
<strong>eigene geplante Einheit</strong> in der aktuellen Gruppe (Kopie des Ablaufs). Die{' '} <strong>eigene geplante Einheit</strong> in der aktuellen Gruppe (Kopie des Ablaufs).
<strong>Verknüpfung zum Rahmen-Slot</strong> bleibt sichtbar.
</p> </p>
<details className="planning-filter-help" style={{ marginBottom: '1rem' }}> <div className="fw-import-results-bar">
<summary className="planning-filter-help__summary">Rahmen filtern (optional)</summary> <div className="fw-import-results-bar__count">
<div className="planning-filter-help__body" style={{ display: 'grid', gap: '10px' }}> <strong className="fw-import-results-bar__num">{matchCount}</strong>
<div className="form-row" style={{ marginBottom: 0 }}> <span>
<label className="form-label">Suche (Titel, Ziele, Katalog)</label> {' '}
<input von {totalCount} Rahmenprogramm{totalCount === 1 ? '' : 'en'}
className="form-input" </span>
value={filterQuery} {matchCount === 0 && totalCount > 0 ? (
onChange={(e) => setFilterQuery(e.target.value)} <span className="fw-import-results-bar__warn"> kein Treffer</span>
placeholder="z. B. Gürtel, Koordination …" ) : 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} 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> </div>
<div className="form-row" style={{ marginBottom: 0 }}> <p className="form-sub fw-import-filter-panel__hint">
<label className="form-label">Ziel-Session-Dauer (Minuten)</label> Entwicklungsziele sind freie Texte die Suche durchsucht auch Ziel-Titel. Bei der Dauer werden nur
<input Programme mit hinterlegter Session-Dauer berücksichtigt.
type="number"
min={0}
className="form-input"
value={filterDurationMin}
onChange={(e) => setFilterDurationMin(e.target.value)}
placeholder="z. B. 90"
disabled={fwImportSubmitting}
/>
<p className="form-sub" style={{ marginTop: '4px' }}>
Zeigt Programme, deren hinterlegte Session-Dauer in etwa passt (±10 Min).
</p>
</div>
{catalogFocusAreas.length > 0 ? (
<div>
<span className="form-label" style={{ display: 'block', marginBottom: '6px' }}>
Fokusbereich
</span>
<div className="framework-catalog-checkgrid">
{catalogFocusAreas.map((fa) => (
<label key={fa.id} className="framework-catalog-check">
<input
type="checkbox"
checked={filterFocusIds.includes(String(fa.id))}
onChange={() => toggleId(filterFocusIds, setFilterFocusIds, fa.id)}
disabled={fwImportSubmitting}
/>
<span>{fa.name}</span>
</label>
))}
</div>
</div>
) : null}
{catalogTrainingTypes.length > 0 ? (
<div>
<span className="form-label" style={{ display: 'block', marginBottom: '6px' }}>
Trainingsart
</span>
<div className="framework-catalog-checkgrid">
{catalogTrainingTypes.map((t) => (
<label key={t.id} className="framework-catalog-check">
<input
type="checkbox"
checked={filterTypeIds.includes(String(t.id))}
onChange={() => toggleId(filterTypeIds, setFilterTypeIds, t.id)}
disabled={fwImportSubmitting}
/>
<span>{t.name}</span>
</label>
))}
</div>
</div>
) : null}
{catalogTargetGroups.length > 0 ? (
<div>
<span className="form-label" style={{ display: 'block', marginBottom: '6px' }}>
Zielgruppe
</span>
<div className="framework-catalog-checkgrid">
{catalogTargetGroups.map((tg) => (
<label key={tg.id} className="framework-catalog-check">
<input
type="checkbox"
checked={filterTargetGroupIds.includes(String(tg.id))}
onChange={() => toggleId(filterTargetGroupIds, setFilterTargetGroupIds, tg.id)}
disabled={fwImportSubmitting}
/>
<span>{tg.name}</span>
</label>
))}
</div>
</div>
) : null}
<p className="form-sub" style={{ margin: 0 }}>
{filteredPrograms.length} von {frameworkProgramsList.length} Rahmenprogramm(en) sichtbar.
Entwicklungsziele sind freie Texte die Suche durchsucht auch Ziel-Titel.
</p> </p>
</div> </div>
</details> ) : null}
<div className="form-row"> <div className="form-row fw-import-program-select">
<label className="form-label">Rahmenprogramm</label> <label className="form-label">
Rahmenprogramm
<span className="fw-import-program-select__count">
{' '}
({matchCount} {matchCount === 1 ? 'Treffer' : 'Treffer'})
</span>
</label>
<select <select
className="form-input" className="form-input"
value={fwImportProgramId} value={fwImportProgramId}
onChange={(e) => onProgramChange(e.target.value)} onChange={(e) => onProgramChange(e.target.value)}
disabled={fwImportLoading || fwImportSubmitting} disabled={fwImportLoading || fwImportSubmitting || matchCount === 0}
> >
<option value="">Bitte wählen</option> <option value="">
{matchCount === 0 ? 'Kein Rahmenprogramm passt zum Filter' : 'Bitte wählen…'}
</option>
{filteredPrograms.map((fp) => ( {filteredPrograms.map((fp) => (
<option key={fp.id} value={String(fp.id)}> <option key={fp.id} value={String(fp.id)}>
{frameworkProgramOptionLabel(fp)} {frameworkProgramOptionLabel(fp)}
@ -231,14 +370,12 @@ export default function TrainingPlanningFrameworkImportModal({
</div> </div>
{fwImportLoading ? ( {fwImportLoading ? (
<p style={{ color: 'var(--text2)', marginTop: '1rem' }}>Laden der Sessions</p> <p className="fw-import-muted">Laden der Sessions</p>
) : fwImportDetail?.slots?.length ? ( ) : fwImportDetail?.slots?.length ? (
<> <>
<fieldset style={{ border: 'none', margin: '1rem 0', padding: 0 }}> <fieldset className="fw-import-sessions">
<legend className="form-label" style={{ padding: 0, marginBottom: '0.5rem' }}> <legend className="form-label">Sessions (mit Ablauf)</legend>
Sessions (mit Ablauf) <ul className="fw-import-sessions__list">
</legend>
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{[...fwImportDetail.slots] {[...fwImportDetail.slots]
.sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)) .sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0))
.map((slot) => { .map((slot) => {
@ -251,51 +388,41 @@ export default function TrainingPlanningFrameworkImportModal({
? formatDurationDisplay(slot.planned_duration_min) ? formatDurationDisplay(slot.planned_duration_min)
: null : null
return ( return (
<li key={slot.id} style={{ marginBottom: '10px' }}> <li key={slot.id} className="fw-import-sessions__item">
<label <label
style={{ className={
display: 'flex', 'fw-import-sessions__label' + (hasBp ? '' : ' fw-import-sessions__label--disabled')
gap: '10px', }
alignItems: 'flex-start',
cursor: hasBp ? 'pointer' : 'not-allowed',
opacity: hasBp ? 1 : 0.55,
}}
> >
<input <input
type="checkbox" type="checkbox"
className="fw-import-sessions__check"
checked={checked} checked={checked}
disabled={!hasBp || fwImportSubmitting} disabled={!hasBp || fwImportSubmitting}
onChange={() => onToggleSlot(slot)} onChange={() => onToggleSlot(slot)}
style={{ marginTop: '0.2rem', flexShrink: 0 }}
/> />
<span style={{ flex: 1, minWidth: 0 }}> <span className="fw-import-sessions__body">
<strong>{label}</strong> <span className="fw-import-sessions__title-row">
{slotDur ? ( <strong>{label}</strong>
<span {slotDur ? (
style={{ <span className="fw-import-sessions__dur">{slotDur}</span>
marginLeft: '8px', ) : (
fontSize: '0.82rem', <span className="fw-import-sessions__dur fw-import-sessions__dur--muted">
color: 'var(--text2)', Dauer offen
fontWeight: 500, </span>
}} )}
> </span>
· {slotDur}
</span>
) : null}
{!hasBp ? ( {!hasBp ? (
<span style={{ display: 'block', fontSize: '0.82rem', color: 'var(--danger)' }}> <span className="fw-import-sessions__warn">
Ohne Session-Ablauf Übernahme nicht möglich. Ohne Session-Ablauf Übernahme nicht möglich.
</span> </span>
) : null} ) : null}
{hasBp && checked ? ( {hasBp && checked ? (
<span style={{ display: 'block', marginTop: '6px' }}> <span className="fw-import-sessions__date">
<span className="form-label" style={{ fontSize: '0.78rem' }}> <span className="form-label">Termin (Datum)</span>
Termin (Datum)
</span>
<input <input
type="date" type="date"
className="form-input" className="form-input"
style={{ maxWidth: '200px', marginTop: '4px' }}
value={fwImportSlotDates[String(slot.id)] || ''} value={fwImportSlotDates[String(slot.id)] || ''}
onChange={(e) => onSlotDateChange(String(slot.id), e.target.value)} onChange={(e) => onSlotDateChange(String(slot.id), e.target.value)}
disabled={fwImportSubmitting} disabled={fwImportSubmitting}
@ -310,15 +437,7 @@ export default function TrainingPlanningFrameworkImportModal({
</ul> </ul>
</fieldset> </fieldset>
<div <div className="fw-import-dates-panel responsive-grid-3">
className="responsive-grid-3"
style={{
marginBottom: '0.75rem',
padding: '12px',
background: 'var(--surface2)',
borderRadius: '8px',
}}
>
<div className="form-row"> <div className="form-row">
<label className="form-label">Startdatum (Vorschlag)</label> <label className="form-label">Startdatum (Vorschlag)</label>
<input <input
@ -340,11 +459,10 @@ export default function TrainingPlanningFrameworkImportModal({
disabled={fwImportSubmitting} disabled={fwImportSubmitting}
/> />
</div> </div>
<div className="form-row" style={{ alignSelf: 'end' }}> <div className="form-row fw-import-dates-panel__action">
<button <button
type="button" type="button"
className="btn btn-secondary" className="btn btn-secondary"
style={{ width: '100%' }}
disabled={fwImportSubmitting} disabled={fwImportSubmitting}
onClick={onApplyDateSuggestions} onClick={onApplyDateSuggestions}
> >
@ -354,10 +472,10 @@ export default function TrainingPlanningFrameworkImportModal({
</div> </div>
</> </>
) : fwImportProgramId ? ( ) : fwImportProgramId ? (
<p style={{ color: 'var(--text2)', marginTop: '0.75rem' }}>Keine Sessions in diesem Programm.</p> <p className="fw-import-muted">Keine Sessions in diesem Programm.</p>
) : null} ) : null}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', marginTop: '1.25rem' }}> <div className="fw-import-modal-panel__footer">
<button <button
type="button" type="button"
className="btn btn-primary" className="btn btn-primary"

View File

@ -1,4 +1,4 @@
import { formatSessionDurationRange } from './trainingDurationUtils' import { formatDurationDisplay, formatSessionDurationRange } from './trainingDurationUtils'
export function frameworkSessionDurationLabel(row) { export function frameworkSessionDurationLabel(row) {
return formatSessionDurationRange(row?.session_duration_min, row?.session_duration_max, { return formatSessionDurationRange(row?.session_duration_min, row?.session_duration_max, {
@ -21,28 +21,129 @@ function parseIdList(raw) {
return [] return []
} }
function rowMatchesDuration(row, targetMin, toleranceMin = 10) { /** Eindeutige Session-Dauern (Minuten) aus allen Rahmen in der Liste. */
if (targetMin == null || targetMin === '' || Number.isNaN(Number(targetMin))) return true export function collectDistinctSessionDurationsMinutes(rows) {
const t = Number(targetMin) const set = new Set()
for (const r of rows || []) {
const lo = r.session_duration_min
const hi = r.session_duration_max
if (lo != null && lo !== '' && Number.isFinite(Number(lo))) set.add(Number(lo))
if (hi != null && hi !== '' && Number.isFinite(Number(hi))) set.add(Number(hi))
}
return [...set].sort((a, b) => a - b)
}
function programDurationBounds(row) {
const lo = row.session_duration_min != null ? Number(row.session_duration_min) : null const lo = row.session_duration_min != null ? Number(row.session_duration_min) : null
const hi = row.session_duration_max != null ? Number(row.session_duration_max) : null const hi = row.session_duration_max != null ? Number(row.session_duration_max) : null
if (lo == null && hi == null) return true if (lo != null && Number.isFinite(lo) && lo > 0) {
if (lo != null && hi != null) { const hiEff = hi != null && Number.isFinite(hi) && hi > 0 ? hi : lo
return t >= lo - toleranceMin && t <= hi + toleranceMin return { lo, hi: hiEff }
} }
const single = lo ?? hi if (hi != null && Number.isFinite(hi) && hi > 0) {
return single != null && Math.abs(single - t) <= toleranceMin return { lo: hi, hi }
}
return null
}
/** Überlappung Programm-Session-Spanne mit Filter-Spanne (Minuten). */
function rowMatchesDurationRange(row, fromMin, toMin) {
const hasFrom = fromMin != null && fromMin !== '' && !Number.isNaN(Number(fromMin))
const hasTo = toMin != null && toMin !== '' && !Number.isNaN(Number(toMin))
if (!hasFrom && !hasTo) return true
const bounds = programDurationBounds(row)
if (!bounds) return false
const fLo = hasFrom ? Number(fromMin) : bounds.lo
const fHi = hasTo ? Number(toMin) : hasFrom ? Number(fromMin) : bounds.hi
const filterLo = Math.min(fLo, fHi)
const filterHi = Math.max(fLo, fHi)
return bounds.lo <= filterHi && bounds.hi >= filterLo
}
function rowMatchesDurationPreset(row, presetMin, toleranceMin = 10) {
if (presetMin == null || presetMin === '' || Number.isNaN(Number(presetMin))) return true
const t = Number(presetMin)
const bounds = programDurationBounds(row)
if (!bounds) return false
return t >= bounds.lo - toleranceMin && t <= bounds.hi + toleranceMin
}
export const EMPTY_FRAMEWORK_IMPORT_FILTERS = {
query: '',
focusAreaIds: [],
trainingTypeIds: [],
targetGroupIds: [],
durationMode: 'any',
durationRangeFrom: '',
durationRangeTo: '',
durationPresetMin: null,
}
export function hasActiveFrameworkImportFilters(filters = {}) {
const f = { ...EMPTY_FRAMEWORK_IMPORT_FILTERS, ...filters }
if ((f.query || '').trim()) return true
if ((f.focusAreaIds || []).length) return true
if ((f.trainingTypeIds || []).length) return true
if ((f.targetGroupIds || []).length) return true
if (f.durationMode === 'range') {
if (String(f.durationRangeFrom || '').trim() !== '') return true
if (String(f.durationRangeTo || '').trim() !== '') return true
}
if (f.durationMode === 'preset' && f.durationPresetMin != null) return true
return false
}
/**
* Kurzbeschreibung aktiver Filter (für Zusammenfassung außerhalb des Panels).
*/
export function summarizeFrameworkImportFilters(filters = {}, catalogs = {}) {
const f = { ...EMPTY_FRAMEWORK_IMPORT_FILTERS, ...filters }
const parts = []
const q = (f.query || '').trim()
if (q) parts.push(`Suche: „${q}"`)
const nameById = (list, id) => list?.find((x) => String(x.id) === String(id))?.name || id
if ((f.focusAreaIds || []).length) {
const names = f.focusAreaIds.map((id) => nameById(catalogs.focusAreas, id))
parts.push(`Fokus: ${names.join(', ')}`)
}
if ((f.trainingTypeIds || []).length) {
const names = f.trainingTypeIds.map((id) => nameById(catalogs.trainingTypes, id))
parts.push(`Trainingsart: ${names.join(', ')}`)
}
if ((f.targetGroupIds || []).length) {
const names = f.targetGroupIds.map((id) => nameById(catalogs.targetGroups, id))
parts.push(`Zielgruppe: ${names.join(', ')}`)
}
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 }) : '—'
parts.push(`Dauer: ${fromLbl} ${toLbl}`)
}
} else if (f.durationMode === 'preset' && f.durationPresetMin != null) {
parts.push(`Dauer: ${formatDurationDisplay(f.durationPresetMin)}`)
}
return parts
} }
/** /**
* Client-Filter für Rahmenprogramm-Auswahl (Liste / Import-Dialog). * Client-Filter für Rahmenprogramm-Auswahl (Liste / Import-Dialog).
*/ */
export function filterFrameworkPrograms(rows, filters = {}) { export function filterFrameworkPrograms(rows, filters = {}) {
const q = (filters.query || '').trim().toLowerCase() const f = { ...EMPTY_FRAMEWORK_IMPORT_FILTERS, ...filters }
const focusIds = new Set((filters.focusAreaIds || []).map(String)) const q = (f.query || '').trim().toLowerCase()
const typeIds = new Set((filters.trainingTypeIds || []).map(String)) const focusIds = new Set((f.focusAreaIds || []).map(String))
const tgIds = new Set((filters.targetGroupIds || []).map(String)) const typeIds = new Set((f.trainingTypeIds || []).map(String))
const durationTarget = filters.durationTargetMin const tgIds = new Set((f.targetGroupIds || []).map(String))
return (rows || []).filter((r) => { return (rows || []).filter((r) => {
if (q) { if (q) {
@ -74,7 +175,12 @@ export function filterFrameworkPrograms(rows, filters = {}) {
if (!tg.some((id) => tgIds.has(id))) return false if (!tg.some((id) => tgIds.has(id))) return false
} }
if (!rowMatchesDuration(r, durationTarget)) return false if (f.durationMode === 'range') {
if (!rowMatchesDurationRange(r, f.durationRangeFrom, f.durationRangeTo)) return false
} else if (f.durationMode === 'preset') {
if (!rowMatchesDurationPreset(r, f.durationPresetMin)) return false
}
return true return true
}) })
} }

View File

@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import {
collectDistinctSessionDurationsMinutes,
filterFrameworkPrograms,
hasActiveFrameworkImportFilters,
} from './frameworkProgramListHelpers.js'
describe('frameworkProgramListHelpers', () => {
const rows = [
{ id: 1, title: 'A', session_duration_min: 60, session_duration_max: 60, focus_area_ids: [1] },
{ id: 2, title: 'B', session_duration_min: 90, session_duration_max: 120, focus_area_ids: [2] },
{ id: 3, title: 'C', session_duration_min: null, session_duration_max: null },
]
it('collectDistinctSessionDurationsMinutes', () => {
expect(collectDistinctSessionDurationsMinutes(rows)).toEqual([60, 90, 120])
})
it('filterFrameworkPrograms duration preset', () => {
const out = filterFrameworkPrograms(rows, { durationMode: 'preset', durationPresetMin: 90 })
expect(out.map((r) => r.id)).toEqual([2])
})
it('filterFrameworkPrograms duration range overlap', () => {
const out = filterFrameworkPrograms(rows, {
durationMode: 'range',
durationRangeFrom: '75',
durationRangeTo: '100',
})
expect(out.map((r) => r.id)).toEqual([2])
})
it('hasActiveFrameworkImportFilters', () => {
expect(hasActiveFrameworkImportFilters({})).toBe(false)
expect(hasActiveFrameworkImportFilters({ query: 'x' })).toBe(true)
expect(hasActiveFrameworkImportFilters({ durationMode: 'preset', durationPresetMin: 60 })).toBe(true)
})
})