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 */
|
/* Rahmenprogramm-Editor: Kurz-Einstieg ausklappbar */
|
||||||
.framework-edit-intro {
|
.framework-edit-intro {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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