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,106 @@
import { describe, it, expect } from 'vitest';
import { ok, err } from '@shared/kernel/fp';
import {
Answers,
initial,
visibleSteps,
currentStep,
next,
back,
submit,
resolve,
reduce,
IntakeState,
} from './intake.machine';
const answering = (answers: Answers, cursor = 0): IntakeState => ({ tag: 'Answering', answers, cursor, errors: {} });
describe('visibleSteps (the branching)', () => {
it('asks only buitenland/uren/punten/review by default', () => {
expect(visibleSteps({})).toEqual(['buitenland', 'uren', 'punten', 'review']);
});
it('adds the buitenlandDetails step when worked abroad', () => {
expect(visibleSteps({ buitenlandGewerkt: 'ja' })).toContain('buitenlandDetails');
expect(visibleSteps({ buitenlandGewerkt: 'nee' })).not.toContain('buitenlandDetails');
});
it('adds the scholing step only when NL-hours are below the threshold', () => {
expect(visibleSteps({ uren: '500' })).toContain('scholing');
expect(visibleSteps({ uren: '4160' })).not.toContain('scholing');
});
});
describe('navigation', () => {
it('Next is a no-op (sets an error) when the current step is invalid', () => {
const s = next(initial); // buitenland unanswered
expect(s.tag).toBe('Answering');
expect((s as any).cursor).toBe(0);
expect((s as any).errors.buitenland).toBeTruthy();
});
it('Next advances once the step is valid', () => {
const s = next(answering({ buitenlandGewerkt: 'nee' }));
expect((s as any).cursor).toBe(1);
expect(currentStep(s as any)).toBe('uren');
});
it('keeps the cursor in range when an answer collapses a branch', () => {
// Worked abroad, cursor sitting on the extra detail step (index 1)...
const collapsed = reduce(answering({ buitenlandGewerkt: 'ja' }, 1), { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' });
// ...the detail step is gone; cursor must not point past the new shorter list.
expect((collapsed as any).cursor).toBeLessThan(visibleSteps((collapsed as any).answers).length);
});
it('Back never goes below the first step', () => {
expect(back(initial)).toBe(initial);
});
});
describe('submit', () => {
const complete: Answers = { buitenlandGewerkt: 'nee', uren: '4160', punten: '200' };
it('reaches Submitting ONLY with valid answers', () => {
expect(submit(answering({ buitenlandGewerkt: 'nee', uren: '4160', punten: 'x' })).tag).toBe('Answering');
const good = submit(answering(complete));
expect(good.tag).toBe('Submitting');
expect((good as any).data.uren).toBe(4160);
});
it('low hours requires the scholing answer before submit', () => {
const noScholing = submit(answering({ buitenlandGewerkt: 'nee', uren: '500', punten: '200' }));
expect(noScholing.tag).toBe('Answering'); // scholing step is required, unanswered
const withScholing = submit(answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja', punten: '200' }));
expect(withScholing.tag).toBe('Submitting');
expect((withScholing as any).data.aanvullendeScholing).toBe(true);
});
it('resolve maps Submitting to Submitted / Failed', () => {
const submitting = submit(answering(complete));
expect(resolve(submitting, ok(undefined)).tag).toBe('Submitted');
expect(resolve(submitting, err('boom')).tag).toBe('Failed');
});
});
describe('reduce (message-driven happy path)', () => {
it('drives abroad branch end to end', () => {
let s: IntakeState = initial;
s = reduce(s, { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'ja' });
s = reduce(s, { tag: 'Next' });
expect(currentStep(s as any)).toBe('buitenlandDetails');
s = reduce(s, { tag: 'SetAnswer', key: 'land', value: 'België' });
s = reduce(s, { tag: 'SetAnswer', key: 'buitenlandseUren', value: '800' });
s = reduce(s, { tag: 'Next' });
expect(currentStep(s as any)).toBe('uren');
s = reduce(s, { tag: 'SetAnswer', key: 'uren', value: '4160' });
s = reduce(s, { tag: 'Next' });
expect(currentStep(s as any)).toBe('punten');
s = reduce(s, { tag: 'SetAnswer', key: 'punten', value: '200' });
s = reduce(s, { tag: 'Next' });
expect(currentStep(s as any)).toBe('review');
s = reduce(s, { tag: 'Submit' });
expect(s.tag).toBe('Submitting');
s = reduce(s, { tag: 'SubmitConfirmed' });
expect(s.tag).toBe('Submitted');
});
});