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
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:
parent
9353909fda
commit
9c3494a7ea
|
|
@ -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 */
|
||||
.framework-edit-intro {
|
||||
margin-bottom: 1rem;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
import React, { useMemo, useState } from 'react'
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
collectDistinctSessionDurationsMinutes,
|
||||
EMPTY_FRAMEWORK_IMPORT_FILTERS,
|
||||
filterFrameworkPrograms,
|
||||
frameworkProgramOptionLabel,
|
||||
frameworkSessionDurationLabel,
|
||||
hasActiveFrameworkImportFilters,
|
||||
summarizeFrameworkImportFilters,
|
||||
} from '../../utils/frameworkProgramListHelpers'
|
||||
import { formatDurationDisplay } from '../../utils/trainingDurationUtils'
|
||||
|
||||
|
|
@ -32,185 +36,320 @@ export default function TrainingPlanningFrameworkImportModal({
|
|||
onSubmit,
|
||||
onClose,
|
||||
}) {
|
||||
const [filterQuery, setFilterQuery] = useState('')
|
||||
const [filterFocusIds, setFilterFocusIds] = useState([])
|
||||
const [filterTypeIds, setFilterTypeIds] = useState([])
|
||||
const [filterTargetGroupIds, setFilterTargetGroupIds] = useState([])
|
||||
const [filterDurationMin, setFilterDurationMin] = useState('')
|
||||
const [filters, setFilters] = useState(() => ({ ...EMPTY_FRAMEWORK_IMPORT_FILTERS }))
|
||||
const [filterPanelOpen, setFilterPanelOpen] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setFilters({ ...EMPTY_FRAMEWORK_IMPORT_FILTERS })
|
||||
setFilterPanelOpen(true)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const distinctDurations = useMemo(
|
||||
() => collectDistinctSessionDurationsMinutes(frameworkProgramsList),
|
||||
[frameworkProgramsList]
|
||||
)
|
||||
|
||||
const filteredPrograms = useMemo(
|
||||
() =>
|
||||
filterFrameworkPrograms(frameworkProgramsList, {
|
||||
query: filterQuery,
|
||||
focusAreaIds: filterFocusIds,
|
||||
trainingTypeIds: filterTypeIds,
|
||||
targetGroupIds: filterTargetGroupIds,
|
||||
durationTargetMin: filterDurationMin === '' ? null : parseInt(filterDurationMin, 10),
|
||||
}),
|
||||
[
|
||||
frameworkProgramsList,
|
||||
filterQuery,
|
||||
filterFocusIds,
|
||||
filterTypeIds,
|
||||
filterTargetGroupIds,
|
||||
filterDurationMin,
|
||||
]
|
||||
() => 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(() => {
|
||||
if (!fwImportProgramId) return
|
||||
const stillVisible = filteredPrograms.some((p) => String(p.id) === String(fwImportProgramId))
|
||||
if (!stillVisible) onProgramChange('')
|
||||
}, [filteredPrograms, fwImportProgramId, onProgramChange])
|
||||
|
||||
const selectedProgramSummary = useMemo(() => {
|
||||
if (!fwImportProgramId) return null
|
||||
return (frameworkProgramsList || []).find((p) => String(p.id) === String(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)
|
||||
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
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fw-import-modal-backdrop"
|
||||
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
|
||||
style={{
|
||||
background: 'var(--surface)',
|
||||
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 }}>
|
||||
<div className="fw-import-modal-panel">
|
||||
<h2 className="fw-import-modal-panel__title">Sessions aus Rahmen übernehmen</h2>
|
||||
<p className="fw-import-modal-panel__lead">
|
||||
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>Verknüpfung zum Rahmen-Slot</strong> bleibt sichtbar.
|
||||
<strong>eigene geplante Einheit</strong> in der aktuellen Gruppe (Kopie des Ablaufs).
|
||||
</p>
|
||||
|
||||
<details className="planning-filter-help" style={{ marginBottom: '1rem' }}>
|
||||
<summary className="planning-filter-help__summary">Rahmen filtern (optional)</summary>
|
||||
<div className="planning-filter-help__body" style={{ display: 'grid', gap: '10px' }}>
|
||||
<div className="form-row" style={{ marginBottom: 0 }}>
|
||||
<label className="form-label">Suche (Titel, Ziele, Katalog)</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={filterQuery}
|
||||
onChange={(e) => setFilterQuery(e.target.value)}
|
||||
placeholder="z. B. Gürtel, Koordination …"
|
||||
<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>
|
||||
<div className="form-row" style={{ marginBottom: 0 }}>
|
||||
<label className="form-label">Ziel-Session-Dauer (Minuten)</label>
|
||||
<input
|
||||
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 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>
|
||||
</details>
|
||||
) : null}
|
||||
|
||||
<div className="form-row">
|
||||
<label className="form-label">Rahmenprogramm</label>
|
||||
<div className="form-row fw-import-program-select">
|
||||
<label className="form-label">
|
||||
Rahmenprogramm
|
||||
<span className="fw-import-program-select__count">
|
||||
{' '}
|
||||
({matchCount} {matchCount === 1 ? 'Treffer' : 'Treffer'})
|
||||
</span>
|
||||
</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={fwImportProgramId}
|
||||
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) => (
|
||||
<option key={fp.id} value={String(fp.id)}>
|
||||
{frameworkProgramOptionLabel(fp)}
|
||||
|
|
@ -231,14 +370,12 @@ export default function TrainingPlanningFrameworkImportModal({
|
|||
</div>
|
||||
|
||||
{fwImportLoading ? (
|
||||
<p style={{ color: 'var(--text2)', marginTop: '1rem' }}>Laden der Sessions…</p>
|
||||
<p className="fw-import-muted">Laden der Sessions…</p>
|
||||
) : fwImportDetail?.slots?.length ? (
|
||||
<>
|
||||
<fieldset style={{ border: 'none', margin: '1rem 0', padding: 0 }}>
|
||||
<legend className="form-label" style={{ padding: 0, marginBottom: '0.5rem' }}>
|
||||
Sessions (mit Ablauf)
|
||||
</legend>
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
|
||||
<fieldset className="fw-import-sessions">
|
||||
<legend className="form-label">Sessions (mit Ablauf)</legend>
|
||||
<ul className="fw-import-sessions__list">
|
||||
{[...fwImportDetail.slots]
|
||||
.sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0))
|
||||
.map((slot) => {
|
||||
|
|
@ -251,51 +388,41 @@ export default function TrainingPlanningFrameworkImportModal({
|
|||
? formatDurationDisplay(slot.planned_duration_min)
|
||||
: null
|
||||
return (
|
||||
<li key={slot.id} style={{ marginBottom: '10px' }}>
|
||||
<li key={slot.id} className="fw-import-sessions__item">
|
||||
<label
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '10px',
|
||||
alignItems: 'flex-start',
|
||||
cursor: hasBp ? 'pointer' : 'not-allowed',
|
||||
opacity: hasBp ? 1 : 0.55,
|
||||
}}
|
||||
className={
|
||||
'fw-import-sessions__label' + (hasBp ? '' : ' fw-import-sessions__label--disabled')
|
||||
}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="fw-import-sessions__check"
|
||||
checked={checked}
|
||||
disabled={!hasBp || fwImportSubmitting}
|
||||
onChange={() => onToggleSlot(slot)}
|
||||
style={{ marginTop: '0.2rem', flexShrink: 0 }}
|
||||
/>
|
||||
<span style={{ flex: 1, minWidth: 0 }}>
|
||||
<strong>{label}</strong>
|
||||
{slotDur ? (
|
||||
<span
|
||||
style={{
|
||||
marginLeft: '8px',
|
||||
fontSize: '0.82rem',
|
||||
color: 'var(--text2)',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
· {slotDur}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="fw-import-sessions__body">
|
||||
<span className="fw-import-sessions__title-row">
|
||||
<strong>{label}</strong>
|
||||
{slotDur ? (
|
||||
<span className="fw-import-sessions__dur">{slotDur}</span>
|
||||
) : (
|
||||
<span className="fw-import-sessions__dur fw-import-sessions__dur--muted">
|
||||
Dauer offen
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{!hasBp ? (
|
||||
<span style={{ display: 'block', fontSize: '0.82rem', color: 'var(--danger)' }}>
|
||||
<span className="fw-import-sessions__warn">
|
||||
Ohne Session-Ablauf — Übernahme nicht möglich.
|
||||
</span>
|
||||
) : null}
|
||||
{hasBp && checked ? (
|
||||
<span style={{ display: 'block', marginTop: '6px' }}>
|
||||
<span className="form-label" style={{ fontSize: '0.78rem' }}>
|
||||
Termin (Datum)
|
||||
</span>
|
||||
<span className="fw-import-sessions__date">
|
||||
<span className="form-label">Termin (Datum)</span>
|
||||
<input
|
||||
type="date"
|
||||
className="form-input"
|
||||
style={{ maxWidth: '200px', marginTop: '4px' }}
|
||||
value={fwImportSlotDates[String(slot.id)] || ''}
|
||||
onChange={(e) => onSlotDateChange(String(slot.id), e.target.value)}
|
||||
disabled={fwImportSubmitting}
|
||||
|
|
@ -310,15 +437,7 @@ export default function TrainingPlanningFrameworkImportModal({
|
|||
</ul>
|
||||
</fieldset>
|
||||
|
||||
<div
|
||||
className="responsive-grid-3"
|
||||
style={{
|
||||
marginBottom: '0.75rem',
|
||||
padding: '12px',
|
||||
background: 'var(--surface2)',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
>
|
||||
<div className="fw-import-dates-panel responsive-grid-3">
|
||||
<div className="form-row">
|
||||
<label className="form-label">Startdatum (Vorschlag)</label>
|
||||
<input
|
||||
|
|
@ -340,11 +459,10 @@ export default function TrainingPlanningFrameworkImportModal({
|
|||
disabled={fwImportSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-row" style={{ alignSelf: 'end' }}>
|
||||
<div className="form-row fw-import-dates-panel__action">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ width: '100%' }}
|
||||
disabled={fwImportSubmitting}
|
||||
onClick={onApplyDateSuggestions}
|
||||
>
|
||||
|
|
@ -354,10 +472,10 @@ export default function TrainingPlanningFrameworkImportModal({
|
|||
</div>
|
||||
</>
|
||||
) : 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}
|
||||
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', marginTop: '1.25rem' }}>
|
||||
<div className="fw-import-modal-panel__footer">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { formatSessionDurationRange } from './trainingDurationUtils'
|
||||
import { formatDurationDisplay, formatSessionDurationRange } from './trainingDurationUtils'
|
||||
|
||||
export function frameworkSessionDurationLabel(row) {
|
||||
return formatSessionDurationRange(row?.session_duration_min, row?.session_duration_max, {
|
||||
|
|
@ -21,28 +21,129 @@ function parseIdList(raw) {
|
|||
return []
|
||||
}
|
||||
|
||||
function rowMatchesDuration(row, targetMin, toleranceMin = 10) {
|
||||
if (targetMin == null || targetMin === '' || Number.isNaN(Number(targetMin))) return true
|
||||
const t = Number(targetMin)
|
||||
/** Eindeutige Session-Dauern (Minuten) aus allen Rahmen in der Liste. */
|
||||
export function collectDistinctSessionDurationsMinutes(rows) {
|
||||
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 hi = row.session_duration_max != null ? Number(row.session_duration_max) : null
|
||||
if (lo == null && hi == null) return true
|
||||
if (lo != null && hi != null) {
|
||||
return t >= lo - toleranceMin && t <= hi + toleranceMin
|
||||
if (lo != null && Number.isFinite(lo) && lo > 0) {
|
||||
const hiEff = hi != null && Number.isFinite(hi) && hi > 0 ? hi : lo
|
||||
return { lo, hi: hiEff }
|
||||
}
|
||||
const single = lo ?? hi
|
||||
return single != null && Math.abs(single - t) <= toleranceMin
|
||||
if (hi != null && Number.isFinite(hi) && hi > 0) {
|
||||
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).
|
||||
*/
|
||||
export function filterFrameworkPrograms(rows, filters = {}) {
|
||||
const q = (filters.query || '').trim().toLowerCase()
|
||||
const focusIds = new Set((filters.focusAreaIds || []).map(String))
|
||||
const typeIds = new Set((filters.trainingTypeIds || []).map(String))
|
||||
const tgIds = new Set((filters.targetGroupIds || []).map(String))
|
||||
const durationTarget = filters.durationTargetMin
|
||||
const f = { ...EMPTY_FRAMEWORK_IMPORT_FILTERS, ...filters }
|
||||
const q = (f.query || '').trim().toLowerCase()
|
||||
const focusIds = new Set((f.focusAreaIds || []).map(String))
|
||||
const typeIds = new Set((f.trainingTypeIds || []).map(String))
|
||||
const tgIds = new Set((f.targetGroupIds || []).map(String))
|
||||
|
||||
return (rows || []).filter((r) => {
|
||||
if (q) {
|
||||
|
|
@ -74,7 +175,12 @@ export function filterFrameworkPrograms(rows, filters = {}) {
|
|||
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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
38
frontend/src/utils/frameworkProgramListHelpers.test.js
Normal file
38
frontend/src/utils/frameworkProgramListHelpers.test.js
Normal 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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user