Add branching intake wizard (derived steps + radio-group atom)

A second wizard demonstrating a BRANCHING flow: the visible steps are derived
from the answers by a pure `visibleSteps` function rather than stored, so
answering "buiten Nederland gewerkt? -> ja" or reporting few hours adds steps
and the progress denominator changes live. Same Elm-style store + RemoteData
patterns as the fixed wizard; answers persist to localStorage.

- intake.machine.ts: IntakeState union + Answers + visibleSteps + pure reduce (+spec)
- intake-wizard organism, intake.page, submit-intake command
- new radio-group atom (ControlValueAccessor) in shared/ui
- /intake route + dashboard link + concepts showcase section
- tighten Aantekening.type to a 'Specialisme' | 'Aantekening' union
- README + ARCHITECTURE updated

Verified live end-to-end (branches add steps 4->5->6, review, submit) with no
console errors; build, unit tests, and Storybook all green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 09:38:26 +02:00
parent 164d20a10d
commit 7463efdc2d
14 changed files with 960 additions and 85 deletions

View File

@@ -0,0 +1,190 @@
import { Result, ok, err, assertNever } from '@shared/kernel/fp';
import { Uren, parseUren } from '@registratie/domain/value-objects/uren';
/**
* A BRANCHING wizard. Unlike herregistratie.machine (fixed 2 steps), the set of
* steps here is NOT stored — it's *derived* from the answers by `visibleSteps`.
* Answer "buiten Nederland gewerkt? → ja" and two extra steps appear; report few
* hours and a scholing-question appears. The progress bar's denominator changes
* as you type. "Which question comes next" is a pure function, so it's trivial
* to test and impossible to get out of sync with the data.
*/
export type JaNee = 'ja' | 'nee';
/** Every possible question, as a union — never a bare string. */
export type StepId = 'buitenland' | 'buitenlandDetails' | 'uren' | 'scholing' | 'punten' | 'review';
/** One record carried across every step (and persisted). All optional: the user
fills it in gradually, and branches may never ask some fields. */
export interface Answers {
buitenlandGewerkt?: JaNee; // Q1
land?: string; // Q1a — only when buitenlandGewerkt === 'ja'
buitenlandseUren?: string; // Q1b — only when buitenlandGewerkt === 'ja'
uren?: string; // Q2 — uren in NL
scholingGevolgd?: JaNee; // Q3 — only when total hours are below the threshold
punten?: string; // Q4
}
/** What we have after the review step parses — guaranteed valid/typed. */
export interface ValidIntake {
werktBuitenland: boolean;
land?: string;
buitenlandseUren?: Uren;
uren: Uren;
aanvullendeScholing?: boolean;
punten: Uren;
}
/** Below this many NL-hours we ask whether extra scholing was followed. */
const LAGE_UREN_DREMPEL = 1000;
function lageUren(a: Answers): boolean {
const r = parseUren(a.uren ?? '');
return r.ok && r.value < LAGE_UREN_DREMPEL;
}
/**
* THE branching, as one pure function. The step list is recomputed on every
* transition, so changing an earlier answer immediately adds/removes later steps.
*/
export function visibleSteps(a: Answers): StepId[] {
const steps: StepId[] = ['buitenland'];
if (a.buitenlandGewerkt === 'ja') steps.push('buitenlandDetails');
steps.push('uren');
if (lageUren(a)) steps.push('scholing');
steps.push('punten', 'review');
return steps;
}
export type IntakeState =
| { tag: 'Answering'; answers: Answers; cursor: number; errors: Partial<Record<StepId, string>> }
| { tag: 'Submitting'; data: ValidIntake }
| { tag: 'Submitted'; data: ValidIntake }
| { tag: 'Failed'; data: ValidIntake; error: string };
export const initial: IntakeState = { tag: 'Answering', answers: {}, cursor: 0, errors: {} };
/** Which step the cursor currently points at (clamped to the live step list). */
export function currentStep(s: Extract<IntakeState, { tag: 'Answering' }>): StepId {
const steps = visibleSteps(s.answers);
return steps[Math.min(s.cursor, steps.length - 1)];
}
/** Validate ONE step's fields. Returns the per-field error keyed by StepId. */
function validateStep(step: StepId, a: Answers): Result<Partial<Record<StepId, string>>, void> {
switch (step) {
case 'buitenland':
return a.buitenlandGewerkt ? ok(undefined) : err({ buitenland: 'Maak een keuze.' });
case 'buitenlandDetails': {
if (!a.land || a.land.trim() === '') return err({ buitenlandDetails: 'Vul een land in.' });
const u = parseUren(a.buitenlandseUren ?? '');
return u.ok ? ok(undefined) : err({ buitenlandDetails: u.error });
}
case 'uren': {
const u = parseUren(a.uren ?? '');
return u.ok ? ok(undefined) : err({ uren: u.error });
}
case 'scholing':
return a.scholingGevolgd ? ok(undefined) : err({ scholing: 'Maak een keuze.' });
case 'punten': {
const p = parseUren(a.punten ?? '');
return p.ok ? ok(undefined) : err({ punten: p.error });
}
case 'review':
return ok(undefined); // review shows a summary; no own fields
default:
return assertNever(step);
}
}
/** Parse the whole questionnaire into a ValidIntake (called on submit). */
function validateAll(a: Answers): Result<Partial<Record<StepId, string>>, ValidIntake> {
const errors: Partial<Record<StepId, string>> = {};
for (const step of visibleSteps(a)) {
const r = validateStep(step, a);
if (!r.ok) Object.assign(errors, r.error);
}
if (Object.keys(errors).length > 0) return err(errors);
const uren = parseUren(a.uren ?? '');
const punten = parseUren(a.punten ?? '');
// visibleSteps guaranteed these parse, but keep the compiler happy.
if (!uren.ok || !punten.ok) return err(errors);
const werktBuitenland = a.buitenlandGewerkt === 'ja';
const buitenland = parseUren(a.buitenlandseUren ?? '');
return ok({
werktBuitenland,
land: werktBuitenland ? a.land : undefined,
buitenlandseUren: werktBuitenland && buitenland.ok ? buitenland.value : undefined,
uren: uren.value,
aanvullendeScholing: lageUren(a) ? a.scholingGevolgd === 'ja' : undefined,
punten: punten.value,
});
}
export function setAnswer(s: IntakeState, key: keyof Answers, value: string): IntakeState {
if (s.tag !== 'Answering') return s;
const answers = { ...s.answers, [key]: value };
// Editing an earlier answer can shrink the step list; clamp the cursor.
const cursor = Math.min(s.cursor, visibleSteps(answers).length - 1);
return { ...s, answers, cursor };
}
export function next(s: IntakeState): IntakeState {
if (s.tag !== 'Answering') return s;
const r = validateStep(currentStep(s), s.answers);
if (!r.ok) return { ...s, errors: r.error };
const last = visibleSteps(s.answers).length - 1;
return { ...s, cursor: Math.min(s.cursor + 1, last), errors: {} };
}
export function back(s: IntakeState): IntakeState {
if (s.tag !== 'Answering' || s.cursor === 0) return s;
return { ...s, cursor: s.cursor - 1, errors: {} };
}
export function submit(s: IntakeState): IntakeState {
if (s.tag !== 'Answering') return s;
const r = validateAll(s.answers);
return r.ok ? { tag: 'Submitting', data: r.value } : { ...s, errors: r.error };
}
export function resolve(s: IntakeState, r: Result<string, void>): IntakeState {
if (s.tag !== 'Submitting') return s;
return r.ok ? { tag: 'Submitted', data: s.data } : { tag: 'Failed', data: s.data, error: r.error };
}
export type IntakeMsg =
| { tag: 'SetAnswer'; key: keyof Answers; value: string }
| { tag: 'Next' }
| { tag: 'Back' }
| { tag: 'Submit' }
| { tag: 'Retry' }
| { tag: 'SubmitConfirmed' }
| { tag: 'SubmitFailed'; error: string }
| { tag: 'Seed'; state: IntakeState };
export function reduce(s: IntakeState, m: IntakeMsg): IntakeState {
switch (m.tag) {
case 'SetAnswer':
return setAnswer(s, m.key, m.value);
case 'Next':
return next(s);
case 'Back':
return back(s);
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 'Seed':
return m.state;
default:
return assertNever(m);
}
}