develop #38

Merged
Lars merged 14 commits from develop into main 2026-05-19 14:56:42 +02:00
6 changed files with 101 additions and 3 deletions
Showing only changes of commit cb868373f4 - Show all commits

View File

@ -1,11 +1,15 @@
import { Outlet } from 'react-router-dom' import { Outlet, useLocation } from 'react-router-dom'
import PlanningRouteNav from '../components/planning/PlanningRouteNav' import PlanningRouteNav from '../components/planning/PlanningRouteNav'
import { isPlanningUnitEditorPath } from '../utils/planningUnitRoutes'
/** Gemeinsame Hülle für Planung, Rahmenprogramme, Module und Vorlagen. */ /** Gemeinsame Hülle für Planung, Rahmenprogramme, Module und Vorlagen. */
export default function PlanningLayout() { export default function PlanningLayout() {
const { pathname } = useLocation()
const hideRouteNav = isPlanningUnitEditorPath(pathname)
return ( return (
<div className="app-page planning-layout"> <div className="app-page planning-layout">
<PlanningRouteNav /> {!hideRouteNav ? <PlanningRouteNav /> : null}
<div className="planning-layout__main"> <div className="planning-layout__main">
<Outlet /> <Outlet />
</div> </div>

View File

@ -26,9 +26,12 @@ import {
createEmptyTrainingUnitFormData, createEmptyTrainingUnitFormData,
buildTrainingUnitSavePayload, buildTrainingUnitSavePayload,
trainingUnitToFormFields, trainingUnitToFormFields,
trainingUnitFormSnapshot,
validateTrainingUnitFormForSave, validateTrainingUnitFormForSave,
} from '../utils/trainingUnitEditorCore' } from '../utils/trainingUnitEditorCore'
import PageFormEditorChrome from '../components/PageFormEditorChrome' import PageFormEditorChrome from '../components/PageFormEditorChrome'
import UnsavedChangesPrompt from '../components/UnsavedChangesPrompt'
import { useBeforeUnloadWhen, useUnsavedChangesBlocker } from '../hooks/useUnsavedChangesBlocker'
import { buildPlanUnitEditPath, planningHubPathFromReturnState } from '../utils/planningUnitRoutes' import { buildPlanUnitEditPath, planningHubPathFromReturnState } from '../utils/planningUnitRoutes'
export default function TrainingUnitEditPage() { export default function TrainingUnitEditPage() {
@ -54,6 +57,45 @@ export default function TrainingUnitEditPage() {
const formRef = useRef(formData) const formRef = useRef(formData)
formRef.current = 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 [exercisePickerOpen, setExercisePickerOpen] = useState(false)
const [exercisePickerTarget, setExercisePickerTarget] = useState(null) const [exercisePickerTarget, setExercisePickerTarget] = useState(null)
const [planningPeekCtx, setPlanningPeekCtx] = useState(null) const [planningPeekCtx, setPlanningPeekCtx] = useState(null)
@ -400,10 +442,18 @@ export default function TrainingUnitEditPage() {
const reloadUnitAfterSave = useCallback( const reloadUnitAfterSave = useCallback(
async (savedId) => { async (savedId) => {
const fullUnit = await api.getTrainingUnit(savedId) const fullUnit = await api.getTrainingUnit(savedId)
const nextDraftTemplateId = fullUnit.plan_template_id ? String(fullUnit.plan_template_id) : ''
setEditingUnit(fullUnit) setEditingUnit(fullUnit)
setDraftPlanTemplateId(nextDraftTemplateId)
let sections = normalizeUnitToForm(fullUnit) let sections = normalizeUnitToForm(fullUnit)
sections = await enrichSectionsWithVariants(sections) 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) { if (!isNew && savedId !== unitId) {
navigate(buildPlanUnitEditPath(savedId), { replace: true, state: location.state }) navigate(buildPlanUnitEditPath(savedId), { replace: true, state: location.state })
} }
@ -448,6 +498,11 @@ export default function TrainingUnitEditPage() {
[formData, editingUnit, draftPlanTemplateId, toast, goBack, reloadUnitAfterSave] [formData, editingUnit, draftPlanTemplateId, toast, goBack, reloadUnitAfterSave]
) )
const handleUnsavedDialogSave = async () => {
const ok = await handleSubmit(null, { closeAfter: false })
if (ok) blocker.proceed()
}
const actionConfig = useMemo( const actionConfig = useMemo(
() => ({ () => ({
formId: 'planning-unit-form', formId: 'planning-unit-form',
@ -726,6 +781,14 @@ export default function TrainingUnitEditPage() {
peekExtras={planningPeekCtx?.peekExtras ?? undefined} peekExtras={planningPeekCtx?.peekExtras ?? undefined}
onClose={() => setPlanningPeekCtx(null)} onClose={() => setPlanningPeekCtx(null)}
/> />
<UnsavedChangesPrompt
blocker={blocker}
isBusy={saving}
onSave={handleUnsavedDialogSave}
onDiscardWithoutSave={() => setBypassDirty(true)}
detail="Du hast ungespeicherte Änderungen vorgenommen. Möchtest du die Seite wirklich verlassen?"
/>
</PageFormEditorChrome> </PageFormEditorChrome>
) )
} }

View File

@ -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<typeof buildPlanningHubReturnState>|null|undefined} state */ /** @param {ReturnType<typeof buildPlanningHubReturnState>|null|undefined} state */
export function planningHubPathFromReturnState(state) { export function planningHubPathFromReturnState(state) {
if (!state || state.v !== 1) return PLANNING_HUB_PATH if (!state || state.v !== 1) return PLANNING_HUB_PATH

View File

@ -3,6 +3,7 @@ import {
buildPlanUnitEditPath, buildPlanUnitEditPath,
buildPlanUnitNewPath, buildPlanUnitNewPath,
buildPlanningHubReturnState, buildPlanningHubReturnState,
isPlanningUnitEditorPath,
legacyPlanningUnitDeepLinkTarget, legacyPlanningUnitDeepLinkTarget,
parsePlanningHubQuery, parsePlanningHubQuery,
planningHubPathFromReturnState, planningHubPathFromReturnState,
@ -22,6 +23,13 @@ describe('planningUnitRoutes', () => {
expect(buildPlanUnitNewPath()).toBe('/planning/units/new') 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', () => { it('legacyPlanningUnitDeepLinkTarget', () => {
expect(legacyPlanningUnitDeepLinkTarget('unit=5')).toBe('/planning/units/5/edit') expect(legacyPlanningUnitDeepLinkTarget('unit=5')).toBe('/planning/units/5/edit')
expect(legacyPlanningUnitDeepLinkTarget('unit=5&debrief=1')).toBe( expect(legacyPlanningUnitDeepLinkTarget('unit=5&debrief=1')).toBe(

View File

@ -131,3 +131,11 @@ export function validateTrainingUnitFormForSave(formData) {
} }
return { ok: true } 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 }))
}

View File

@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import { import {
buildTrainingUnitSavePayload, buildTrainingUnitSavePayload,
createEmptyTrainingUnitFormData, createEmptyTrainingUnitFormData,
trainingUnitFormSnapshot,
validateTrainingUnitFormForSave, validateTrainingUnitFormForSave,
} from './trainingUnitEditorCore.js' } from './trainingUnitEditorCore.js'
@ -42,4 +43,11 @@ describe('trainingUnitEditorCore', () => {
expect(payload.assistant_trainer_profile_ids).toBeNull() expect(payload.assistant_trainer_profile_ids).toBeNull()
expect(payload.group_id).toBeUndefined() 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))
})
}) })