shinkan-jinkendo/frontend/src/components/planning/TrainingPlanningFrameworkImportModal.jsx

495 lines
20 KiB
React
Raw Normal View History

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'
/**
* Modal: geplante Einheiten aus einem Trainingsrahmenprogramm (Blueprint-Slots) erzeugen.
*/
export default function TrainingPlanningFrameworkImportModal({
open,
frameworkProgramsList,
catalogFocusAreas = [],
catalogTrainingTypes = [],
catalogTargetGroups = [],
fwImportProgramId,
onProgramChange,
fwImportLoading,
fwImportDetail,
fwImportSelectedSlots,
onToggleSlot,
fwImportSlotDates,
onSlotDateChange,
fwImportStartDate,
onFwImportStartDateChange,
fwImportIntervalDays,
onFwImportIntervalDaysChange,
fwImportSubmitting,
onApplyDateSuggestions,
onSubmit,
onClose,
}) {
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, 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 updateFilter = (patch) => setFilters((prev) => ({ ...prev, ...patch }))
const clearFilters = () => setFilters({ ...EMPTY_FRAMEWORK_IMPORT_FILTERS })
const toggleId = (key, id) => {
const s = String(id)
setFilters((prev) => {
const cur = prev[key] || []
const next = cur.includes(s) ? cur.filter((x) => x !== s) : [...cur, s]
return { ...prev, [key]: next }
})
}
if (!open) return null
return (
<div
className="fw-import-modal-backdrop"
data-testid="planning-framework-import-modal"
>
<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).
</p>
<div className="fw-import-results-bar">
<div className="fw-import-results-bar__count">
<strong className="fw-import-results-bar__num">{matchCount}</strong>
<span>
{' '}
von {totalCount} Rahmenprogramm{totalCount === 1 ? '' : 'en'}
</span>
{matchCount === 0 && totalCount > 0 ? (
<span className="fw-import-results-bar__warn"> — kein Treffer</span>
) : null}
</div>
<div className="fw-import-results-bar__actions">
{filterActive ? (
<span className="fw-import-filter-badge" title={filterSummaryParts.join(' · ')}>
Filter aktiv
</span>
) : null}
{filterActive ? (
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
disabled={fwImportSubmitting}
onClick={clearFilters}
>
Filter zurücksetzen
</button>
) : null}
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
disabled={fwImportSubmitting}
onClick={() => setFilterPanelOpen((v) => !v)}
aria-expanded={filterPanelOpen}
>
{filterPanelOpen ? 'Filter einklappen' : 'Filter anzeigen'}
</button>
</div>
</div>
{!filterPanelOpen && filterActive && filterSummaryParts.length > 0 ? (
<ul className="fw-import-filter-chips" aria-label="Aktive Filter">
{filterSummaryParts.map((part) => (
<li key={part} className="fw-import-filter-chip">
{part}
</li>
))}
</ul>
) : null}
{filterPanelOpen ? (
<div className="fw-import-filter-panel">
<div className="fw-import-filter-panel__grid">
<div className="form-row fw-import-filter-panel__search">
<label className="form-label">Suche (Titel, Ziele, Katalog)</label>
<input
className="form-input"
value={filters.query}
onChange={(e) => updateFilter({ query: e.target.value })}
placeholder="z. B. Gürtel, Koordination …"
disabled={fwImportSubmitting}
/>
</div>
<fieldset className="fw-import-duration-fieldset">
<legend className="form-label">Ziel-Session-Dauer</legend>
<div className="fw-import-duration-mode" role="radiogroup" aria-label="Dauer-Filtermodus">
{[
{ id: 'any', label: 'Alle' },
{ id: 'range', label: 'Zeitspanne' },
{ id: 'preset', label: 'Vorhandene Zeiten' },
].map((opt) => (
<label key={opt.id} className="fw-import-duration-mode__opt">
<input
type="radio"
name="fw-duration-mode"
checked={filters.durationMode === opt.id}
disabled={fwImportSubmitting || (opt.id === 'preset' && distinctDurations.length === 0)}
onChange={() =>
updateFilter({
durationMode: opt.id,
...(opt.id === 'any'
? { durationRangeFrom: '', durationRangeTo: '', durationPresetMin: null }
: {}),
})
}
/>
<span>{opt.label}</span>
</label>
))}
</div>
{filters.durationMode === 'range' ? (
<div className="responsive-grid-2 fw-import-duration-range">
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label">Von (Minuten)</label>
<input
type="number"
min={0}
className="form-input"
value={filters.durationRangeFrom}
onChange={(e) =>
updateFilter({ durationMode: 'range', durationRangeFrom: e.target.value })
}
placeholder="z. B. 60"
disabled={fwImportSubmitting}
/>
</div>
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label">Bis (Minuten)</label>
<input
type="number"
min={0}
className="form-input"
value={filters.durationRangeTo}
onChange={(e) =>
updateFilter({ durationMode: 'range', durationRangeTo: e.target.value })
}
placeholder="z. B. 90"
disabled={fwImportSubmitting}
/>
</div>
</div>
) : null}
{filters.durationMode === 'preset' ? (
distinctDurations.length === 0 ? (
<p className="form-sub" style={{ margin: '8px 0 0' }}>
In der Bibliothek sind noch keine Session-Dauern hinterlegt. Nutze „Zeitspanne“ oder lege
Dauer pro Session im Rahmenprogramm fest.
</p>
) : (
<div className="fw-import-duration-presets">
{distinctDurations.map((min) => {
const on = filters.durationPresetMin === min
return (
<button
key={min}
type="button"
className={
'btn framework-ctrl framework-ctrl--xs' +
(on ? ' fw-import-duration-preset--on' : ' btn-secondary')
}
disabled={fwImportSubmitting}
onClick={() =>
updateFilter({
durationMode: 'preset',
durationPresetMin: on ? null : min,
durationRangeFrom: '',
durationRangeTo: '',
})
}
>
{formatDurationDisplay(min)}
</button>
)
})}
</div>
)
) : null}
</fieldset>
{catalogFocusAreas.length > 0 ? (
<div className="fw-import-catalog-block">
<span className="form-label">Fokusbereich</span>
<div className="framework-catalog-checkgrid">
{catalogFocusAreas.map((fa) => (
<label key={fa.id} className="framework-catalog-check">
<input
type="checkbox"
checked={(filters.focusAreaIds || []).includes(String(fa.id))}
onChange={() => toggleId('focusAreaIds', fa.id)}
disabled={fwImportSubmitting}
/>
<span>{fa.name}</span>
</label>
))}
</div>
</div>
) : null}
{catalogTrainingTypes.length > 0 ? (
<div className="fw-import-catalog-block">
<span className="form-label">Trainingsart</span>
<div className="framework-catalog-checkgrid">
{catalogTrainingTypes.map((t) => (
<label key={t.id} className="framework-catalog-check">
<input
type="checkbox"
checked={(filters.trainingTypeIds || []).includes(String(t.id))}
onChange={() => toggleId('trainingTypeIds', t.id)}
disabled={fwImportSubmitting}
/>
<span>{t.name}</span>
</label>
))}
</div>
</div>
) : null}
{catalogTargetGroups.length > 0 ? (
<div className="fw-import-catalog-block">
<span className="form-label">Zielgruppe</span>
<div className="framework-catalog-checkgrid">
{catalogTargetGroups.map((tg) => (
<label key={tg.id} className="framework-catalog-check">
<input
type="checkbox"
checked={(filters.targetGroupIds || []).includes(String(tg.id))}
onChange={() => toggleId('targetGroupIds', tg.id)}
disabled={fwImportSubmitting}
/>
<span>{tg.name}</span>
</label>
))}
</div>
</div>
) : null}
</div>
<p className="form-sub fw-import-filter-panel__hint">
Entwicklungsziele sind freie Texte — die Suche durchsucht auch Ziel-Titel. Bei der Dauer werden nur
Programme mit hinterlegter Session-Dauer berücksichtigt.
</p>
</div>
) : null}
<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 || matchCount === 0}
>
<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)}
</option>
))}
</select>
{selectedProgramSummary ? (
<p className="form-sub" style={{ marginTop: '6px' }}>
Session-Dauer: <strong>{frameworkSessionDurationLabel(selectedProgramSummary)}</strong>
{selectedProgramSummary.goal_titles_agg ? (
<>
{' '}
· Ziele: {selectedProgramSummary.goal_titles_agg}
</>
) : null}
</p>
) : null}
</div>
{fwImportLoading ? (
<p className="fw-import-muted">Laden der Sessions…</p>
) : fwImportDetail?.slots?.length ? (
<>
<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) => {
const hasBp = !!slot.blueprint_training_unit_id
const checked = fwImportSelectedSlots.has(slot.id)
const label =
(slot.title || '').trim() || `Session ${(slot.sort_order ?? 0) + 1}`
const slotDur =
slot.planned_duration_min != null
? formatDurationDisplay(slot.planned_duration_min)
: null
return (
<li key={slot.id} className="fw-import-sessions__item">
<label
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)}
/>
<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 className="fw-import-sessions__warn">
Ohne Session-Ablauf — Übernahme nicht möglich.
</span>
) : null}
{hasBp && checked ? (
<span className="fw-import-sessions__date">
<span className="form-label">Termin (Datum)</span>
<input
type="date"
className="form-input"
value={fwImportSlotDates[String(slot.id)] || ''}
onChange={(e) => onSlotDateChange(String(slot.id), e.target.value)}
disabled={fwImportSubmitting}
/>
</span>
) : null}
</span>
</label>
</li>
)
})}
</ul>
</fieldset>
<div className="fw-import-dates-panel responsive-grid-3">
<div className="form-row">
<label className="form-label">Startdatum (Vorschlag)</label>
<input
type="date"
className="form-input"
value={fwImportStartDate}
onChange={(e) => onFwImportStartDateChange(e.target.value)}
disabled={fwImportSubmitting}
/>
</div>
<div className="form-row">
<label className="form-label">Abstand (Tage)</label>
<input
type="number"
min={0}
className="form-input"
value={fwImportIntervalDays}
onChange={(e) => onFwImportIntervalDaysChange(parseInt(e.target.value, 10) || 0)}
disabled={fwImportSubmitting}
/>
</div>
<div className="form-row fw-import-dates-panel__action">
<button
type="button"
className="btn btn-secondary"
disabled={fwImportSubmitting}
onClick={onApplyDateSuggestions}
>
Datumsvorschläge setzen
</button>
</div>
</div>
</>
) : fwImportProgramId ? (
<p className="fw-import-muted">Keine Sessions in diesem Programm.</p>
) : null}
<div className="fw-import-modal-panel__footer">
<button
type="button"
className="btn btn-primary"
disabled={fwImportSubmitting || !fwImportDetail}
onClick={onSubmit}
>
{fwImportSubmitting ? 'Übernehmen…' : 'In Planung übernehmen'}
</button>
<button type="button" className="btn btn-secondary" disabled={fwImportSubmitting} onClick={onClose}>
Abbrechen
</button>
</div>
</div>
</div>
)
}