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
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:
parent
623af621b4
commit
7693139242
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user