RD-22 and RD-23 brought the wizard containers under the 250-line budget, so `max-lines` reports nothing. The files still read badly. Line count was never the problem. Fix three things in all three containers: 1. The member order was scrambled, and it differed per file. `registratie` declared `draftSync` in the middle of a run of `computed`s; `herregistratie` read `this.stepLabels.length` seven lines before `stepLabels` existed; the three files put the copy arrays in three different places. All three now use one nine-section order, so they compare side by side. 2. Pure logic sat in the container. Extract `digitalDocumentIds` into `upload.machine.ts` — the "digital and finished uploading" filter was written out four times, and it removes a `documentId!` assertion from both containers. Extract `diplomaMsg` into a sibling of the step files. 3. Comments carried archaeology. Drop the three RD-05 references and keep the rule. Drop "replaces sessionStorage" and the note about focus management that moved to the shell. Fix `intake`'s class comment, which claimed answers persist to sessionStorage and was contradicted 30 lines below. `phase` deliberately stays in all three: it cannot live in `domain/`, and three siblings plus three specs is a worse trade than 17 readable lines. The store ⇄ `draftSync` cycle also stays — both callbacks are deferred, so it is safe, and one comment now names it. No behaviour change. Member lists and every `private`/`protected`/`readonly` modifier are unchanged, which the showcase depends on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
372 lines
14 KiB
TypeScript
372 lines
14 KiB
TypeScript
import { Result, ok, err, assertNever } from '@shared/kernel/fp';
|
|
import { Postcode, parsePostcode } from '@registratie/domain/value-objects/postcode';
|
|
import { Email, parseEmail } from '@registratie/domain/value-objects/email';
|
|
import {
|
|
UploadState,
|
|
UploadMsg,
|
|
DeliveryChannel,
|
|
initialUpload,
|
|
reduceUpload,
|
|
requiredCategoriesSatisfied,
|
|
deliveryRefs,
|
|
digitalDocumentIds,
|
|
} from '@shared/domain/upload.machine';
|
|
|
|
/**
|
|
* A FIXED 3-step registration wizard. The steps never change in number (always
|
|
* `STEPS`): (1) adres + correspondentievoorkeur, (2) beroep o.b.v. diploma,
|
|
* (3) controle & indienen. Follow-up questions appear *inline within a step*
|
|
* (e.g. choosing 'email' reveals the e-mail field). "Is this field required
|
|
* right now" is a pure function (`validateStep`), so it is trivial to test and
|
|
* impossible to get out of sync with the data. Invariants live here, not in the
|
|
* UI: the wizard reaches `Indienen` only when a complete `ValidRegistratie` parses.
|
|
*/
|
|
|
|
export type StepId = 'adres' | 'beroep' | 'controle';
|
|
|
|
/** The fixed step list. Number of steps never changes; questions reveal inline. */
|
|
export const STEPS: StepId[] = ['adres', 'beroep', 'controle'];
|
|
|
|
/** Where a piece of data came from — recorded on the aggregate (PRD §5). */
|
|
export type AdresHerkomst = 'brp' | 'handmatig';
|
|
export type DiplomaHerkomst = 'duo' | 'handmatig';
|
|
export type Correspondentie = 'email' | 'post';
|
|
|
|
/** One record carried across every step (and persisted). All optional: the user
|
|
fills it in gradually. Adres fields are kept flat so one `SetField` message
|
|
serves them all (mirrors the intake machine). */
|
|
export interface Draft {
|
|
straat?: string;
|
|
postcode?: string;
|
|
woonplaats?: string;
|
|
adresHerkomst?: AdresHerkomst;
|
|
correspondentie?: Correspondentie;
|
|
email?: string;
|
|
diplomaId?: string;
|
|
diplomaHerkomst?: DiplomaHerkomst;
|
|
beroep?: string; // DERIVED from the chosen DUO diploma (or declared for a manual one)
|
|
vraagIds?: string[]; // ids of the policy questions that apply to the chosen diploma
|
|
antwoorden: Record<string, string>; // geldigheidsantwoorden, keyed by question id
|
|
}
|
|
|
|
/** What we have after the controle step parses — guaranteed valid/typed. */
|
|
export interface ValidRegistratie {
|
|
adres: { straat: string; postcode: Postcode; woonplaats: string };
|
|
adresHerkomst: AdresHerkomst;
|
|
correspondentie: Correspondentie;
|
|
email?: Email; // only when correspondentie === 'email'
|
|
diplomaId: string;
|
|
diplomaHerkomst: DiplomaHerkomst;
|
|
beroep: string;
|
|
antwoorden: Record<string, string>;
|
|
documents: Array<{ categoryId: string; channel: DeliveryChannel; documentId?: string }>;
|
|
}
|
|
|
|
/** Text fields settable via SetField. */
|
|
export type DraftField = 'straat' | 'postcode' | 'woonplaats' | 'email';
|
|
|
|
/** Per-field error map. `antwoorden` holds per-policy-question errors, keyed by
|
|
question id (a step can show several questions). */
|
|
export type Errors = {
|
|
straat?: string;
|
|
postcode?: string;
|
|
woonplaats?: string;
|
|
email?: string;
|
|
correspondentie?: string;
|
|
diploma?: string;
|
|
documenten?: string;
|
|
antwoorden?: Record<string, string>;
|
|
};
|
|
|
|
export type RegistratieState =
|
|
| { tag: 'Invullen'; draft: Draft; cursor: number; errors: Errors; upload: UploadState }
|
|
| { tag: 'Indienen'; data: ValidRegistratie }
|
|
| { tag: 'Ingediend'; data: ValidRegistratie; referentie: string }
|
|
| { tag: 'Mislukt'; data: ValidRegistratie; error: string };
|
|
|
|
const emptyDraft: Draft = { antwoorden: {} };
|
|
export const initial: RegistratieState = {
|
|
tag: 'Invullen',
|
|
draft: emptyDraft,
|
|
cursor: 0,
|
|
errors: {},
|
|
upload: initialUpload,
|
|
};
|
|
|
|
/** Which step the cursor currently points at (clamped to the fixed list). */
|
|
export function currentStep(s: Extract<RegistratieState, { tag: 'Invullen' }>): StepId {
|
|
return STEPS[Math.min(s.cursor, STEPS.length - 1)];
|
|
}
|
|
|
|
/** Has the user meaningfully started, so it's worth persisting as a Concept? Excludes
|
|
the automatic BRP address prefill on step 0 — a bare page visit creates nothing.
|
|
ponytail: an address typed at step 0 without any of these signals is not yet
|
|
persisted (created once they advance/choose); accepted regression vs. sessionStorage. */
|
|
export function hasProgress(s: Extract<RegistratieState, { tag: 'Invullen' }>): boolean {
|
|
const d = s.draft;
|
|
return (
|
|
s.cursor > 0 ||
|
|
!!d.correspondentie ||
|
|
!!d.email ||
|
|
!!d.diplomaId ||
|
|
!!d.beroep ||
|
|
digitalDocumentIds(s.upload).length > 0
|
|
);
|
|
}
|
|
|
|
/** Validate every question currently visible in ONE step. Errors keyed per field. */
|
|
function validateStep(step: StepId, d: Draft, upload: UploadState): Result<Errors, void> {
|
|
const errors: Errors = {};
|
|
switch (step) {
|
|
case 'adres': {
|
|
if (!d.straat || d.straat.trim() === '')
|
|
errors.straat = $localize`:@@validation.straat2:Vul een straat en huisnummer in.`;
|
|
const pc = parsePostcode(d.postcode ?? '');
|
|
if (!pc.ok) errors.postcode = pc.error;
|
|
if (!d.woonplaats || d.woonplaats.trim() === '')
|
|
errors.woonplaats = $localize`:@@validation.woonplaats:Vul een woonplaats in.`;
|
|
if (!d.correspondentie)
|
|
errors.correspondentie = $localize`:@@validation.maakKeuze:Maak een keuze.`;
|
|
// E-mail is only required when 'email' is the chosen channel.
|
|
if (d.correspondentie === 'email') {
|
|
const e = parseEmail(d.email ?? '');
|
|
if (!e.ok) errors.email = e.error;
|
|
}
|
|
break;
|
|
}
|
|
case 'beroep': {
|
|
// A diploma must be chosen (or declared manually); its beroep is then known.
|
|
if (!d.diplomaId || !d.beroep) {
|
|
errors.diploma = $localize`:@@validation.diploma:Kies het diploma waarmee u zich wilt registreren, of voer het handmatig in.`;
|
|
break;
|
|
}
|
|
// Every policy question the chosen diploma raised must be answered. Which
|
|
// questions apply is server-decided (carried in `vraagIds`); we only check
|
|
// they're answered.
|
|
const open: Record<string, string> = {};
|
|
for (const id of d.vraagIds ?? []) {
|
|
if (!(d.antwoorden[id] ?? '').trim())
|
|
open[id] = $localize`:@@validation.beantwoordVraag:Beantwoord deze vraag.`;
|
|
}
|
|
if (Object.keys(open).length > 0) errors.antwoorden = open;
|
|
// Required documents for this wizard attach to the beroep step (inline upload).
|
|
if (!requiredCategoriesSatisfied(upload)) {
|
|
errors.documenten = $localize`:@@validation.documenten:Lever de verplichte documenten aan (upload of kies "per post nasturen").`;
|
|
}
|
|
break;
|
|
}
|
|
case 'controle':
|
|
break; // controle shows a summary; no own fields
|
|
default:
|
|
return assertNever(step);
|
|
}
|
|
return Object.keys(errors).length > 0 ? err(errors) : ok(undefined);
|
|
}
|
|
|
|
/** Parse the whole wizard into a ValidRegistratie (called on submit). */
|
|
function validateAll(d: Draft, upload: UploadState): Result<Errors, ValidRegistratie> {
|
|
const errors: Errors = {};
|
|
for (const step of STEPS) {
|
|
const r = validateStep(step, d, upload);
|
|
if (!r.ok) Object.assign(errors, r.error);
|
|
}
|
|
if (Object.keys(errors).length > 0) return err(errors);
|
|
|
|
const pc = parsePostcode(d.postcode ?? '');
|
|
// validateStep guaranteed these parse, but keep the compiler happy.
|
|
if (!pc.ok || !d.diplomaId || !d.beroep || !d.correspondentie) return err(errors);
|
|
const email = d.correspondentie === 'email' ? parseEmail(d.email ?? '') : undefined;
|
|
// Keep only the answers to the questions that actually applied.
|
|
const vraagIds = d.vraagIds ?? [];
|
|
const antwoorden = Object.fromEntries(vraagIds.map((id) => [id, d.antwoorden[id] ?? '']));
|
|
|
|
return ok({
|
|
adres: { straat: d.straat!, postcode: pc.value, woonplaats: d.woonplaats! },
|
|
adresHerkomst: d.adresHerkomst ?? 'handmatig',
|
|
correspondentie: d.correspondentie,
|
|
email: email?.ok ? email.value : undefined,
|
|
diplomaId: d.diplomaId,
|
|
diplomaHerkomst: d.diplomaHerkomst ?? 'handmatig',
|
|
beroep: d.beroep,
|
|
antwoorden,
|
|
documents: deliveryRefs(upload),
|
|
});
|
|
}
|
|
|
|
export function setField(s: RegistratieState, key: DraftField, value: string): RegistratieState {
|
|
if (s.tag !== 'Invullen') return s;
|
|
const draft: Draft = { ...s.draft, [key]: value };
|
|
// Editing an address field means the user owns it now — not the BRP copy.
|
|
if (key === 'straat' || key === 'postcode' || key === 'woonplaats')
|
|
draft.adresHerkomst = 'handmatig';
|
|
return { ...s, draft };
|
|
}
|
|
|
|
export function setCorrespondentie(s: RegistratieState, value: Correspondentie): RegistratieState {
|
|
if (s.tag !== 'Invullen') return s;
|
|
return { ...s, draft: { ...s.draft, correspondentie: value } };
|
|
}
|
|
|
|
/** Prefill the address from a BRP lookup and flag its origin (PRD §7). */
|
|
export function prefillAdres(
|
|
s: RegistratieState,
|
|
straat: string,
|
|
postcode: string,
|
|
woonplaats: string,
|
|
): RegistratieState {
|
|
if (s.tag !== 'Invullen') return s;
|
|
return { ...s, draft: { ...s.draft, straat, postcode, woonplaats, adresHerkomst: 'brp' } };
|
|
}
|
|
|
|
/** Pick a DUO diploma; the beroep is derived from it and the applicable policy
|
|
questions (`vraagIds`) come with it (both server-computed, passed in). */
|
|
export function kiesDiploma(
|
|
s: RegistratieState,
|
|
diplomaId: string,
|
|
beroep: string,
|
|
vraagIds: string[],
|
|
): RegistratieState {
|
|
if (s.tag !== 'Invullen') return s;
|
|
return {
|
|
...s,
|
|
draft: { ...s.draft, diplomaId, beroep, vraagIds, diplomaHerkomst: 'duo' },
|
|
errors: {},
|
|
};
|
|
}
|
|
|
|
/** Switch to manual diploma entry: the diploma isn't in DUO, so the MAXIMAL
|
|
policy-question set applies and the entry is flagged handmatig/unverified. The
|
|
beroep is declared separately (declareerBeroep). */
|
|
export function kiesHandmatig(s: RegistratieState, vraagIds: string[]): RegistratieState {
|
|
if (s.tag !== 'Invullen') return s;
|
|
return {
|
|
...s,
|
|
draft: {
|
|
...s.draft,
|
|
diplomaId: 'handmatig',
|
|
beroep: undefined,
|
|
vraagIds,
|
|
diplomaHerkomst: 'handmatig',
|
|
},
|
|
errors: {},
|
|
};
|
|
}
|
|
|
|
/** Declare the beroep for a manually-entered diploma (chosen from a fixed list). */
|
|
export function declareerBeroep(s: RegistratieState, beroep: string): RegistratieState {
|
|
if (s.tag !== 'Invullen') return s;
|
|
return { ...s, draft: { ...s.draft, beroep } };
|
|
}
|
|
|
|
export function setAntwoord(s: RegistratieState, vraagId: string, value: string): RegistratieState {
|
|
if (s.tag !== 'Invullen') return s;
|
|
return { ...s, draft: { ...s.draft, antwoorden: { ...s.draft.antwoorden, [vraagId]: value } } };
|
|
}
|
|
|
|
export function next(s: RegistratieState): RegistratieState {
|
|
if (s.tag !== 'Invullen') return s;
|
|
const r = validateStep(currentStep(s), s.draft, s.upload);
|
|
if (!r.ok) return { ...s, errors: r.error };
|
|
return { ...s, cursor: Math.min(s.cursor + 1, STEPS.length - 1), errors: {} };
|
|
}
|
|
|
|
export function back(s: RegistratieState): RegistratieState {
|
|
if (s.tag !== 'Invullen' || s.cursor === 0) return s;
|
|
return { ...s, cursor: s.cursor - 1, errors: {} };
|
|
}
|
|
|
|
/** Jump back to an earlier step to correct data (controle → step N). Forward
|
|
jumps are not allowed (would skip validation). Preserves the draft. */
|
|
export function gaNaarStap(s: RegistratieState, cursor: number): RegistratieState {
|
|
if (s.tag !== 'Invullen' || cursor < 0 || cursor >= s.cursor) return s;
|
|
return { ...s, cursor, errors: {} };
|
|
}
|
|
|
|
export function submit(s: RegistratieState): RegistratieState {
|
|
if (s.tag !== 'Invullen') return s;
|
|
const r = validateAll(s.draft, s.upload);
|
|
return r.ok ? { tag: 'Indienen', data: r.value } : { ...s, errors: r.error };
|
|
}
|
|
|
|
/** The primary button's action: advance, or submit from the controle step.
|
|
No-op outside Invullen — this is the one decision the old component-side handler used to make. */
|
|
export function primary(s: RegistratieState): RegistratieState {
|
|
if (s.tag !== 'Invullen') return s;
|
|
return currentStep(s) === 'controle' ? submit(s) : next(s);
|
|
}
|
|
|
|
/** Route an upload sub-message through the pure upload reducer (Invullen only). */
|
|
export function upload(s: RegistratieState, msg: UploadMsg): RegistratieState {
|
|
if (s.tag !== 'Invullen') return s;
|
|
return { ...s, upload: reduceUpload(s.upload, msg) };
|
|
}
|
|
|
|
export function resolve(s: RegistratieState, r: Result<string, string>): RegistratieState {
|
|
if (s.tag !== 'Indienen') return s;
|
|
return r.ok
|
|
? { tag: 'Ingediend', data: s.data, referentie: r.value }
|
|
: { tag: 'Mislukt', data: s.data, error: r.error };
|
|
}
|
|
|
|
export type RegistratieMsg =
|
|
| { tag: 'SetField'; key: DraftField; value: string }
|
|
| { tag: 'SetCorrespondentie'; value: Correspondentie }
|
|
| { tag: 'PrefillAdres'; straat: string; postcode: string; woonplaats: string }
|
|
| { tag: 'KiesDiploma'; diplomaId: string; beroep: string; vraagIds: string[] }
|
|
| { tag: 'KiesHandmatig'; vraagIds: string[] }
|
|
| { tag: 'DeclareerBeroep'; beroep: string }
|
|
| { tag: 'SetAntwoord'; vraagId: string; value: string }
|
|
| { tag: 'Next' }
|
|
| { tag: 'Back' }
|
|
| { tag: 'GaNaarStap'; cursor: number }
|
|
| { tag: 'Submit' }
|
|
| { tag: 'Primary' }
|
|
| { tag: 'Retry' }
|
|
| { tag: 'SubmitConfirmed'; referentie: string }
|
|
| { tag: 'SubmitFailed'; error: string }
|
|
| { tag: 'Upload'; msg: UploadMsg }
|
|
| { tag: 'Seed'; state: RegistratieState };
|
|
|
|
export function reduce(s: RegistratieState, m: RegistratieMsg): RegistratieState {
|
|
switch (m.tag) {
|
|
case 'SetField':
|
|
return setField(s, m.key, m.value);
|
|
case 'SetCorrespondentie':
|
|
return setCorrespondentie(s, m.value);
|
|
case 'PrefillAdres':
|
|
return prefillAdres(s, m.straat, m.postcode, m.woonplaats);
|
|
case 'KiesDiploma':
|
|
return kiesDiploma(s, m.diplomaId, m.beroep, m.vraagIds);
|
|
case 'KiesHandmatig':
|
|
return kiesHandmatig(s, m.vraagIds);
|
|
case 'DeclareerBeroep':
|
|
return declareerBeroep(s, m.beroep);
|
|
case 'SetAntwoord':
|
|
return setAntwoord(s, m.vraagId, m.value);
|
|
case 'Next':
|
|
return next(s);
|
|
case 'Back':
|
|
return back(s);
|
|
case 'GaNaarStap':
|
|
return gaNaarStap(s, m.cursor);
|
|
case 'Submit':
|
|
return submit(s);
|
|
case 'Primary':
|
|
return primary(s);
|
|
case 'Retry':
|
|
return s.tag === 'Mislukt' ? { tag: 'Indienen', data: s.data } : s;
|
|
case 'SubmitConfirmed':
|
|
return s.tag === 'Indienen'
|
|
? { tag: 'Ingediend', data: s.data, referentie: m.referentie }
|
|
: s;
|
|
case 'SubmitFailed':
|
|
return s.tag === 'Indienen' ? { tag: 'Mislukt', data: s.data, error: m.error } : s;
|
|
case 'Upload':
|
|
return upload(s, m.msg);
|
|
case 'Seed':
|
|
return m.state;
|
|
default:
|
|
return assertNever(m);
|
|
}
|
|
}
|