feat: enhance training framework programs and planning features
- Added aggregation of training type names and target group names in the training framework programs API response for improved data presentation. - Implemented origin framework slot tracking in the training planning module, allowing users to import training units from framework programs. - Enhanced the TrainingFrameworkProgramsListPage to display aggregated training type and target group information, improving user experience. - Introduced a modal for importing framework programs into training planning, streamlining the process of managing training units.
This commit is contained in:
parent
86192df508
commit
6dcbc8c610
|
|
@ -295,7 +295,19 @@ def list_training_framework_programs(session=Depends(require_auth)):
|
|||
(SELECT COUNT(*)::int FROM training_framework_program_training_types t
|
||||
WHERE t.framework_program_id = fp.id) AS training_types_count,
|
||||
(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
|
||||
LEFT JOIN focus_areas fa ON fa.id = fp.focus_area_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")
|
||||
|
||||
|
||||
# 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]]:
|
||||
cur.execute(
|
||||
"""
|
||||
|
|
@ -636,12 +649,15 @@ def list_training_units(
|
|||
tg.name as group_name,
|
||||
tg.weekday as group_weekday,
|
||||
c.name as club_name,
|
||||
p.name as trainer_name
|
||||
p.name as trainer_name"""
|
||||
query += "," + _ORIGIN_LINEAGE_FIELDS
|
||||
query += """
|
||||
FROM training_units tu
|
||||
LEFT JOIN training_groups tg ON tu.group_id = tg.id
|
||||
LEFT JOIN clubs c ON tg.club_id = c.id
|
||||
LEFT JOIN profiles p ON tu.created_by = p.id
|
||||
"""
|
||||
query += _ORIGIN_LINEAGE_JOIN
|
||||
|
||||
where = []
|
||||
params = []
|
||||
|
|
@ -695,11 +711,13 @@ def get_training_unit(unit_id: int, session=Depends(require_auth)):
|
|||
tg.time_end as group_time_end,
|
||||
tg.location as group_location,
|
||||
c.name as club_name,
|
||||
p.name as trainer_name
|
||||
p.name as trainer_name,
|
||||
""" + _ORIGIN_LINEAGE_FIELDS.strip() + """
|
||||
FROM training_units tu
|
||||
LEFT JOIN training_groups tg ON tu.group_id = tg.id
|
||||
LEFT JOIN clubs c ON tg.club_id = c.id
|
||||
LEFT JOIN profiles p ON tu.created_by = p.id
|
||||
""" + _ORIGIN_LINEAGE_JOIN.strip() + """
|
||||
WHERE tu.id = %s
|
||||
""",
|
||||
(unit_id,),
|
||||
|
|
|
|||
|
|
@ -2,21 +2,58 @@ import React, { useCallback, useEffect, useState } from 'react'
|
|||
import { Link } from 'react-router-dom'
|
||||
import api from '../utils/api'
|
||||
|
||||
const TYPE_COUNT = (r) =>
|
||||
typeof r.training_types_count === 'number' ? r.training_types_count : null
|
||||
const TG_COUNT = (r) =>
|
||||
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 dashIfEmpty(val) {
|
||||
const s = (val ?? '').toString().trim()
|
||||
return s.length ? s : '—'
|
||||
}
|
||||
|
||||
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() {
|
||||
const [rows, setRows] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
|
@ -123,7 +160,7 @@ export default function TrainingFrameworkProgramsListPage() {
|
|||
gap: '0.75rem',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ minWidth: 0, flex: '1 1 220px' }}>
|
||||
<Link
|
||||
to={`/planning/framework-programs/${r.id}`}
|
||||
style={{ fontWeight: 600, fontSize: '1.05rem', color: 'var(--text1)' }}
|
||||
|
|
@ -132,17 +169,11 @@ export default function TrainingFrameworkProgramsListPage() {
|
|||
</Link>
|
||||
<div style={{ fontSize: '0.85rem', color: 'var(--text2)', marginTop: '0.35rem' }}>
|
||||
<span>
|
||||
{r.goals_count ?? '—'} Ziele · {r.slots_count ?? '—'} Slots
|
||||
{(r.goals_count ?? '—') + ' Ziele · '}
|
||||
{(r.slots_count ?? '—') + ' Slots'}
|
||||
</span>
|
||||
{contextTeaser(r) ? (
|
||||
<span style={{ display: 'block', marginTop: '0.25rem', color: 'var(--text3)' }}>
|
||||
{contextTeaser(r)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{r.description ? (
|
||||
<p style={{ marginTop: '0.5rem', fontSize: '0.9rem', color: 'var(--text2)' }}>{r.description}</p>
|
||||
) : null}
|
||||
<FrameworkSummaryMeta r={r} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
||||
<Link
|
||||
|
|
|
|||
|
|
@ -13,6 +13,12 @@ import {
|
|||
hydrateExercisePlanningRow,
|
||||
} 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() {
|
||||
const { user } = useAuth()
|
||||
const [groups, setGroups] = useState([])
|
||||
|
|
@ -31,6 +37,17 @@ function TrainingPlanningPage() {
|
|||
const today = new Date().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 [endDate, setEndDate] = useState(thirtyDaysLater)
|
||||
|
||||
|
|
@ -60,6 +77,137 @@ function TrainingPlanningPage() {
|
|||
}
|
||||
}, [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 () => {
|
||||
try {
|
||||
const tpl = await api.listTrainingPlanTemplates()
|
||||
|
|
@ -457,6 +605,15 @@ function TrainingPlanningPage() {
|
|||
Schnell erstellen
|
||||
</button>
|
||||
</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>
|
||||
|
|
@ -476,7 +633,9 @@ function TrainingPlanningPage() {
|
|||
</div>
|
||||
) : (
|
||||
<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
|
||||
style={{
|
||||
|
|
@ -505,6 +664,29 @@ function TrainingPlanningPage() {
|
|||
Fokus: {unit.planned_focus}
|
||||
</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' }}>
|
||||
<span
|
||||
style={{
|
||||
|
|
@ -586,7 +768,201 @@ function TrainingPlanningPage() {
|
|||
</p>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
|
||||
|
|
@ -625,6 +1001,39 @@ function TrainingPlanningPage() {
|
|||
{editingUnit ? 'Trainingseinheit bearbeiten' : 'Neue Trainingseinheit'}
|
||||
</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 && (
|
||||
<div className="form-row" style={{ marginBottom: '1.25rem' }}>
|
||||
<label className="form-label">Gliederungsvorlage (optional)</label>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user