Update version to 0.8.146 and implement publish-to-framework feature for training units
All checks were successful
Deploy Development / deploy (push) Successful in 49s
Test Suite / pytest-backend (push) Successful in 37s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m19s

- Incremented app version to 0.8.146 and updated build date to 2026-05-19.
- Added new API endpoint to publish training units as session blueprints to framework programs.
- Introduced frontend functionality to support publishing training units, including a modal for user interaction.
- Updated changelog to reflect the new feature and its associated changes.
This commit is contained in:
Lars 2026-05-19 08:51:48 +02:00
parent 623af621b4
commit 7693139242
6 changed files with 770 additions and 4 deletions

View File

@ -878,6 +878,86 @@ def _phases_clone_payload(cur, unit_id: int) -> List[Dict[str, Any]]:
return out
def _copy_scheduled_unit_plan_to_blueprint(
cur,
source_unit_id: int,
blueprint_unit_id: int,
profile_id: int,
role: str,
) -> None:
"""Übernimmt Phasen/Sektionen einer geplanten Einheit in eine Rahmen-Blueprint-Einheit."""
cloned_phases = _phases_clone_payload(cur, source_unit_id)
if cloned_phases:
_replace_unit_phases(cur, blueprint_unit_id, cloned_phases, profile_id, role, profile_id)
return
secs = _fetch_sections(cur, source_unit_id)
sections_payload = [_clone_section_payload_dict(s) for s in secs]
if not sections_payload:
_replace_unit_sections(
cur,
blueprint_unit_id,
[{"title": "Ablauf", "order_index": 0, "guidance_notes": None, "items": []}],
)
return
_replace_unit_sections(cur, blueprint_unit_id, sections_payload)
def _shift_framework_slots_sort_orders_from(cur, framework_program_id: int, from_sort_order: int) -> None:
cur.execute(
"""
UPDATE training_framework_slots
SET sort_order = sort_order + 1
WHERE framework_program_id = %s AND sort_order >= %s
""",
(framework_program_id, int(from_sort_order)),
)
def _insert_framework_slot_and_blueprint_unit(
cur,
framework_program_id: int,
sort_order: int,
title: Optional[str],
notes: Optional[Any],
profile_id: int,
) -> Tuple[int, int]:
"""Legt Slot-Zeile + Blueprint-`training_units`-Zeile an; gibt (slot_id, blueprint_unit_id) zurück."""
cur.execute(
"""
INSERT INTO training_framework_slots (
framework_program_id, sort_order, title, notes, training_unit_id
) VALUES (%s, %s, %s, %s, NULL)
RETURNING id
""",
(
framework_program_id,
int(sort_order),
title,
notes,
),
)
sid = int(cur.fetchone()["id"])
cur.execute(
"""
INSERT INTO training_units (
group_id, planned_date,
planned_time_start, planned_time_end, planned_focus,
status, notes, trainer_notes,
created_by, plan_template_id, framework_slot_id
) VALUES (
NULL, NULL,
NULL, NULL, NULL,
'planned', NULL, NULL,
%s, NULL, %s
)
RETURNING id
""",
(profile_id, sid),
)
bid = int(cur.fetchone()["id"])
return sid, bid
def _copy_blueprint_into_scheduled_unit(
cur,
blueprint_unit_id: int,
@ -2877,6 +2957,234 @@ def create_training_unit_from_framework_slot(data: dict, tenant: TenantContext =
return get_training_unit(new_id, tenant)
@router.post("/training-units/{unit_id}/publish-to-framework")
def publish_training_unit_to_framework(
unit_id: int, data: dict, tenant: TenantContext = Depends(get_tenant_context)
):
"""Geplanten Ablauf einer Einheit als Session-Blueprint in ein Rahmenprogramm übernehmen (neu / bestehend, Slot wählbar)."""
from routers.training_framework_programs import ( # noqa: WPS433 — zyklischer Import
_assert_visibility,
_fetch_framework_row,
_insert_goal_rows,
_parse_positive_int_ids,
_replace_target_groups,
_replace_training_types,
_response_framework_detail,
)
profile_id = tenant.profile_id
role = tenant.global_role
if not _has_planning_role(role):
raise HTTPException(status_code=403, detail="Nur Planungsberechtigte dürfen Rahmenprogramme bearbeiten")
mode = (data.get("mode") or "").strip().lower()
if mode not in ("new_slot", "existing_slot"):
raise HTTPException(status_code=400, detail="mode muss new_slot oder existing_slot sein")
fw_new = data.get("new_framework")
fw_id_raw = data.get("framework_program_id")
has_new = isinstance(fw_new, dict) and len(fw_new) > 0
has_id = fw_id_raw not in (None, "")
if has_new == has_id:
raise HTTPException(
status_code=400,
detail="Entweder new_framework ODER framework_program_id angeben (nicht beides, nicht keines)",
)
framework_id: int = 0
slot_title_o: Optional[str] = None
notes_o = data.get("slot_notes")
st = data.get("slot_title")
if st is not None:
st = str(st).strip()
slot_title_o = st or None
with get_db() as conn:
cur = get_cursor(conn)
unit_row = _training_unit_guard_row(cur, unit_id)
if unit_row.get("framework_slot_id"):
raise HTTPException(
status_code=400,
detail="Nur geplante Einheiten (Kalender) können in einen Rahmen übernommen werden, keine Blueprint-Einheit",
)
_assert_training_unit_permission(cur, unit_row, profile_id, role)
if has_new:
title = (fw_new.get("title") or "").strip()
if not title:
raise HTTPException(status_code=400, detail="new_framework.title ist Pflicht")
vis = _assert_visibility(fw_new.get("visibility") or "private")
club_nf = fw_new.get("club_id")
if club_nf in ("", []):
club_nf = None
if vis == "club" and club_nf is None:
club_nf = tenant.effective_club_id
goals_in = fw_new.get("goals")
if not isinstance(goals_in, list) or not goals_in:
raise HTTPException(
status_code=400,
detail="new_framework.goals als Liste mit mindestens einem Eintrag ist Pflicht",
)
fa_id = _optional_positive_int(fw_new.get("focus_area_id"), "focus_area_id")
sd_id = _optional_positive_int(fw_new.get("style_direction_id"), "style_direction_id")
tt_ids = _parse_positive_int_ids(fw_new.get("training_type_ids"), "training_type_ids")
tg_ids = _parse_positive_int_ids(fw_new.get("target_group_ids"), "target_group_ids")
assert_valid_governance_visibility(cur, profile_id, role, vis, club_nf)
cur.execute(
"""
INSERT INTO training_framework_programs (
title, description,
planned_period_start, planned_period_end,
visibility, club_id, created_by,
focus_area_id, style_direction_id
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
""",
(
title[:200],
fw_new.get("description"),
fw_new.get("planned_period_start"),
fw_new.get("planned_period_end"),
vis,
club_nf,
profile_id,
fa_id,
sd_id,
),
)
framework_id = int(cur.fetchone()["id"])
_insert_goal_rows(cur, framework_id, goals_in)
_replace_training_types(cur, framework_id, tt_ids)
_replace_target_groups(cur, framework_id, tg_ids)
else:
try:
framework_id = int(fw_id_raw)
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail="framework_program_id ungültig") from None
if framework_id < 1:
raise HTTPException(status_code=400, detail="framework_program_id ungültig")
row_fw = _fetch_framework_row(cur, framework_id)
assert_library_content_editable(cur, profile_id, role, row_fw)
ins_raw = data.get("insert_at_index")
slot_id_out: int = 0
if mode == "new_slot":
pos: Optional[int] = None
if ins_raw is not None and ins_raw != "":
try:
pos = int(ins_raw)
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail="insert_at_index ungültig") from None
if pos < 0:
raise HTTPException(status_code=400, detail="insert_at_index ungültig")
if pos is None:
cur.execute(
"""
SELECT COALESCE(MAX(sort_order), -1) + 1 AS n
FROM training_framework_slots
WHERE framework_program_id = %s
""",
(framework_id,),
)
pos = int(cur.fetchone()["n"])
else:
cur.execute(
"SELECT COUNT(*)::int AS c FROM training_framework_slots WHERE framework_program_id = %s",
(framework_id,),
)
cmax = int(cur.fetchone()["c"])
if pos > cmax:
pos = cmax
_shift_framework_slots_sort_orders_from(cur, framework_id, pos)
sid, bid = _insert_framework_slot_and_blueprint_unit(
cur,
framework_id,
pos,
slot_title_o,
notes_o,
profile_id,
)
slot_id_out = sid
_copy_scheduled_unit_plan_to_blueprint(cur, unit_id, bid, profile_id, role)
_promote_private_exercises_used_in_unit(cur, bid, profile_id, role)
else:
raw_existing = data.get("framework_slot_id")
try:
slot_id_out = int(raw_existing)
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail="framework_slot_id ist Pflicht und muss eine Zahl sein") from None
if slot_id_out < 1:
raise HTTPException(status_code=400, detail="framework_slot_id ist ungültig")
cur.execute(
"""
SELECT id, framework_program_id, sort_order, title, notes
FROM training_framework_slots
WHERE id = %s
""",
(slot_id_out,),
)
slot_row = cur.fetchone()
if not slot_row:
raise HTTPException(status_code=404, detail="Rahmen-Slot nicht gefunden")
if int(slot_row["framework_program_id"]) != framework_id:
raise HTTPException(status_code=400, detail="Slot gehört nicht zu diesem Rahmenprogramm")
cur.execute(
"SELECT id FROM training_units WHERE framework_slot_id = %s",
(slot_id_out,),
)
bp = cur.fetchone()
if not bp:
raise HTTPException(status_code=404, detail="Keine Blueprint-Einheit für diesen Slot")
meta_fields: List[str] = []
meta_params: List[Any] = []
if "slot_title" in data:
stn = data.get("slot_title")
title_v = (str(stn).strip() or None) if stn is not None else None
meta_fields.append("title = %s")
meta_params.append(title_v)
if "slot_notes" in data:
meta_fields.append("notes = %s")
meta_params.append(data.get("slot_notes"))
if meta_fields:
meta_params.append(slot_id_out)
cur.execute(
f"""
UPDATE training_framework_slots
SET {", ".join(meta_fields)}
WHERE id = %s
""",
tuple(meta_params),
)
bid = int(bp["id"])
_copy_scheduled_unit_plan_to_blueprint(cur, unit_id, bid, profile_id, role)
_promote_private_exercises_used_in_unit(cur, bid, profile_id, role)
cur.execute(
"UPDATE training_framework_programs SET updated_at = NOW() WHERE id = %s",
(framework_id,),
)
conn.commit()
return _response_framework_detail(framework_id, profile_id, role)
@router.post("/training-units/quick-create")
def quick_create_training_unit(data: dict, tenant: TenantContext = Depends(get_tenant_context)):
profile_id = tenant.profile_id

