diff --git a/frontend/src/layouts/PlanningLayout.jsx b/frontend/src/layouts/PlanningLayout.jsx
index c11b1b5..1839a1b 100644
--- a/frontend/src/layouts/PlanningLayout.jsx
+++ b/frontend/src/layouts/PlanningLayout.jsx
@@ -1,11 +1,15 @@
-import { Outlet } from 'react-router-dom'
+import { Outlet, useLocation } from 'react-router-dom'
import PlanningRouteNav from '../components/planning/PlanningRouteNav'
+import { isPlanningUnitEditorPath } from '../utils/planningUnitRoutes'
/** Gemeinsame Hülle für Planung, Rahmenprogramme, Module und Vorlagen. */
export default function PlanningLayout() {
+ const { pathname } = useLocation()
+ const hideRouteNav = isPlanningUnitEditorPath(pathname)
+
return (
-
+ {!hideRouteNav ?
: null}
diff --git a/frontend/src/pages/TrainingUnitEditPage.jsx b/frontend/src/pages/TrainingUnitEditPage.jsx
index a44eaf8..45b0745 100644
--- a/frontend/src/pages/TrainingUnitEditPage.jsx
+++ b/frontend/src/pages/TrainingUnitEditPage.jsx
@@ -26,9 +26,12 @@ import {
createEmptyTrainingUnitFormData,
buildTrainingUnitSavePayload,
trainingUnitToFormFields,
+ trainingUnitFormSnapshot,
validateTrainingUnitFormForSave,
} from '../utils/trainingUnitEditorCore'
import PageFormEditorChrome from '../components/PageFormEditorChrome'
+import UnsavedChangesPrompt from '../components/UnsavedChangesPrompt'
+import { useBeforeUnloadWhen, useUnsavedChangesBlocker } from '../hooks/useUnsavedChangesBlocker'
import { buildPlanUnitEditPath, planningHubPathFromReturnState } from '../utils/planningUnitRoutes'
export default function TrainingUnitEditPage() {
@@ -54,6 +57,45 @@ export default function TrainingUnitEditPage() {
const formRef = useRef(formData)
formRef.current = formData
+ const baselineRef = useRef(null)
+ const [baselineReady, setBaselineReady] = useState(false)
+ const [bypassDirty, setBypassDirty] = useState(false)
+
+ const dirtySignature = trainingUnitFormSnapshot(formRef.current, {
+ editingUnit,
+ draftPlanTemplateId,
+ })
+
+ useEffect(() => {
+ baselineRef.current = null
+ setBaselineReady(false)
+ setBypassDirty(false)
+ }, [isNew, unitId])
+
+ useEffect(() => {
+ if (loading) return undefined
+ const handle = window.setTimeout(() => {
+ baselineRef.current = trainingUnitFormSnapshot(formRef.current, {
+ editingUnit,
+ draftPlanTemplateId,
+ })
+ setBaselineReady(true)
+ }, 120)
+ return () => clearTimeout(handle)
+ // Baseline nur nach initialem Laden — nicht bei Template-/Form-Änderungen im Editor
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- editingUnit/draftPlanTemplateId bewusst ausgeschlossen
+ }, [loading, isNew, unitId])
+
+ const formDirtyEffective =
+ baselineReady &&
+ baselineRef.current != null &&
+ !bypassDirty &&
+ !loading &&
+ dirtySignature !== baselineRef.current
+
+ const blocker = useUnsavedChangesBlocker(Boolean(formDirtyEffective && !saving))
+ useBeforeUnloadWhen(Boolean(formDirtyEffective && !saving))
+
const [exercisePickerOpen, setExercisePickerOpen] = useState(false)
const [exercisePickerTarget, setExercisePickerTarget] = useState(null)
const [planningPeekCtx, setPlanningPeekCtx] = useState(null)
@@ -400,10 +442,18 @@ export default function TrainingUnitEditPage() {
const reloadUnitAfterSave = useCallback(
async (savedId) => {
const fullUnit = await api.getTrainingUnit(savedId)
+ const nextDraftTemplateId = fullUnit.plan_template_id ? String(fullUnit.plan_template_id) : ''
setEditingUnit(fullUnit)
+ setDraftPlanTemplateId(nextDraftTemplateId)
let sections = normalizeUnitToForm(fullUnit)
sections = await enrichSectionsWithVariants(sections)
- setFormData(trainingUnitToFormFields(fullUnit, sections))
+ const nextForm = trainingUnitToFormFields(fullUnit, sections)
+ setFormData(nextForm)
+ baselineRef.current = trainingUnitFormSnapshot(nextForm, {
+ editingUnit: fullUnit,
+ draftPlanTemplateId: nextDraftTemplateId,
+ })
+ setBypassDirty(false)
if (!isNew && savedId !== unitId) {
navigate(buildPlanUnitEditPath(savedId), { replace: true, state: location.state })
}
@@ -448,6 +498,11 @@ export default function TrainingUnitEditPage() {
[formData, editingUnit, draftPlanTemplateId, toast, goBack, reloadUnitAfterSave]
)
+ const handleUnsavedDialogSave = async () => {
+ const ok = await handleSubmit(null, { closeAfter: false })
+ if (ok) blocker.proceed()
+ }
+
const actionConfig = useMemo(
() => ({
formId: 'planning-unit-form',
@@ -726,6 +781,14 @@ export default function TrainingUnitEditPage() {
peekExtras={planningPeekCtx?.peekExtras ?? undefined}
onClose={() => setPlanningPeekCtx(null)}
/>
+
+
setBypassDirty(true)}
+ detail="Du hast ungespeicherte Änderungen vorgenommen. Möchtest du die Seite wirklich verlassen?"
+ />
)
}
diff --git a/frontend/src/utils/planningUnitRoutes.js b/frontend/src/utils/planningUnitRoutes.js
index fa639ac..ba69613 100644
--- a/frontend/src/utils/planningUnitRoutes.js
+++ b/frontend/src/utils/planningUnitRoutes.js
@@ -64,6 +64,13 @@ export function buildPlanningHubReturnState(hubState = {}) {
}
}
+/** @param {string} pathname */
+export function isPlanningUnitEditorPath(pathname) {
+ if (!pathname) return false
+ if (pathname === '/planning/units/new') return true
+ return /^\/planning\/units\/\d+\/edit$/.test(pathname)
+}
+
/** @param {ReturnType|null|undefined} state */
export function planningHubPathFromReturnState(state) {
if (!state || state.v !== 1) return PLANNING_HUB_PATH
diff --git a/frontend/src/utils/planningUnitRoutes.test.js b/frontend/src/utils/planningUnitRoutes.test.js
index 5df40e8..5b738ee 100644
--- a/frontend/src/utils/planningUnitRoutes.test.js
+++ b/frontend/src/utils/planningUnitRoutes.test.js
@@ -3,6 +3,7 @@ import {
buildPlanUnitEditPath,
buildPlanUnitNewPath,
buildPlanningHubReturnState,
+ isPlanningUnitEditorPath,
legacyPlanningUnitDeepLinkTarget,
parsePlanningHubQuery,
planningHubPathFromReturnState,
@@ -22,6 +23,13 @@ describe('planningUnitRoutes', () => {
expect(buildPlanUnitNewPath()).toBe('/planning/units/new')
})
+ it('isPlanningUnitEditorPath', () => {
+ expect(isPlanningUnitEditorPath('/planning/units/new')).toBe(true)
+ expect(isPlanningUnitEditorPath('/planning/units/42/edit')).toBe(true)
+ expect(isPlanningUnitEditorPath('/planning')).toBe(false)
+ expect(isPlanningUnitEditorPath('/planning/training-modules')).toBe(false)
+ })
+
it('legacyPlanningUnitDeepLinkTarget', () => {
expect(legacyPlanningUnitDeepLinkTarget('unit=5')).toBe('/planning/units/5/edit')
expect(legacyPlanningUnitDeepLinkTarget('unit=5&debrief=1')).toBe(
diff --git a/frontend/src/utils/trainingUnitEditorCore.js b/frontend/src/utils/trainingUnitEditorCore.js
index 9b05e53..a03a905 100644
--- a/frontend/src/utils/trainingUnitEditorCore.js
+++ b/frontend/src/utils/trainingUnitEditorCore.js
@@ -131,3 +131,11 @@ export function validateTrainingUnitFormForSave(formData) {
}
return { ok: true }
}
+
+/** Stabiler Fingerabdruck für Dirty-Check (identisch zum Save-Payload). */
+export function trainingUnitFormSnapshot(
+ formData,
+ { editingUnit = null, draftPlanTemplateId = '' } = {}
+) {
+ return JSON.stringify(buildTrainingUnitSavePayload(formData, { editingUnit, draftPlanTemplateId }))
+}
diff --git a/frontend/src/utils/trainingUnitEditorCore.test.js b/frontend/src/utils/trainingUnitEditorCore.test.js
index 3866e25..d320900 100644
--- a/frontend/src/utils/trainingUnitEditorCore.test.js
+++ b/frontend/src/utils/trainingUnitEditorCore.test.js
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
buildTrainingUnitSavePayload,
createEmptyTrainingUnitFormData,
+ trainingUnitFormSnapshot,
validateTrainingUnitFormForSave,
} from './trainingUnitEditorCore.js'
@@ -42,4 +43,11 @@ describe('trainingUnitEditorCore', () => {
expect(payload.assistant_trainer_profile_ids).toBeNull()
expect(payload.group_id).toBeUndefined()
})
+
+ it('trainingUnitFormSnapshot matches save payload', () => {
+ const formData = createEmptyTrainingUnitFormData({ groupId: '5', plannedDate: '2026-05-10' })
+ const snap = trainingUnitFormSnapshot(formData, { draftPlanTemplateId: '3' })
+ const payload = buildTrainingUnitSavePayload(formData, { draftPlanTemplateId: '3' })
+ expect(snap).toBe(JSON.stringify(payload))
+ })
})