Trainingsplanung und Rahmenplanung #9
|
|
@ -295,7 +295,19 @@ def list_training_framework_programs(session=Depends(require_auth)):
|
||||||
(SELECT COUNT(*)::int FROM training_framework_program_training_types t
|
(SELECT COUNT(*)::int FROM training_framework_program_training_types t
|
||||||
WHERE t.framework_program_id = fp.id) AS training_types_count,
|
WHERE t.framework_program_id = fp.id) AS training_types_count,
|
||||||
(SELECT COUNT(*)::int FROM training_framework_program_target_groups tg
|
(SELECT COUNT(*)::int FROM training_framework_program_target_groups tg
|
||||||
WHERE tg.framework_program_id = fp.id) AS target_groups_count
|
WHERE tg.framework_program_id = fp.id) AS target_groups_count,
|
||||||
|
(
|
||||||
|
SELECT STRING_AGG(typ.name::text, ', ' ORDER BY typ.sort_order NULLS LAST, typ.name)
|
||||||
|
FROM training_framework_program_training_types j
|
||||||
|
JOIN training_types typ ON typ.id = j.training_type_id
|
||||||
|
WHERE j.framework_program_id = fp.id
|
||||||
|
) AS training_type_names_agg,
|
||||||
|
(
|
||||||
|
SELECT STRING_AGG(tg.name::text, ', ' ORDER BY tg.name)
|
||||||
|
FROM training_framework_program_target_groups j
|
||||||
|
JOIN target_groups tg ON tg.id = j.target_group_id
|
||||||
|
WHERE j.framework_program_id = fp.id
|
||||||
|
) AS target_group_names_agg
|
||||||
FROM training_framework_programs fp
|
FROM training_framework_programs fp
|
||||||
LEFT JOIN focus_areas fa ON fa.id = fp.focus_area_id
|
LEFT JOIN focus_areas fa ON fa.id = fp.focus_area_id
|
||||||
LEFT JOIN style_directions sd ON sd.id = fp.style_direction_id
|
LEFT JOIN style_directions sd ON sd.id = fp.style_direction_id
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,19 @@ def _assert_delete_training_unit(role: str, created_by: int, profile_id: int) ->
|
||||||
raise HTTPException(status_code=403, detail="Keine Berechtigung")
|
raise HTTPException(status_code=403, detail="Keine Berechtigung")
|
||||||
|
|
||||||
|
|
||||||
|
# Nachverfolgbarkeit: Übernahmen aus Rahmenprogramm über origin_framework_slot_id
|
||||||
|
_ORIGIN_LINEAGE_JOIN = """
|
||||||
|
LEFT JOIN training_framework_slots origin_slot ON origin_slot.id = tu.origin_framework_slot_id
|
||||||
|
LEFT JOIN training_framework_programs origin_fp ON origin_fp.id = origin_slot.framework_program_id
|
||||||
|
"""
|
||||||
|
_ORIGIN_LINEAGE_FIELDS = """
|
||||||
|
origin_fp.id AS origin_framework_program_id,
|
||||||
|
origin_fp.title AS origin_framework_program_title,
|
||||||
|
COALESCE(TRIM(origin_slot.title), '') AS origin_framework_slot_title,
|
||||||
|
origin_slot.sort_order AS origin_framework_slot_sort_order
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
def _fetch_sections(cur, unit_id: int) -> List[Dict[str, Any]]:
|
def _fetch_sections(cur, unit_id: int) -> List[Dict[str, Any]]:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
|
|
@ -636,12 +649,15 @@ def list_training_units(
|
||||||
tg.name as group_name,
|
tg.name as group_name,
|
||||||
tg.weekday as group_weekday,
|
tg.weekday as group_weekday,
|
||||||
c.name as club_name,
|
c.name as club_name,
|
||||||
p.name as trainer_name
|
p.name as trainer_name"""
|
||||||
|
query += "," + _ORIGIN_LINEAGE_FIELDS
|
||||||
|
query += """
|
||||||
FROM training_units tu
|
FROM training_units tu
|
||||||
LEFT JOIN training_groups tg ON tu.group_id = tg.id
|
LEFT JOIN training_groups tg ON tu.group_id = tg.id
|
||||||
LEFT JOIN clubs c ON tg.club_id = c.id
|
LEFT JOIN clubs c ON tg.club_id = c.id
|
||||||
LEFT JOIN profiles p ON tu.created_by = p.id
|
LEFT JOIN profiles p ON tu.created_by = p.id
|
||||||
"""
|
"""
|
||||||
|
query += _ORIGIN_LINEAGE_JOIN
|
||||||
|
|
||||||
where = []
|
where = []
|
||||||
params = []
|
params = []
|
||||||
|
|
@ -695,11 +711,13 @@ def get_training_unit(unit_id: int, session=Depends(require_auth)):
|
||||||
tg.time_end as group_time_end,
|
tg.time_end as group_time_end,
|
||||||
tg.location as group_location,
|
tg.location as group_location,
|
||||||
c.name as club_name,
|
c.name as club_name,
|
||||||
p.name as trainer_name
|
p.name as trainer_name,
|
||||||
|
""" + _ORIGIN_LINEAGE_FIELDS.strip() + """
|
||||||
FROM training_units tu
|
FROM training_units tu
|
||||||
LEFT JOIN training_groups tg ON tu.group_id = tg.id
|
LEFT JOIN training_groups tg ON tu.group_id = tg.id
|
||||||
LEFT JOIN clubs c ON tg.club_id = c.id
|
LEFT JOIN clubs c ON tg.club_id = c.id
|
||||||
LEFT JOIN profiles p ON tu.created_by = p.id
|
LEFT JOIN profiles p ON tu.created_by = p.id
|
||||||
|
""" + _ORIGIN_LINEAGE_JOIN.strip() + """
|
||||||
WHERE tu.id = %s
|
WHERE tu.id = %s
|
||||||
""",
|
""",
|
||||||
(unit_id,),
|
(unit_id,),
|
||||||
|
|
|
||||||
|
|
@ -2,21 +2,58 @@ import React, { useCallback, useEffect, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
|
|
||||||
const TYPE_COUNT = (r) =>
|
function dashIfEmpty(val) {
|
||||||
typeof r.training_types_count === 'number' ? r.training_types_count : null
|
const s = (val ?? '').toString().trim()
|
||||||
const TG_COUNT = (r) =>
|
return s.length ? s : '—'
|
||||||
typeof r.target_groups_count === 'number' ? r.target_groups_count : null
|
|
||||||
|
|
||||||
function contextTeaser(r) {
|
|
||||||
const bits = []
|
|
||||||
if (r.focus_area_name) bits.push(r.focus_area_name)
|
|
||||||
if (r.style_direction_name) bits.push(r.style_direction_name)
|
|
||||||
const tn = TYPE_COUNT(r)
|
|
||||||
const gn = TG_COUNT(r)
|
|
||||||
if (tn != null && tn > 0) bits.push(`${tn} Trainingsart${tn === 1 ? '' : 'en'}`)
|
|
||||||
if (gn != null && gn > 0) bits.push(`${gn} Zielgruppe${gn === 1 ? '' : 'n'}`)
|
|
||||||
return bits.length ? bits.join(' · ') : null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function FrameworkSummaryMeta({ r }) {
|
||||||
|
const trainingTypes =
|
||||||
|
typeof r.training_type_names_agg === 'string' ? r.training_type_names_agg.trim() : ''
|
||||||
|
const targetGroups =
|
||||||
|
typeof r.target_group_names_agg === 'string' ? r.target_group_names_agg.trim() : ''
|
||||||
|
const styleDir = typeof r.style_direction_name === 'string' ? r.style_direction_name.trim() : ''
|
||||||
|
const focus = typeof r.focus_area_name === 'string' ? r.focus_area_name.trim() : ''
|
||||||
|
|
||||||
|
const rowStyle = {
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'minmax(6.5rem, 32%) 1fr',
|
||||||
|
gap: '0.25rem 0.75rem',
|
||||||
|
alignItems: 'start',
|
||||||
|
marginTop: '0.35rem',
|
||||||
|
lineHeight: 1.45,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<dl style={{ margin: '0.5rem 0 0', padding: 0, fontSize: '0.875rem', color: 'var(--text2)' }}>
|
||||||
|
<div style={rowStyle}>
|
||||||
|
<dt style={{ margin: 0, fontWeight: 600, color: 'var(--text3)' }}>Fokusbereich</dt>
|
||||||
|
<dd style={{ margin: 0 }}>{dashIfEmpty(focus)}</dd>
|
||||||
|
</div>
|
||||||
|
{styleDir ? (
|
||||||
|
<div style={rowStyle}>
|
||||||
|
<dt style={{ margin: 0, fontWeight: 600, color: 'var(--text3)' }}>Stilrichtung</dt>
|
||||||
|
<dd style={{ margin: 0 }}>{styleDir}</dd>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div style={rowStyle}>
|
||||||
|
<dt style={{ margin: 0, fontWeight: 600, color: 'var(--text3)' }}>Trainingsarten</dt>
|
||||||
|
<dd style={{ margin: 0 }}>{trainingTypes.length ? trainingTypes : '—'}</dd>
|
||||||
|
</div>
|
||||||
|
<div style={rowStyle}>
|
||||||
|
<dt style={{ margin: 0, fontWeight: 600, color: 'var(--text3)' }}>Zielgruppen</dt>
|
||||||
|
<dd style={{ margin: 0 }}>{targetGroups.length ? targetGroups : '—'}</dd>
|
||||||
|
</div>
|
||||||
|
<div style={{ ...rowStyle, marginTop: '0.5rem' }}>
|
||||||
|
<dt style={{ margin: 0, fontWeight: 600, color: 'var(--text3)' }}>Kurzbeschreibung</dt>
|
||||||
|
<dd style={{ margin: 0, whiteSpace: 'pre-wrap' }}>
|
||||||
|
{(r.description && String(r.description).trim()) || '—'}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function TrainingFrameworkProgramsListPage() {
|
export default function TrainingFrameworkProgramsListPage() {
|
||||||
const [rows, setRows] = useState([])
|
const [rows, setRows] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
@ -123,7 +160,7 @@ export default function TrainingFrameworkProgramsListPage() {
|
||||||
gap: '0.75rem',
|
gap: '0.75rem',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div>
|
<div style={{ minWidth: 0, flex: '1 1 220px' }}>
|
||||||
<Link
|
<Link
|
||||||
to={`/planning/framework-programs/${r.id}`}
|
to={`/planning/framework-programs/${r.id}`}
|
||||||
style={{ fontWeight: 600, fontSize: '1.05rem', color: 'var(--text1)' }}
|
style={{ fontWeight: 600, fontSize: '1.05rem', color: 'var(--text1)' }}
|
||||||
|
|
@ -132,17 +169,11 @@ export default function TrainingFrameworkProgramsListPage() {
|
||||||
</Link>
|
</Link>
|
||||||
<div style={{ fontSize: '0.85rem', color: 'var(--text2)', marginTop: '0.35rem' }}>
|
<div style={{ fontSize: '0.85rem', color: 'var(--text2)', marginTop: '0.35rem' }}>
|
||||||
<span>
|
<span>
|
||||||
{r.goals_count ?? '—'} Ziele · {r.slots_count ?? '—'} Slots
|
{(r.goals_count ?? '—') + ' Ziele · '}
|
||||||
|
{(r.slots_count ?? '—') + ' Slots'}
|
||||||
</span>
|
</span>
|
||||||
{contextTeaser(r) ? (
|
|
||||||
<span style={{ display: 'block', marginTop: '0.25rem', color: 'var(--text3)' }}>
|
|
||||||
{contextTeaser(r)}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
{r.description ? (
|
<FrameworkSummaryMeta r={r} />
|
||||||
<p style={{ marginTop: '0.5rem', fontSize: '0.9rem', color: 'var(--text2)' }}>{r.description}</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
||||||
<Link
|
<Link
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,12 @@ import {
|
||||||
hydrateExercisePlanningRow,
|
hydrateExercisePlanningRow,
|
||||||
} from '../utils/trainingUnitSectionsForm'
|
} from '../utils/trainingUnitSectionsForm'
|
||||||
|
|
||||||
|
function addDaysIsoDate(isoDay, daysDelta) {
|
||||||
|
const d = new Date(`${isoDay}T12:00:00`)
|
||||||
|
d.setDate(d.getDate() + daysDelta)
|
||||||
|
return d.toISOString().slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
function TrainingPlanningPage() {
|
function TrainingPlanningPage() {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const [groups, setGroups] = useState([])
|
const [groups, setGroups] = useState([])
|
||||||
|
|
@ -31,6 +37,17 @@ function TrainingPlanningPage() {
|
||||||
const today = new Date().toISOString().split('T')[0]
|
const today = new Date().toISOString().split('T')[0]
|
||||||
const thirtyDaysLater = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
|
const thirtyDaysLater = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
|
||||||
|
|
||||||
|
const [frameworkImportOpen, setFrameworkImportOpen] = useState(false)
|
||||||
|
const [frameworkProgramsList, setFrameworkProgramsList] = useState([])
|
||||||
|
const [fwImportProgramId, setFwImportProgramId] = useState('')
|
||||||
|
const [fwImportDetail, setFwImportDetail] = useState(null)
|
||||||
|
const [fwImportLoading, setFwImportLoading] = useState(false)
|
||||||
|
const [fwImportSelectedSlots, setFwImportSelectedSlots] = useState(() => new Set())
|
||||||
|
const [fwImportSlotDates, setFwImportSlotDates] = useState({})
|
||||||
|
const [fwImportStartDate, setFwImportStartDate] = useState(today)
|
||||||
|
const [fwImportIntervalDays, setFwImportIntervalDays] = useState(7)
|
||||||
|
const [fwImportSubmitting, setFwImportSubmitting] = useState(false)
|
||||||
|
|
||||||
const [startDate, setStartDate] = useState(today)
|
const [startDate, setStartDate] = useState(today)
|
||||||
const [endDate, setEndDate] = useState(thirtyDaysLater)
|
const [endDate, setEndDate] = useState(thirtyDaysLater)
|
||||||
|
|
||||||
|
|
@ -60,6 +77,137 @@ function TrainingPlanningPage() {
|
||||||
}
|
}
|
||||||
}, [selectedGroupId, startDate, endDate])
|
}, [selectedGroupId, startDate, endDate])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!frameworkImportOpen) return
|
||||||
|
let cancelled = false
|
||||||
|
;(async () => {
|
||||||
|
try {
|
||||||
|
const list = await api.listTrainingFrameworkPrograms()
|
||||||
|
if (!cancelled) setFrameworkProgramsList(Array.isArray(list) ? list : [])
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) {
|
||||||
|
console.error('Rahmenprogramme laden:', e)
|
||||||
|
setFrameworkProgramsList([])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [frameworkImportOpen])
|
||||||
|
|
||||||
|
const openFrameworkImportModal = useCallback(() => {
|
||||||
|
setFwImportProgramId('')
|
||||||
|
setFwImportDetail(null)
|
||||||
|
setFwImportSelectedSlots(new Set())
|
||||||
|
setFwImportSlotDates({})
|
||||||
|
setFwImportStartDate(new Date().toISOString().split('T')[0])
|
||||||
|
setFwImportIntervalDays(7)
|
||||||
|
setFrameworkImportOpen(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const onFwImportProgramChange = async (idStr) => {
|
||||||
|
setFwImportProgramId(idStr)
|
||||||
|
if (!idStr) {
|
||||||
|
setFwImportDetail(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setFwImportLoading(true)
|
||||||
|
try {
|
||||||
|
const d = await api.getTrainingFrameworkProgram(parseInt(idStr, 10))
|
||||||
|
setFwImportDetail(d)
|
||||||
|
setFwImportSelectedSlots(new Set())
|
||||||
|
setFwImportSlotDates({})
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.message || 'Rahmenprogramm laden fehlgeschlagen')
|
||||||
|
setFwImportDetail(null)
|
||||||
|
} finally {
|
||||||
|
setFwImportLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleFwImportSlot = (slot) => {
|
||||||
|
if (!slot?.blueprint_training_unit_id) return
|
||||||
|
const sid = slot.id
|
||||||
|
setFwImportSelectedSlots((prev) => {
|
||||||
|
const n = new Set(prev)
|
||||||
|
if (n.has(sid)) n.delete(sid)
|
||||||
|
else n.add(sid)
|
||||||
|
return n
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyFwImportDateSuggestions = () => {
|
||||||
|
if (!fwImportDetail?.slots?.length) return
|
||||||
|
const sorted = [...fwImportDetail.slots].sort(
|
||||||
|
(a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)
|
||||||
|
)
|
||||||
|
let offset = 0
|
||||||
|
const iv = Math.max(0, Number(fwImportIntervalDays) || 0)
|
||||||
|
const next = {}
|
||||||
|
for (const s of sorted) {
|
||||||
|
if (!fwImportSelectedSlots.has(s.id)) continue
|
||||||
|
if (!s.blueprint_training_unit_id) continue
|
||||||
|
next[String(s.id)] = addDaysIsoDate(fwImportStartDate, offset)
|
||||||
|
offset += iv
|
||||||
|
}
|
||||||
|
setFwImportSlotDates((prev) => ({ ...prev, ...next }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitFrameworkImport = async () => {
|
||||||
|
if (!selectedGroupId) {
|
||||||
|
alert('Bitte zuerst eine Trainingsgruppe wählen.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const gid = parseInt(selectedGroupId, 10)
|
||||||
|
if (!fwImportDetail?.slots?.length) return
|
||||||
|
const sorted = [...fwImportDetail.slots].sort(
|
||||||
|
(a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)
|
||||||
|
)
|
||||||
|
const picks = sorted.filter(
|
||||||
|
(s) => fwImportSelectedSlots.has(s.id) && s.blueprint_training_unit_id
|
||||||
|
)
|
||||||
|
if (!picks.length) {
|
||||||
|
alert('Bitte mindestens eine Session mit Ablauf (Blueprint) auswählen.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for (const s of picks) {
|
||||||
|
const key = String(s.id)
|
||||||
|
const date = fwImportSlotDates[key] || fwImportStartDate
|
||||||
|
if (!date) {
|
||||||
|
alert('Bitte für jede ausgewählte Session ein Datum setzen (oder Datumsvorschläge nutzen).')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setFwImportSubmitting(true)
|
||||||
|
try {
|
||||||
|
for (const s of picks) {
|
||||||
|
const key = String(s.id)
|
||||||
|
const date = fwImportSlotDates[key] || fwImportStartDate
|
||||||
|
await api.createTrainingUnitFromFrameworkSlot({
|
||||||
|
group_id: gid,
|
||||||
|
planned_date: date,
|
||||||
|
framework_slot_id: s.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
setFrameworkImportOpen(false)
|
||||||
|
await loadUnits()
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.message || 'Übernahme fehlgeschlagen')
|
||||||
|
} finally {
|
||||||
|
setFwImportSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const frameworkLineageText = (unit) => {
|
||||||
|
const fpTitle = (unit.origin_framework_program_title || '').trim() || 'Rahmenprogramm'
|
||||||
|
const st = (unit.origin_framework_slot_title || '').trim()
|
||||||
|
const idx = unit.origin_framework_slot_sort_order
|
||||||
|
const slotBit =
|
||||||
|
st || (typeof idx === 'number' ? `Session ${idx + 1}` : 'Session')
|
||||||
|
return { fpTitle, slotBit, fpId: unit.origin_framework_program_id }
|
||||||
|
}
|
||||||
|
|
||||||
const loadPlanTemplates = useCallback(async () => {
|
const loadPlanTemplates = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const tpl = await api.listTrainingPlanTemplates()
|
const tpl = await api.listTrainingPlanTemplates()
|
||||||
|
|
@ -457,6 +605,15 @@ function TrainingPlanningPage() {
|
||||||
Schnell erstellen
|
Schnell erstellen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
disabled={!selectedGroupId}
|
||||||
|
title={!selectedGroupId ? 'Zuerst eine Trainingsgruppe wählen' : undefined}
|
||||||
|
onClick={openFrameworkImportModal}
|
||||||
|
>
|
||||||
|
Aus Rahmen übernehmen…
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -476,7 +633,9 @@ function TrainingPlanningPage() {
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'grid', gap: '1rem' }}>
|
<div style={{ display: 'grid', gap: '1rem' }}>
|
||||||
{units.map((unit) => (
|
{units.map((unit) => {
|
||||||
|
const lineage = unit.origin_framework_slot_id ? frameworkLineageText(unit) : null
|
||||||
|
return (
|
||||||
<div key={unit.id} className="card">
|
<div key={unit.id} className="card">
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -505,6 +664,29 @@ function TrainingPlanningPage() {
|
||||||
Fokus: {unit.planned_focus}
|
Fokus: {unit.planned_focus}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{lineage ? (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
color: 'var(--text2)',
|
||||||
|
fontSize: '0.82rem',
|
||||||
|
marginBottom: '0.5rem',
|
||||||
|
lineHeight: 1.45,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontWeight: 600, color: 'var(--text3)' }}>Aus Rahmen: </span>
|
||||||
|
{unit.origin_framework_program_id ? (
|
||||||
|
<Link
|
||||||
|
to={`/planning/framework-programs/${unit.origin_framework_program_id}`}
|
||||||
|
style={{ color: 'var(--accent-dark)' }}
|
||||||
|
>
|
||||||
|
{lineage.fpTitle}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span>{lineage.fpTitle}</span>
|
||||||
|
)}
|
||||||
|
<span style={{ color: 'var(--text2)' }}> · {lineage.slotBit}</span>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -586,7 +768,201 @@ function TrainingPlanningPage() {
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{frameworkImportOpen && (
|
||||||
|
<div
|
||||||
|
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(620px, 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{' '}
|
||||||
|
<strong>eigene geplante Einheit</strong> in der aktuellen Gruppe (Kopie des Ablaufs). Die{' '}
|
||||||
|
<strong>Verknüpfung zum Rahmen-Slot</strong> wird gespeichert, damit die Herkunft sichtbar bleibt.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Rahmenprogramm</label>
|
||||||
|
<select
|
||||||
|
className="form-input"
|
||||||
|
value={fwImportProgramId}
|
||||||
|
onChange={(e) => onFwImportProgramChange(e.target.value)}
|
||||||
|
disabled={fwImportLoading || fwImportSubmitting}
|
||||||
|
>
|
||||||
|
<option value="">Bitte wählen…</option>
|
||||||
|
{frameworkProgramsList.map((fp) => (
|
||||||
|
<option key={fp.id} value={String(fp.id)}>
|
||||||
|
{(fp.title || '').trim() || `Rahmen #${fp.id}`}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
|
</select>
|
||||||
|
</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}`
|
||||||
|
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={() => toggleFwImportSlot(slot)}
|
||||||
|
style={{ marginTop: '0.2rem', flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
<span style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<strong>{label}</strong>
|
||||||
|
{!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) =>
|
||||||
|
setFwImportSlotDates((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[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) => setFwImportStartDate(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) => setFwImportIntervalDays(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={applyFwImportDateSuggestions}
|
||||||
|
>
|
||||||
|
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={submitFrameworkImport}
|
||||||
|
>
|
||||||
|
{fwImportSubmitting ? 'Übernehmen…' : 'In Planung übernehmen'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
disabled={fwImportSubmitting}
|
||||||
|
onClick={() => setFrameworkImportOpen(false)}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -625,6 +1001,39 @@ function TrainingPlanningPage() {
|
||||||
{editingUnit ? 'Trainingseinheit bearbeiten' : 'Neue Trainingseinheit'}
|
{editingUnit ? 'Trainingseinheit bearbeiten' : 'Neue Trainingseinheit'}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
|
{editingUnit?.origin_framework_slot_id ? (() => {
|
||||||
|
const L = frameworkLineageText(editingUnit)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="card"
|
||||||
|
style={{
|
||||||
|
marginBottom: '1.1rem',
|
||||||
|
padding: '12px 14px',
|
||||||
|
background: 'var(--surface2)',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
lineHeight: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong style={{ color: 'var(--text1)' }}>Herkunft:</strong>{' '}
|
||||||
|
{editingUnit.origin_framework_program_id ? (
|
||||||
|
<Link
|
||||||
|
to={`/planning/framework-programs/${editingUnit.origin_framework_program_id}`}
|
||||||
|
style={{ color: 'var(--accent-dark)' }}
|
||||||
|
>
|
||||||
|
{L.fpTitle}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
L.fpTitle
|
||||||
|
)}
|
||||||
|
<span style={{ color: 'var(--text2)' }}> · {L.slotBit}</span>
|
||||||
|
<p style={{ margin: '0.5rem 0 0', fontSize: '0.82rem', color: 'var(--text2)' }}>
|
||||||
|
Inhalt stammt aus dem Session-Blueprint des Rahmenprogramms. Änderungen gelten nur für diese
|
||||||
|
geplante Einheit; die Zuordnung zum Rahmen bleibt zur Nachverfolgung erhalten.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})() : null}
|
||||||
|
|
||||||
{!editingUnit && (
|
{!editingUnit && (
|
||||||
<div className="form-row" style={{ marginBottom: '1.25rem' }}>
|
<div className="form-row" style={{ marginBottom: '1.25rem' }}>
|
||||||
<label className="form-label">Gliederungsvorlage (optional)</label>
|
<label className="form-label">Gliederungsvorlage (optional)</label>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user