View File

@ -1,7 +1,7 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.145"
BUILD_DATE = "2026-05-16"
APP_VERSION = "0.8.146"
BUILD_DATE = "2026-05-19"
DB_SCHEMA_VERSION = "20260516065"
MODULE_VERSIONS = {
@ -22,9 +22,9 @@ MODULE_VERSIONS = {
"skills": "0.1.1", # DB 065 karate_relevance + relevance_level; CRUD unterstützt Felder
"methods": "0.1.0",
"exercises": "2.28.0", # GET /api/exercises Keyset cursor_updated_at + cursor_id; Sortierung id als Tie-break
"training_units": "0.3.0", # GET /api/training-units Keyset cursor_planned_date + cursor_id (+ optional cursor_planned_time); Sort mit id-Tiebreak
"training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint
"training_programs": "0.1.0",
"planning": "0.13.0", # Vorlagen/Framework/Module/Graphs: RBAC wie Übungen (edit/delete/governance transition); Planungs-UI Sichtbarkeit neue Vorlage
"planning": "0.14.0", # publish-to-framework; UI Rahmen-Session aus Planung
"dashboard": "1.1.0", # GET /api/dashboard/kpis inkl. training_home (ein Client-Roundtrip für KPIs + nächste Termine)
"training_modules": "1.1.0", # PUT/DELETE: assert_library_content_* (Vereinsadmin löscht Vereins-Inhalt, Trainer bearbeitet club wie Übungen)
"import_wiki": "1.0.3", # Default-Kategorie Fähigkeiten: Fähigkeitsbeschreibung; cmtitle-Normalisierung; UI Preview/Execute Defaults je Typ
@ -36,6 +36,13 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.146",
"date": "2026-05-19",
"changes": [
"Planung: Trainingseinheit → Rahmenprogramm (Session-Slot) speichern; API POST /api/training-units/{id}/publish-to-framework",
],
},
{
"version": "0.8.145",
"date": "2026-05-16",

View File

@ -144,6 +144,14 @@ export async function applyTrainingModuleToTrainingUnit(unitId, data) {
})
}
/** Geplanten Ablauf als Session-Blueprint in ein Rahmenprogramm schreiben (neu oder bestehend). */
export async function publishTrainingUnitToFramework(unitId, data) {
return request(`/api/training-units/${unitId}/publish-to-framework`, {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function listTrainingFrameworkPrograms() {
return request('/api/training-framework-programs')
}

View File

@ -11,6 +11,7 @@ import TrainingPlanningFrameworkImportModal from './TrainingPlanningFrameworkImp
import TrainingPlanningModuleApplyModal from './TrainingPlanningModuleApplyModal'
import TrainingPlanningTrainerAssignModal from './TrainingPlanningTrainerAssignModal'
import TrainingPlanningUnitFormModal from './TrainingPlanningUnitFormModal'
import TrainingPublishToFrameworkModal from './TrainingPublishToFrameworkModal'
/* Parallele Streams: Editor bleibt flache Abschnittsliste; `planLoc` pro Abschnitt steuert PUT `phases` vs. Legacy `sections`. */
import {
defaultSection,
@ -51,6 +52,7 @@ function TrainingPlanningPageRoot() {
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [editingUnit, setEditingUnit] = useState(null)
const [publishFrameworkOpen, setPublishFrameworkOpen] = useState(false)
/** Abschnitts-Editor bei Bearbeitung: Planung vs. Nachbereitung (Ist & Abweichungen) */
const [sectionsEditMode, setSectionsEditMode] = useState('planning')
const [draftPlanTemplateId, setDraftPlanTemplateId] = useState('')
@ -1955,6 +1957,14 @@ function TrainingPlanningPageRoot() {
onClose={() => setFrameworkImportOpen(false)}
/>
<TrainingPublishToFrameworkModal
open={publishFrameworkOpen}
onClose={() => setPublishFrameworkOpen(false)}
onSuccess={() => setShowModal(false)}
unitId={editingUnit?.id}
planningModalClubId={planningModalClubId}
/>
<TrainingPlanningUnitFormModal
open={showModal}
editingUnit={editingUnit}
@ -1975,6 +1985,7 @@ function TrainingPlanningPageRoot() {
sectionsEditMode={sectionsEditMode}
setSectionsEditMode={setSectionsEditMode}
onSaveAsTemplate={handleSaveAsTemplate}
onRequestPublishToFramework={() => setPublishFrameworkOpen(true)}
onRequestTrainingModulePick={(ctx) => {
void openModuleApplyModal(ctx)
}}

View File

@ -29,6 +29,7 @@ export default function TrainingPlanningUnitFormModal({
sectionsEditMode,
setSectionsEditMode,
onSaveAsTemplate,
onRequestPublishToFramework,
onRequestTrainingModulePick,
onRequestExercisePick,
onPeekExercise,
@ -492,6 +493,17 @@ export default function TrainingPlanningUnitFormModal({
>
Vorlage aus Aufbau speichern
</button>
{editingUnit?.id && !editingUnit?.framework_slot_id ? (
<button
type="button"
className="btn btn-secondary"
style={{ marginBottom: '2px' }}
onClick={() => onRequestPublishToFramework?.()}
title="Letzten gespeicherten Ablauf ins Rahmenprogramm übernehmen"
>
Als Rahmen-Session speichern
</button>
) : null}
</div>
</>
}

View File

@ -0,0 +1,420 @@
import React, { useEffect, useState, useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import api from '../../utils/api'
import { useToast } from '../../context/ToastContext'
import { useAuth } from '../../context/AuthContext'
import { activeClubMemberships } from '../../utils/activeClub'
/**
* Übernimmt den gespeicherten Ablauf einer geplanten Trainingseinheit in ein Rahmenprogramm (neu oder bestehend, Slot wählbar).
*/
export default function TrainingPublishToFrameworkModal({
open,
onClose,
unitId,
planningModalClubId,
onSuccess,
}) {
const navigate = useNavigate()
const toast = useToast()
const { user } = useAuth()
const memberClubs = useMemo(() => activeClubMemberships(user?.clubs), [user?.clubs])
const roleLc = String(user?.role || '').toLowerCase()
const isSuperadmin = roleLc === 'superadmin'
const [scope, setScope] = useState('existing')
const [programs, setPrograms] = useState([])
const [programsLoading, setProgramsLoading] = useState(false)
const [fwProgramId, setFwProgramId] = useState('')
const [fwDetail, setFwDetail] = useState(null)
const [fwDetailLoading, setFwDetailLoading] = useState(false)
const [slotMode, setSlotMode] = useState('new_slot')
const [insertAt, setInsertAt] = useState('')
const [existingSlotId, setExistingSlotId] = useState('')
const [newTitle, setNewTitle] = useState('')
const [newVisibility, setNewVisibility] = useState('private')
const [newClubId, setNewClubId] = useState('')
const [newGoalTitle, setNewGoalTitle] = useState('Aus geplanter Einheit')
const [slotTitle, setSlotTitle] = useState('')
const [slotNotes, setSlotNotes] = useState('')
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
if (!open) {
return
}
if (planningModalClubId != null && planningModalClubId !== '') {
setNewClubId(String(planningModalClubId))
} else if (memberClubs.length === 1) {
setNewClubId(String(memberClubs[0].id))
}
setProgramsLoading(true)
api
.listTrainingFrameworkPrograms()
.then((list) => {
setPrograms(Array.isArray(list) ? list : [])
})
.catch(() => setPrograms([]))
.finally(() => setProgramsLoading(false))
}, [open, planningModalClubId, memberClubs])
useEffect(() => {
if (!open || scope !== 'existing' || !fwProgramId) {
setFwDetail(null)
return
}
const id = parseInt(fwProgramId, 10)
if (!Number.isFinite(id) || id < 1) {
setFwDetail(null)
return
}
setFwDetailLoading(true)
api
.getTrainingFrameworkProgram(id)
.then(setFwDetail)
.catch(() => setFwDetail(null))
.finally(() => setFwDetailLoading(false))
}, [open, scope, fwProgramId])
const sortedSlots = useMemo(() => {
const sl = fwDetail?.slots
if (!Array.isArray(sl)) return []
return [...sl].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0))
}, [fwDetail])
const resetAndClose = () => {
onClose()
}
const handleSubmit = async (e) => {
e.preventDefault()
if (!unitId || submitting) return
setSubmitting(true)
try {
if (scope === 'new') {
const tit = (newTitle || '').trim()
if (!tit) {
toast.error('Bitte einen Titel für das neue Rahmenprogramm angeben.')
setSubmitting(false)
return
}
const gt = (newGoalTitle || '').trim() || 'Entwicklungsziel'
let club_id =
newVisibility === 'club' && newClubId ? parseInt(newClubId, 10) : null
if (newVisibility === 'club' && (!Number.isFinite(club_id) || club_id < 1)) {
toast.error('Bitte einen Verein wählen (Sichtbarkeit „Verein“).')
setSubmitting(false)
return
}
if (newVisibility !== 'club') club_id = null
const st = (slotTitle || '').trim()
const sn = (slotNotes || '').trim()
const created = await api.publishTrainingUnitToFramework(unitId, {
new_framework: {
title: tit,
visibility: newVisibility,
club_id,
goals: [{ sort_order: 0, title: gt, notes: null }],
},
mode: 'new_slot',
insert_at_index: null,
...(st ? { slot_title: st } : {}),
...(sn ? { slot_notes: slotNotes } : {}),
})
toast.success('Ablauf wurde im Rahmenprogramm gespeichert.')
if (created?.id) {
navigate(`/planning/framework-programs/${created.id}`)
}
onSuccess?.()
resetAndClose()
return
} else {
const fid = parseInt(fwProgramId, 10)
if (!Number.isFinite(fid) || fid < 1) {
toast.error('Bitte ein Rahmenprogramm auswählen.')
setSubmitting(false)
return
}
const payload = {
framework_program_id: fid,
mode: slotMode,
}
const st = (slotTitle || '').trim()
const sn = (slotNotes || '').trim()
if (st) payload.slot_title = st
if (sn) payload.slot_notes = slotNotes
if (slotMode === 'new_slot') {
if (insertAt.trim() === '') {
payload.insert_at_index = null
} else {
const n = parseInt(insertAt, 10)
if (!Number.isFinite(n) || n < 0) {
toast.error('Position: nicht negative Ganzzahl oder leer (anhängen).')
setSubmitting(false)
return
}
payload.insert_at_index = n
}
} else {
const sid = parseInt(existingSlotId, 10)
if (!Number.isFinite(sid) || sid < 1) {
toast.error('Bitte einen Session-Slot zum Überschreiben wählen.')
setSubmitting(false)
return
}
payload.framework_slot_id = sid
}
const updated = await api.publishTrainingUnitToFramework(unitId, payload)
toast.success('Ablauf wurde im Rahmenprogramm gespeichert.')
if (updated?.id) {
navigate(`/planning/framework-programs/${updated.id}`)
}
onSuccess?.()
resetAndClose()
return
}
} catch (err) {
toast.error(err.message || 'Speichern fehlgeschlagen')
} finally {
setSubmitting(false)
}
}
if (!open) return null
return (
<div
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1100,
padding: '1rem',
overflowY: 'auto',
}}
>
<div
className="card"
style={{
maxWidth: 'min(520px, 100%)',
width: '100%',
padding: '1.25rem',
maxHeight: '90vh',
overflowY: 'auto',
}}
>
<h2 style={{ marginTop: 0, marginBottom: '0.65rem' }}>Ablauf ins Rahmenprogramm übernehmen</h2>
<p style={{ fontSize: '0.88rem', color: 'var(--text2)', lineHeight: 1.45, marginBottom: '1rem' }}>
Es wird der <strong>zuletzt gespeicherte</strong> Ablauf dieser Einheit aus der Datenbank übernommen.
Nicht gespeicherte Änderungen im Formular sind nicht enthalten bitte vorher die Einheit speichern.
</p>
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: '1rem' }}>
<span className="form-label">Ziel</span>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px', marginTop: '0.35rem' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
<input
type="radio"
name="pf_scope"
checked={scope === 'existing'}
onChange={() => setScope('existing')}
/>
Bestehendes Rahmenprogramm
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
<input
type="radio"
name="pf_scope"
checked={scope === 'new'}
onChange={() => setScope('new')}
/>
Neues Rahmenprogramm
</label>
</div>
</div>
{scope === 'existing' ? (
<>
<div className="form-row" style={{ marginBottom: '0.85rem' }}>
<label className="form-label">Rahmenprogramm</label>
<select
className="form-input"
value={fwProgramId}
onChange={(e) => {
setFwProgramId(e.target.value)
setExistingSlotId('')
}}
required
>
<option value="">{programsLoading ? 'Laden…' : '— Wählen —'}</option>
{programs.map((p) => (
<option key={p.id} value={String(p.id)}>
{(p.title || '').trim() || `Rahmen #${p.id}`}
</option>
))}
</select>
</div>
<div style={{ marginBottom: '0.85rem' }}>
<span className="form-label">Session-Platz</span>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: '0.35rem' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
<input
type="radio"
name="pf_slot"
checked={slotMode === 'new_slot'}
onChange={() => setSlotMode('new_slot')}
/>
Neuen Session-Slot anlegen
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
<input
type="radio"
name="pf_slot"
checked={slotMode === 'existing_slot'}
onChange={() => setSlotMode('existing_slot')}
/>
Bestehenden Slot überschreiben (Ablauf ersetzen)
</label>
</div>
</div>
{slotMode === 'new_slot' ? (
<div className="form-row" style={{ marginBottom: '0.85rem' }}>
<label className="form-label">Position (0 = erste Stelle)</label>
<input
className="form-input"
type="text"
inputMode="numeric"
placeholder="Leer = ans Ende anhängen"
value={insertAt}
onChange={(e) => setInsertAt(e.target.value)}
/>
<p style={{ fontSize: '0.78rem', color: 'var(--text3)', margin: '0.35rem 0 0' }}>
Die Reihenfolge der Slots kannst du in der Rahmen-Bearbeitung jederzeit ändern (Ziehen oder
Pfeile).
</p>
</div>
) : (
<div className="form-row" style={{ marginBottom: '0.85rem' }}>
<label className="form-label">Slot</label>
<select
className="form-input"
value={existingSlotId}
onChange={(e) => setExistingSlotId(e.target.value)}
required={slotMode === 'existing_slot'}
>
<option value="">
{fwDetailLoading ? 'Laden…' : '— Session wählen —'}
</option>
{sortedSlots.map((s, i) => (
<option key={s.id} value={String(s.id)}>
{(s.title || '').trim() || `Session ${i + 1}`}
</option>
))}
</select>
</div>
)}
</>
) : (
<>
<div className="form-row" style={{ marginBottom: '0.85rem' }}>
<label className="form-label">Titel Rahmenprogramm</label>
<input
className="form-input"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
placeholder="z. B. Saisonvorbereitung"
required
/>
</div>
<div className="form-row" style={{ marginBottom: '0.85rem' }}>
<label className="form-label">Erstes Entwicklungsziel (Pflichtfeld Rahmen)</label>
<input
className="form-input"
value={newGoalTitle}
onChange={(e) => setNewGoalTitle(e.target.value)}
/>
</div>
<div className="form-row" style={{ marginBottom: '0.85rem' }}>
<label className="form-label">Sichtbarkeit</label>
<select
className="form-input"
value={newVisibility}
onChange={(e) => {
const v = e.target.value
setNewVisibility(v)
if (v === 'club' && !newClubId && planningModalClubId != null) {
setNewClubId(String(planningModalClubId))
}
}}
>
<option value="private">Privat</option>
<option value="club">Verein</option>
{isSuperadmin ? <option value="official">Offiziell</option> : null}
</select>
</div>
{newVisibility === 'club' ? (
<div className="form-row" style={{ marginBottom: '0.85rem' }}>
<label className="form-label">Verein</label>
<select
className="form-input"
value={newClubId}
onChange={(e) => setNewClubId(e.target.value)}
>
<option value=""> Verein wählen </option>
{memberClubs.map((c) => (
<option key={c.id} value={String(c.id)}>
{c.name || `Verein #${c.id}`}
</option>
))}
</select>
</div>
) : null}
</>
)}
<div className="form-row" style={{ marginBottom: '0.85rem' }}>
<label className="form-label">Session-Titel (optional)</label>
<input
className="form-input"
value={slotTitle}
onChange={(e) => setSlotTitle(e.target.value)}
placeholder="z. B. Woche 3 — Technik"
/>
</div>
<div className="form-row" style={{ marginBottom: '1.1rem' }}>
<label className="form-label">Notizen zur Session (optional)</label>
<textarea
className="form-input"
rows={2}
value={slotNotes}
onChange={(e) => setSlotNotes(e.target.value)}
/>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '10px', justifyContent: 'flex-end' }}>
<button type="button" className="btn btn-secondary" onClick={resetAndClose} disabled={submitting}>
Abbrechen
</button>
<button type="submit" className="btn btn-primary" disabled={submitting}>
{submitting ? 'Speichern…' : 'In Rahmen übernehmen'}
</button>
</div>
</form>
</div>
</div>
)
}