From 7693139242ace0900f480d17cb7ca32990ae5b17 Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 19 May 2026 08:51:48 +0200 Subject: [PATCH 01/14] Update version to 0.8.146 and implement publish-to-framework feature for training units - 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. --- backend/routers/training_planning.py | 308 +++++++++++++ backend/version.py | 15 +- frontend/src/api/planning.js | 8 + .../planning/TrainingPlanningPageRoot.jsx | 11 + .../TrainingPlanningUnitFormModal.jsx | 12 + .../TrainingPublishToFrameworkModal.jsx | 420 ++++++++++++++++++ 6 files changed, 770 insertions(+), 4 deletions(-) create mode 100644 frontend/src/components/planning/TrainingPublishToFrameworkModal.jsx diff --git a/backend/routers/training_planning.py b/backend/routers/training_planning.py index e0f215a..ddc05f7 100644 --- a/backend/routers/training_planning.py +++ b/backend/routers/training_planning.py @@ -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 diff --git a/backend/version.py b/backend/version.py index a3c7b7d..597299b 100644 --- a/backend/version.py +++ b/backend/version.py @@ -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", diff --git a/frontend/src/api/planning.js b/frontend/src/api/planning.js index eb02a91..8eb6ee1 100644 --- a/frontend/src/api/planning.js +++ b/frontend/src/api/planning.js @@ -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') } diff --git a/frontend/src/components/planning/TrainingPlanningPageRoot.jsx b/frontend/src/components/planning/TrainingPlanningPageRoot.jsx index e72c214..f17d57d 100644 --- a/frontend/src/components/planning/TrainingPlanningPageRoot.jsx +++ b/frontend/src/components/planning/TrainingPlanningPageRoot.jsx @@ -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)} /> + setPublishFrameworkOpen(false)} + onSuccess={() => setShowModal(false)} + unitId={editingUnit?.id} + planningModalClubId={planningModalClubId} + /> + setPublishFrameworkOpen(true)} onRequestTrainingModulePick={(ctx) => { void openModuleApplyModal(ctx) }} diff --git a/frontend/src/components/planning/TrainingPlanningUnitFormModal.jsx b/frontend/src/components/planning/TrainingPlanningUnitFormModal.jsx index 7136320..caaa064 100644 --- a/frontend/src/components/planning/TrainingPlanningUnitFormModal.jsx +++ b/frontend/src/components/planning/TrainingPlanningUnitFormModal.jsx @@ -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 + {editingUnit?.id && !editingUnit?.framework_slot_id ? ( + + ) : null} } diff --git a/frontend/src/components/planning/TrainingPublishToFrameworkModal.jsx b/frontend/src/components/planning/TrainingPublishToFrameworkModal.jsx new file mode 100644 index 0000000..40aafd2 --- /dev/null +++ b/frontend/src/components/planning/TrainingPublishToFrameworkModal.jsx @@ -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 ( +
+
+

Ablauf ins Rahmenprogramm übernehmen

+

+ Es wird der zuletzt gespeicherte Ablauf dieser Einheit aus der Datenbank übernommen. + Nicht gespeicherte Änderungen im Formular sind nicht enthalten — bitte vorher die Einheit speichern. +

+ +
+
+ Ziel +
+ + +
+
+ + {scope === 'existing' ? ( + <> +
+ + +
+ +
+ Session-Platz +
+ + +
+
+ + {slotMode === 'new_slot' ? ( +
+ + setInsertAt(e.target.value)} + /> +

+ Die Reihenfolge der Slots kannst du in der Rahmen-Bearbeitung jederzeit ändern (Ziehen oder + Pfeile). +

+
+ ) : ( +
+ + +
+ )} + + ) : ( + <> +
+ + setNewTitle(e.target.value)} + placeholder="z. B. Saisonvorbereitung" + required + /> +
+
+ + setNewGoalTitle(e.target.value)} + /> +
+
+ + +
+ {newVisibility === 'club' ? ( +
+ + +
+ ) : null} + + )} + +
+ + setSlotTitle(e.target.value)} + placeholder="z. B. Woche 3 — Technik" + /> +
+
+ +