diff --git a/frontend/src/app.css b/frontend/src/app.css index 29877e8..783627b 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -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; diff --git a/frontend/src/components/planning/TrainingPlanningFrameworkImportModal.jsx b/frontend/src/components/planning/TrainingPlanningFrameworkImportModal.jsx index 248832b..8743478 100644 --- a/frontend/src/components/planning/TrainingPlanningFrameworkImportModal.jsx +++ b/frontend/src/components/planning/TrainingPlanningFrameworkImportModal.jsx @@ -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 (
-
-

Sessions aus Rahmen übernehmen

-

+

+

Sessions aus Rahmen übernehmen

+

Wähle ein Trainingsrahmenprogramm und eine oder mehrere Sessions. Pro Session entsteht eine{' '} - eigene geplante Einheit in der aktuellen Gruppe (Kopie des Ablaufs). Die{' '} - Verknüpfung zum Rahmen-Slot bleibt sichtbar. + eigene geplante Einheit in der aktuellen Gruppe (Kopie des Ablaufs).

-
- Rahmen filtern (optional) -
-
- - setFilterQuery(e.target.value)} - placeholder="z. B. Gürtel, Koordination …" +
+
+ {matchCount} + + {' '} + von {totalCount} Rahmenprogramm{totalCount === 1 ? '' : 'en'} + + {matchCount === 0 && totalCount > 0 ? ( + — kein Treffer + ) : null} +
+
+ {filterActive ? ( + + Filter aktiv + + ) : null} + {filterActive ? ( + + ) : null} + +
+
+ + {!filterPanelOpen && filterActive && filterSummaryParts.length > 0 ? ( +
    + {filterSummaryParts.map((part) => ( +
  • + {part} +
  • + ))} +
+ ) : null} + + {filterPanelOpen ? ( +
+
+
+ + updateFilter({ query: e.target.value })} + placeholder="z. B. Gürtel, Koordination …" + disabled={fwImportSubmitting} + /> +
+ +
+ Ziel-Session-Dauer +
+ {[ + { id: 'any', label: 'Alle' }, + { id: 'range', label: 'Zeitspanne' }, + { id: 'preset', label: 'Vorhandene Zeiten' }, + ].map((opt) => ( + + ))} +
+ + {filters.durationMode === 'range' ? ( +
+
+ + + updateFilter({ durationMode: 'range', durationRangeFrom: e.target.value }) + } + placeholder="z. B. 60" + disabled={fwImportSubmitting} + /> +
+
+ + + updateFilter({ durationMode: 'range', durationRangeTo: e.target.value }) + } + placeholder="z. B. 90" + disabled={fwImportSubmitting} + /> +
+
+ ) : null} + + {filters.durationMode === 'preset' ? ( + distinctDurations.length === 0 ? ( +

+ In der Bibliothek sind noch keine Session-Dauern hinterlegt. Nutze „Zeitspanne“ oder lege + Dauer pro Session im Rahmenprogramm fest. +

+ ) : ( +
+ {distinctDurations.map((min) => { + const on = filters.durationPresetMin === min + return ( + + ) + })} +
+ ) + ) : null} +
+ + {catalogFocusAreas.length > 0 ? ( +
+ Fokusbereich +
+ {catalogFocusAreas.map((fa) => ( + + ))} +
+
+ ) : null} + + {catalogTrainingTypes.length > 0 ? ( +
+ Trainingsart +
+ {catalogTrainingTypes.map((t) => ( + + ))} +
+
+ ) : null} + + {catalogTargetGroups.length > 0 ? ( +
+ Zielgruppe +
+ {catalogTargetGroups.map((tg) => ( + + ))} +
+
+ ) : null}
-
- - setFilterDurationMin(e.target.value)} - placeholder="z. B. 90" - disabled={fwImportSubmitting} - /> -

- Zeigt Programme, deren hinterlegte Session-Dauer in etwa passt (±10 Min). -

-
- {catalogFocusAreas.length > 0 ? ( -
- - Fokusbereich - -
- {catalogFocusAreas.map((fa) => ( - - ))} -
-
- ) : null} - {catalogTrainingTypes.length > 0 ? ( -
- - Trainingsart - -
- {catalogTrainingTypes.map((t) => ( - - ))} -
-
- ) : null} - {catalogTargetGroups.length > 0 ? ( -
- - Zielgruppe - -
- {catalogTargetGroups.map((tg) => ( - - ))} -
-
- ) : null} -

- {filteredPrograms.length} von {frameworkProgramsList.length} Rahmenprogramm(en) sichtbar. - Entwicklungsziele sind freie Texte — die Suche durchsucht auch Ziel-Titel. +

+ Entwicklungsziele sind freie Texte — die Suche durchsucht auch Ziel-Titel. Bei der Dauer werden nur + Programme mit hinterlegter Session-Dauer berücksichtigt.

-
+ ) : null} -
- +
+ onToggleSlot(slot)} - style={{ marginTop: '0.2rem', flexShrink: 0 }} /> - - {label} - {slotDur ? ( - - · {slotDur} - - ) : null} + + + {label} + {slotDur ? ( + {slotDur} + ) : ( + + Dauer offen + + )} + {!hasBp ? ( - + Ohne Session-Ablauf — Übernahme nicht möglich. ) : null} {hasBp && checked ? ( - - - Termin (Datum) - + + Termin (Datum) onSlotDateChange(String(slot.id), e.target.value)} disabled={fwImportSubmitting} @@ -310,15 +437,7 @@ export default function TrainingPlanningFrameworkImportModal({ -
+
-
+
) : fwImportProgramId ? ( -

Keine Sessions in diesem Programm.

+

Keine Sessions in diesem Programm.

) : null} -
+