import { Result, assertNever } from '@shared/kernel/fp'; import { Uren, parseUren } from '@registratie/domain/value-objects/uren'; import { UploadState, UploadMsg, initialUpload, reduceUpload, requiredCategoriesSatisfied, deliveryRefs, } from '@shared/upload/upload.machine'; /** What the user is typing (raw, possibly invalid). */ export interface Draft { uren: string; jaren: string; punten: string; } export type StepErrors = Partial>; /** What we have AFTER parsing — branded/typed, guaranteed valid. */ export interface Valid { uren: Uren; jaren: number; punten: number; documents: Array<{ categoryId: string; channel: 'digital' | 'post'; documentId?: string }>; } /** * The whole wizard as one tagged union. `step` and `errors` exist ONLY while * Editing; Submitting/Submitted/Failed carry a `Valid` payload and nothing else. * So "submitting while a field is invalid" or "showing the success screen with * errors set" are unrepresentable — the bug class is gone by construction. */ export type WizardState = | { tag: 'Editing'; step: 1 | 2 | 3; draft: Draft; errors: StepErrors; upload: UploadState } | { tag: 'Submitting'; data: Valid } | { tag: 'Submitted'; data: Valid } | { tag: 'Failed'; data: Valid; error: string }; export const initial: WizardState = { tag: 'Editing', step: 1, draft: { uren: '', jaren: '', punten: '' }, errors: {}, upload: initialUpload, }; /** Has the user meaningfully started, so it's worth persisting as a Concept? */ export function hasProgress(s: Extract): boolean { return ( s.step > 1 || !!s.draft.uren || !!s.draft.jaren || !!s.draft.punten || deliveryRefs(s.upload).some((r) => r.channel === 'digital' && !!r.documentId) ); } /** Parse every field; on success hand back a Valid, else the per-field errors. */ function validate(draft: Draft, upload: UploadState): Result { const uren = parseUren(draft.uren); const jaren = parseUren(draft.jaren); const punten = parseUren(draft.punten); const errors: StepErrors = {}; if (!uren.ok) errors.uren = uren.error; if (!jaren.ok) errors.jaren = jaren.error; if (!punten.ok) errors.punten = punten.error; if (!requiredCategoriesSatisfied(upload)) { errors.documenten = $localize`:@@validation.documenten:Lever de verplichte documenten aan (upload of kies "per post nasturen").`; } if (uren.ok && jaren.ok && punten.ok && !errors.documenten) { return { ok: true, value: { uren: uren.value, jaren: jaren.value, punten: punten.value, documents: deliveryRefs(upload), }, }; } return { ok: false, error: errors }; } /** Advance one step, gating on that step's fields. Illegal elsewhere = no-op. */ export function next(s: WizardState): WizardState { if (s.tag !== 'Editing') return s; const errors: StepErrors = {}; if (s.step === 1) { const uren = parseUren(s.draft.uren); const jaren = parseUren(s.draft.jaren); if (!uren.ok) errors.uren = uren.error; if (!jaren.ok) errors.jaren = jaren.error; return Object.keys(errors).length === 0 ? { ...s, step: 2, errors: {} } : { ...s, errors }; } if (s.step === 2) { const punten = parseUren(s.draft.punten); if (!punten.ok) errors.punten = punten.error; return punten.ok ? { ...s, step: 3, errors: {} } : { ...s, errors }; } return s; } export function back(s: WizardState): WizardState { if (s.tag !== 'Editing' || s.step === 1) return s; return { ...s, step: (s.step - 1) as 1 | 2, errors: {} }; } /** Jump back to an earlier step to correct data (controle → step N). Forward jumps are not allowed (would skip validation). */ export function gaNaarStap(s: WizardState, step: 1 | 2 | 3): WizardState { if (s.tag !== 'Editing' || step >= s.step) return s; return { ...s, step, errors: {} }; } /** Step 3 submit: parse everything + require documents; Submitting only with Valid. */ export function submit(s: WizardState): WizardState { if (s.tag !== 'Editing' || s.step !== 3) return s; const result = validate(s.draft, s.upload); return result.ok ? { tag: 'Submitting', data: result.value } : { ...s, errors: result.error }; } /** Route an upload sub-message through the pure upload reducer (Editing only). */ export function upload(s: WizardState, msg: UploadMsg): WizardState { if (s.tag !== 'Editing') return s; return { ...s, upload: reduceUpload(s.upload, msg) }; } /** Resolve the async submit. Only meaningful while Submitting. */ export function resolve(s: WizardState, r: Result): WizardState { if (s.tag !== 'Submitting') return s; return r.ok ? { tag: 'Submitted', data: s.data } : { tag: 'Failed', data: s.data, error: r.error }; } /** Update one draft field while editing; ignored in any other state. */ export function setField(s: WizardState, key: keyof Draft, value: string): WizardState { if (s.tag !== 'Editing') return s; return { ...s, draft: { ...s.draft, [key]: value } }; } /** * Every event that can happen to the wizard, as one message type. The component * sends a WizardMsg; `reduce` decides the next state. This is the Elm * Model+Msg+update pattern: ONE pure function describes all state changes. */ export type WizardMsg = | { tag: 'SetField'; key: keyof Draft; value: string } | { tag: 'Next' } | { tag: 'Back' } | { tag: 'GaNaarStap'; step: 1 | 2 | 3 } | { tag: 'Submit' } | { tag: 'Retry' } | { tag: 'SubmitConfirmed' } | { tag: 'SubmitFailed'; error: string } | { tag: 'Upload'; msg: UploadMsg } | { tag: 'Seed'; state: WizardState }; // mount a specific state (stories/showcase) export function reduce(s: WizardState, m: WizardMsg): WizardState { switch (m.tag) { case 'SetField': return setField(s, m.key, m.value); case 'Next': return next(s); case 'Back': return back(s); case 'GaNaarStap': return gaNaarStap(s, m.step); case 'Submit': return submit(s); case 'Retry': return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s; case 'SubmitConfirmed': return s.tag === 'Submitting' ? { tag: 'Submitted', data: s.data } : s; case 'SubmitFailed': return s.tag === 'Submitting' ? { tag: 'Failed', data: s.data, error: m.error } : s; case 'Upload': return upload(s, m.msg); case 'Seed': return m.state; default: return assertNever(m); } }