develop #38
|
|
@ -878,6 +878,86 @@ def _phases_clone_payload(cur, unit_id: int) -> List[Dict[str, Any]]:
|
||||||
return out
|
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(
|
def _copy_blueprint_into_scheduled_unit(
|
||||||
cur,
|
cur,
|
||||||
blueprint_unit_id: int,
|
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)
|
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")
|
@router.post("/training-units/quick-create")
|
||||||
def quick_create_training_unit(data: dict, tenant: TenantContext = Depends(get_tenant_context)):
|
def quick_create_training_unit(data: dict, tenant: TenantContext = Depends(get_tenant_context)):
|
||||||
profile_id = tenant.profile_id
|
profile_id = tenant.profile_id
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Shinkan Jinkendo Version Information
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.145"
|
APP_VERSION = "0.8.146"
|
||||||
BUILD_DATE = "2026-05-16"
|
BUILD_DATE = "2026-05-19"
|
||||||
DB_SCHEMA_VERSION = "20260516065"
|
DB_SCHEMA_VERSION = "20260516065"
|
||||||
|
|
||||||
MODULE_VERSIONS = {
|
MODULE_VERSIONS = {
|
||||||
|
|
@ -22,9 +22,9 @@ MODULE_VERSIONS = {
|
||||||
"skills": "0.1.1", # DB 065 karate_relevance + relevance_level; CRUD unterstützt Felder
|
"skills": "0.1.1", # DB 065 karate_relevance + relevance_level; CRUD unterstützt Felder
|
||||||
"methods": "0.1.0",
|
"methods": "0.1.0",
|
||||||
"exercises": "2.28.0", # GET /api/exercises Keyset cursor_updated_at + cursor_id; Sortierung id als Tie-break
|
"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",
|
"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)
|
"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)
|
"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
|
"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 = [
|
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",
|
"version": "0.8.145",
|
||||||
"date": "2026-05-16",
|
"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() {
|
export async function listTrainingFrameworkPrograms() {
|
||||||
return request('/api/training-framework-programs')
|
return request('/api/training-framework-programs')
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import TrainingPlanningFrameworkImportModal from './TrainingPlanningFrameworkImp
|
||||||
import TrainingPlanningModuleApplyModal from './TrainingPlanningModuleApplyModal'
|
import TrainingPlanningModuleApplyModal from './TrainingPlanningModuleApplyModal'
|
||||||
import TrainingPlanningTrainerAssignModal from './TrainingPlanningTrainerAssignModal'
|
import TrainingPlanningTrainerAssignModal from './TrainingPlanningTrainerAssignModal'
|
||||||
import TrainingPlanningUnitFormModal from './TrainingPlanningUnitFormModal'
|
import TrainingPlanningUnitFormModal from './TrainingPlanningUnitFormModal'
|
||||||
|
import TrainingPublishToFrameworkModal from './TrainingPublishToFrameworkModal'
|
||||||
/* Parallele Streams: Editor bleibt flache Abschnittsliste; `planLoc` pro Abschnitt steuert PUT `phases` vs. Legacy `sections`. */
|
/* Parallele Streams: Editor bleibt flache Abschnittsliste; `planLoc` pro Abschnitt steuert PUT `phases` vs. Legacy `sections`. */
|
||||||
import {
|
import {
|
||||||
defaultSection,
|
defaultSection,
|
||||||
|
|
@ -51,6 +52,7 @@ function TrainingPlanningPageRoot() {
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [showModal, setShowModal] = useState(false)
|
const [showModal, setShowModal] = useState(false)
|
||||||
const [editingUnit, setEditingUnit] = useState(null)
|
const [editingUnit, setEditingUnit] = useState(null)
|
||||||
|
const [publishFrameworkOpen, setPublishFrameworkOpen] = useState(false)
|
||||||
/** Abschnitts-Editor bei Bearbeitung: Planung vs. Nachbereitung (Ist & Abweichungen) */
|
/** Abschnitts-Editor bei Bearbeitung: Planung vs. Nachbereitung (Ist & Abweichungen) */
|
||||||
const [sectionsEditMode, setSectionsEditMode] = useState('planning')
|
const [sectionsEditMode, setSectionsEditMode] = useState('planning')
|
||||||
const [draftPlanTemplateId, setDraftPlanTemplateId] = useState('')
|
const [draftPlanTemplateId, setDraftPlanTemplateId] = useState('')
|
||||||
|
|
@ -1955,6 +1957,14 @@ function TrainingPlanningPageRoot() {
|
||||||
onClose={() => setFrameworkImportOpen(false)}
|
onClose={() => setFrameworkImportOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<TrainingPublishToFrameworkModal
|
||||||
|
open={publishFrameworkOpen}
|
||||||
|
onClose={() => setPublishFrameworkOpen(false)}
|
||||||
|
onSuccess={() => setShowModal(false)}
|
||||||
|
unitId={editingUnit?.id}
|
||||||
|
planningModalClubId={planningModalClubId}
|
||||||
|
/>
|
||||||
|
|
||||||
<TrainingPlanningUnitFormModal
|
<TrainingPlanningUnitFormModal
|
||||||
open={showModal}
|
open={showModal}
|
||||||
editingUnit={editingUnit}
|
editingUnit={editingUnit}
|
||||||
|
|
@ -1975,6 +1985,7 @@ function TrainingPlanningPageRoot() {
|
||||||
sectionsEditMode={sectionsEditMode}
|
sectionsEditMode={sectionsEditMode}
|
||||||
setSectionsEditMode={setSectionsEditMode}
|
setSectionsEditMode={setSectionsEditMode}
|
||||||
onSaveAsTemplate={handleSaveAsTemplate}
|
onSaveAsTemplate={handleSaveAsTemplate}
|
||||||
|
onRequestPublishToFramework={() => setPublishFrameworkOpen(true)}
|
||||||
onRequestTrainingModulePick={(ctx) => {
|
onRequestTrainingModulePick={(ctx) => {
|
||||||
void openModuleApplyModal(ctx)
|
void openModuleApplyModal(ctx)
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ export default function TrainingPlanningUnitFormModal({
|
||||||
sectionsEditMode,
|
sectionsEditMode,
|
||||||
setSectionsEditMode,
|
setSectionsEditMode,
|
||||||
onSaveAsTemplate,
|
onSaveAsTemplate,
|
||||||
|
onRequestPublishToFramework,
|
||||||
onRequestTrainingModulePick,
|
onRequestTrainingModulePick,
|
||||||
onRequestExercisePick,
|
onRequestExercisePick,
|
||||||
onPeekExercise,
|
onPeekExercise,
|
||||||
|
|
@ -492,6 +493,17 @@ export default function TrainingPlanningUnitFormModal({
|
||||||
>
|
>
|
||||||
Vorlage aus Aufbau speichern
|
Vorlage aus Aufbau speichern
|
||||||
</button>
|
</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>
|
</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