diff --git a/backend/version.py b/backend/version.py index 597299b..b885c74 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.146" +APP_VERSION = "0.8.147" BUILD_DATE = "2026-05-19" DB_SCHEMA_VERSION = "20260516065" @@ -37,7 +37,12 @@ MODULE_VERSIONS = { CHANGELOG = [ { - "version": "0.8.146", + "version": "0.8.147", + "date": "2026-05-19", + "changes": [ + "Planung: Liste – Rahmen-Session & Übungen→Modul; Dialog Modul aus Einheit; klarere Rahmen-Unit-ID aus Liste", + ], + }, "date": "2026-05-19", "changes": [ "Planung: Trainingseinheit → Rahmenprogramm (Session-Slot) speichern; API POST /api/training-units/{id}/publish-to-framework", diff --git a/frontend/src/components/planning/SaveExercisesAsModuleModal.jsx b/frontend/src/components/planning/SaveExercisesAsModuleModal.jsx new file mode 100644 index 0000000..ad00fd1 --- /dev/null +++ b/frontend/src/components/planning/SaveExercisesAsModuleModal.jsx @@ -0,0 +1,326 @@ +import React, { useCallback, useEffect, useMemo, useState } 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' +import { collectExercisePlacementsForModule } from '../../utils/trainingPlanModuleFromUnit' + +/** + * Erstellt ein Trainingsmodul aus den Übungen einer gespeicherten Trainingseinheit (Mehrfachauswahl). + */ +export default function SaveExercisesAsModuleModal({ + 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 [loading, setLoading] = useState(false) + const [submitting, setSubmitting] = useState(false) + const [loadErr, setLoadErr] = useState('') + const [unitLabel, setUnitLabel] = useState('') + const [candidates, setCandidates] = useState([]) + const [selected, setSelected] = useState(() => []) + + const [title, setTitle] = useState('') + const [visibility, setVisibility] = useState('club') + const [clubId, setClubId] = useState('') + + const resetLocal = useCallback(() => { + setLoadErr('') + setUnitLabel('') + setCandidates([]) + setSelected([]) + setTitle('') + setVisibility('club') + setClubId('') + }, []) + + useEffect(() => { + if (!open || !unitId) { + resetLocal() + return + } + if (planningModalClubId != null && planningModalClubId !== '') { + setClubId(String(planningModalClubId)) + } else if (memberClubs.length === 1) { + setClubId(String(memberClubs[0].id)) + } + setLoading(true) + setLoadErr('') + api + .getTrainingUnit(unitId) + .then((u) => { + const dateStr = (u.planned_date || '').trim() || 'Training' + setUnitLabel(dateStr) + setTitle(`Modul · ${dateStr}`) + const c = collectExercisePlacementsForModule(u) + setCandidates(c) + setSelected(c.map(() => true)) + }) + .catch((e) => { + setLoadErr(e.message || 'Einheit konnte nicht geladen werden') + setCandidates([]) + setSelected([]) + }) + .finally(() => setLoading(false)) + }, [open, unitId, planningModalClubId, memberClubs.length, resetLocal]) + + const toggleOne = (idx) => { + setSelected((prev) => { + const next = [...prev] + next[idx] = !next[idx] + return next + }) + } + + const setAll = (on) => { + setSelected(candidates.map(() => on)) + } + + const selectedCount = selected.filter(Boolean).length + + const handleSubmit = async (e) => { + e.preventDefault() + if (!unitId || submitting) return + const itemsPayload = [] + let oi = 0 + for (let i = 0; i < candidates.length; i += 1) { + if (!selected[i]) continue + const c = candidates[i] + itemsPayload.push({ + item_type: 'exercise', + order_index: oi, + exercise_id: c.exercise_id, + exercise_variant_id: c.exercise_variant_id, + planned_duration_min: c.planned_duration_min, + notes: c.notes, + }) + oi += 1 + } + if (!itemsPayload.length) { + toast.error('Mindestens eine Übung auswählen.') + return + } + const tit = (title || '').trim() + if (!tit) { + toast.error('Bitte einen Modultitel angeben.') + return + } + let cid = visibility === 'club' && clubId ? parseInt(clubId, 10) : null + if (visibility === 'club' && (!Number.isFinite(cid) || cid < 1)) { + toast.error('Bitte einen Verein wählen (Sichtbarkeit „Verein“).') + return + } + if (visibility !== 'club') cid = null + + setSubmitting(true) + try { + const created = await api.createTrainingModule({ + title: tit, + visibility, + club_id: cid, + items: itemsPayload, + }) + toast.success('Trainingsmodul gespeichert.') + if (created?.id) { + navigate(`/planning/training-modules/${created.id}`) + } + onSuccess?.() + onClose() + } catch (err) { + toast.error(err.message || 'Speichern fehlgeschlagen') + } finally { + setSubmitting(false) + } + } + + if (!open) return null + + return ( +
+
+

Übungen als Trainingsmodul

+

+ Es werden die gespeicherten Übungspositionen der Einheit vom{' '} + {unitLabel || '…'} verwendet. Speichere die Planung vorher, wenn du den aktuellen Stand + brauchst. +

