2026-05-20 13:19:40 +02:00
|
|
|
import React, { useMemo, useState } from 'react'
|
|
|
|
|
import {
|
|
|
|
|
filterFrameworkPrograms,
|
|
|
|
|
frameworkProgramOptionLabel,
|
|
|
|
|
frameworkSessionDurationLabel,
|
|
|
|
|
} from '../../utils/frameworkProgramListHelpers'
|
|
|
|
|
import { formatDurationDisplay } from '../../utils/trainingDurationUtils'
|
2026-05-14 13:26:02 +02:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Modal: geplante Einheiten aus einem Trainingsrahmenprogramm (Blueprint-Slots) erzeugen.
|
|
|
|
|
*/
|
|
|
|
|
export default function TrainingPlanningFrameworkImportModal({
|
|
|
|
|
open,
|
|
|
|
|
frameworkProgramsList,
|
2026-05-20 13:19:40 +02:00
|
|
|
catalogFocusAreas = [],
|
|
|
|
|
catalogTrainingTypes = [],
|
|
|
|
|
catalogTargetGroups = [],
|
2026-05-14 13:26:02 +02:00
|
|
|
fwImportProgramId,
|
|
|
|
|
onProgramChange,
|
|
|
|
|
fwImportLoading,
|
|
|
|
|
fwImportDetail,
|
|
|
|
|
fwImportSelectedSlots,
|
|
|
|
|
onToggleSlot,
|
|
|
|
|
fwImportSlotDates,
|
|
|
|
|
onSlotDateChange,
|
|
|
|
|
fwImportStartDate,
|
|
|
|
|
onFwImportStartDateChange,
|
|
|
|
|
fwImportIntervalDays,
|
|
|
|
|
onFwImportIntervalDaysChange,
|
|
|
|
|
fwImportSubmitting,
|
|
|
|
|
onApplyDateSuggestions,
|
|
|
|
|
onSubmit,
|
|
|
|
|
onClose,
|
|
|
|
|
}) {
|
2026-05-20 13:19:40 +02:00
|
|
|
const [filterQuery, setFilterQuery] = useState('')
|
|
|
|
|
const [filterFocusIds, setFilterFocusIds] = useState([])
|
|
|
|
|
const [filterTypeIds, setFilterTypeIds] = useState([])
|
|
|
|
|
const [filterTargetGroupIds, setFilterTargetGroupIds] = useState([])
|
|
|
|
|
const [filterDurationMin, setFilterDurationMin] = useState('')
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const selectedProgramSummary = useMemo(() => {
|
|
|
|
|
if (!fwImportProgramId) return null
|
|
|
|
|
return (frameworkProgramsList || []).find((p) => String(p.id) === String(fwImportProgramId))
|
|
|
|
|
}, [frameworkProgramsList, fwImportProgramId])
|
|
|
|
|
|
|
|
|
|
const toggleId = (list, setList, id) => {
|
|
|
|
|
const s = String(id)
|
|
|
|
|
setList((prev) => (prev.includes(s) ? prev.filter((x) => x !== s) : [...prev, s]))
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 13:26:02 +02:00
|
|
|
if (!open) return null
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
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)',
|
2026-05-20 13:19:40 +02:00
|
|
|
maxWidth: 'min(680px, 100%)',
|
2026-05-14 13:26:02 +02:00
|
|
|
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{' '}
|
|
|
|
|
<strong>eigene geplante Einheit</strong> in der aktuellen Gruppe (Kopie des Ablaufs). Die{' '}
|
2026-05-20 13:19:40 +02:00
|
|
|
<strong>Verknüpfung zum Rahmen-Slot</strong> bleibt sichtbar.
|
2026-05-14 13:26:02 +02:00
|
|
|
</p>
|
|
|
|
|
|
2026-05-20 13:19:40 +02:00
|
|
|
<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 …"
|
|
|
|
|
disabled={fwImportSubmitting}
|
|
|
|
|
/>
|
|
|
|
|
</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>
|
|
|
|
|
</div>
|
|
|
|
|
</details>
|
|
|
|
|
|
2026-05-14 13:26:02 +02:00
|
|
|
<div className="form-row">
|
|
|
|
|
<label className="form-label">Rahmenprogramm</label>
|
|
|
|
|
<select
|
|
|
|
|
className="form-input"
|
|
|
|
|
value={fwImportProgramId}
|
|
|
|
|
onChange={(e) => onProgramChange(e.target.value)}
|
|
|
|
|
disabled={fwImportLoading || fwImportSubmitting}
|
|
|
|
|
>
|
|
|
|
|
<option value="">Bitte wählen…</option>
|
2026-05-20 13:19:40 +02:00
|
|
|
{filteredPrograms.map((fp) => (
|
2026-05-14 13:26:02 +02:00
|
|
|
<option key={fp.id} value={String(fp.id)}>
|
2026-05-20 13:19:40 +02:00
|
|
|
{frameworkProgramOptionLabel(fp)}
|
2026-05-14 13:26:02 +02:00
|
|
|
</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
2026-05-20 13:19:40 +02:00
|
|
|
{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}
|
2026-05-14 13:26:02 +02:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{fwImportLoading ? (
|
|
|
|
|
<p style={{ color: 'var(--text2)', marginTop: '1rem' }}>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 }}>
|
|
|
|
|
{[...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}`
|
2026-05-20 13:19:40 +02:00
|
|
|
const slotDur =
|
|
|
|
|
slot.planned_duration_min != null
|
|
|
|
|
? formatDurationDisplay(slot.planned_duration_min)
|
|
|
|
|
: null
|
2026-05-14 13:26:02 +02:00
|
|
|
return (
|
|
|
|
|
<li key={slot.id} style={{ marginBottom: '10px' }}>
|
|
|
|
|
<label
|
|
|
|
|
style={{
|
|
|
|
|
display: 'flex',
|
|
|
|
|
gap: '10px',
|
|
|
|
|
alignItems: 'flex-start',
|
|
|
|
|
cursor: hasBp ? 'pointer' : 'not-allowed',
|
|
|
|
|
opacity: hasBp ? 1 : 0.55,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
checked={checked}
|
|
|
|
|
disabled={!hasBp || fwImportSubmitting}
|
|
|
|
|
onChange={() => onToggleSlot(slot)}
|
|
|
|
|
style={{ marginTop: '0.2rem', flexShrink: 0 }}
|
|
|
|
|
/>
|
|
|
|
|
<span style={{ flex: 1, minWidth: 0 }}>
|
|
|
|
|
<strong>{label}</strong>
|
2026-05-20 13:19:40 +02:00
|
|
|
{slotDur ? (
|
|
|
|
|
<span
|
|
|
|
|
style={{
|
|
|
|
|
marginLeft: '8px',
|
|
|
|
|
fontSize: '0.82rem',
|
|
|
|
|
color: 'var(--text2)',
|
|
|
|
|
fontWeight: 500,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
· {slotDur}
|
|
|
|
|
</span>
|
|
|
|
|
) : null}
|
2026-05-14 13:26:02 +02:00
|
|
|
{!hasBp ? (
|
|
|
|
|
<span style={{ display: 'block', fontSize: '0.82rem', color: 'var(--danger)' }}>
|
|
|
|
|
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>
|
|
|
|
|
<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}
|
|
|
|
|
/>
|
|
|
|
|
</span>
|
|
|
|
|
) : null}
|
|
|
|
|
</span>
|
|
|
|
|
</label>
|
|
|
|
|
</li>
|
|
|
|
|
)
|
|
|
|
|
})}
|
|
|
|
|
</ul>
|
|
|
|
|
</fieldset>
|
|
|
|
|
|
|
|
|
|
<div
|
|
|
|
|
className="responsive-grid-3"
|
|
|
|
|
style={{
|
|
|
|
|
marginBottom: '0.75rem',
|
|
|
|
|
padding: '12px',
|
|
|
|
|
background: 'var(--surface2)',
|
|
|
|
|
borderRadius: '8px',
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<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" style={{ alignSelf: 'end' }}>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="btn btn-secondary"
|
|
|
|
|
style={{ width: '100%' }}
|
|
|
|
|
disabled={fwImportSubmitting}
|
|
|
|
|
onClick={onApplyDateSuggestions}
|
|
|
|
|
>
|
|
|
|
|
Datumsvorschläge setzen
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
) : fwImportProgramId ? (
|
|
|
|
|
<p style={{ color: 'var(--text2)', marginTop: '0.75rem' }}>Keine Sessions in diesem Programm.</p>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', marginTop: '1.25rem' }}>
|
|
|
|
|
<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>
|
|
|
|
|
)
|
|
|
|
|
}
|