+ + {loading ? ( +

Laden …

+ ) : loadErr ? ( +

{loadErr}

+ ) : candidates.length === 0 ? ( +

In dieser Einheit sind keine Übungen im Ablauf hinterlegt.

+ ) : ( +
+
+ + setTitle(e.target.value)} + required + placeholder="z. B. Aufwärmsequenz" + /> +
+ +
+ + + + {selectedCount} von {candidates.length} gewählt (Reihenfolge wie im Plan) + +
+ +
+
    + {candidates.map((c, idx) => ( +
  • + toggleOne(idx)} + style={{ marginTop: 4 }} + /> +
    +
    + {c.exercise_title} +
    + {c.contextLabel ? ( +
    {c.contextLabel}
    + ) : null} +
    + {c.exercise_variant_id ? `Variante #${c.exercise_variant_id}` : 'Standard-Variante'} + {c.planned_duration_min != null && Number.isFinite(Number(c.planned_duration_min)) + ? ` · ${c.planned_duration_min} Min (Plan)` + : ''} +
    +
    +
  • + ))} +
+
+ +
+ + +
+ {visibility === 'club' ? ( +
+ + +
+ ) : null} + +
+ + +
+
+ )} + + {loading ? ( +
+ +
+ ) : null} + + {!loading && loadErr ? ( +
+ +
+ ) : null} + + {!loading && !loadErr && candidates.length === 0 ? ( +
+ +
+ ) : null} +
+
+ ) +} diff --git a/frontend/src/components/planning/TrainingPlanningPageRoot.jsx b/frontend/src/components/planning/TrainingPlanningPageRoot.jsx index f17d57d..29cd11b 100644 --- a/frontend/src/components/planning/TrainingPlanningPageRoot.jsx +++ b/frontend/src/components/planning/TrainingPlanningPageRoot.jsx @@ -12,6 +12,7 @@ import TrainingPlanningModuleApplyModal from './TrainingPlanningModuleApplyModal import TrainingPlanningTrainerAssignModal from './TrainingPlanningTrainerAssignModal' import TrainingPlanningUnitFormModal from './TrainingPlanningUnitFormModal' import TrainingPublishToFrameworkModal from './TrainingPublishToFrameworkModal' +import SaveExercisesAsModuleModal from './SaveExercisesAsModuleModal' /* Parallele Streams: Editor bleibt flache Abschnittsliste; `planLoc` pro Abschnitt steuert PUT `phases` vs. Legacy `sections`. */ import { defaultSection, @@ -53,6 +54,10 @@ function TrainingPlanningPageRoot() { const [showModal, setShowModal] = useState(false) const [editingUnit, setEditingUnit] = useState(null) const [publishFrameworkOpen, setPublishFrameworkOpen] = useState(false) + /** Einheit für „Rahmen-Session“-Dialog (Liste oder geöffnetes Bearbeiten) */ + const [publishFrameworkUnitId, setPublishFrameworkUnitId] = useState(null) + const [saveModuleOpen, setSaveModuleOpen] = useState(false) + const [saveModuleUnitId, setSaveModuleUnitId] = useState(null) /** Abschnitts-Editor bei Bearbeitung: Planung vs. Nachbereitung (Ist & Abweichungen) */ const [sectionsEditMode, setSectionsEditMode] = useState('planning') const [draftPlanTemplateId, setDraftPlanTemplateId] = useState('') @@ -1853,6 +1858,28 @@ function TrainingPlanningPageRoot() { + + {mayConfigureSessionAssignments(unit) ? ( ) : null} + {editingUnit?.id && !editingUnit?.framework_slot_id ? ( + + ) : null} } diff --git a/frontend/src/utils/trainingPlanModuleFromUnit.js b/frontend/src/utils/trainingPlanModuleFromUnit.js new file mode 100644 index 0000000..e96dcf8 --- /dev/null +++ b/frontend/src/utils/trainingPlanModuleFromUnit.js @@ -0,0 +1,60 @@ +/** + * Übungspositionen aus GET /api/training-units/:id für die Modul-Erstellung (Reihenfolge = Timeline). + * @param {object} unit – hydratisierte Trainingseinheit mit `phases` und/oder `sections` + * @returns {Array<{ exercise_id: number, exercise_variant_id: number|null, planned_duration_min: number|null, notes: string|null, exercise_title?: string, contextLabel: string }>} + */ +export function collectExercisePlacementsForModule(unit) { + if (!unit || typeof unit !== 'object') return [] + + const rows = [] + + const pushFromSection = (sec, ctx) => { + const st = (sec?.title || '').trim() + for (const it of sec?.items || []) { + if ((it?.item_type || 'exercise') !== 'exercise') continue + const eid = it.exercise_id + if (!eid) continue + const labelParts = [ctx, st].filter(Boolean) + rows.push({ + exercise_id: Number(eid), + exercise_variant_id: + it.exercise_variant_id != null && it.exercise_variant_id !== '' + ? Number(it.exercise_variant_id) + : null, + planned_duration_min: + it.planned_duration_min != null && it.planned_duration_min !== '' + ? Number(it.planned_duration_min) + : null, + notes: it.notes != null && String(it.notes).trim() ? String(it.notes).trim() : null, + exercise_title: (it.exercise_title || '').trim() || `Übung #${eid}`, + contextLabel: labelParts.length ? labelParts.join(' · ') : '', + }) + } + } + + const phases = Array.isArray(unit.phases) ? unit.phases : [] + for (const ph of phases) { + const pk = String(ph?.phase_kind || '').toLowerCase() + const phaseCtx = (ph?.title || '').trim() + if (pk === 'parallel') { + for (const st of ph.streams || []) { + const streamCtx = [phaseCtx, (st?.title || '').trim()].filter(Boolean).join(' · ') + for (const sec of st?.sections || []) { + pushFromSection(sec, streamCtx) + } + } + } else { + for (const sec of ph?.sections || []) { + pushFromSection(sec, phaseCtx) + } + } + } + + if (!rows.length && Array.isArray(unit.sections)) { + for (const sec of unit.sections) { + pushFromSection(sec, '') + } + } + + return rows +}