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:
eho
2026-06-26 09:38:26 +02:00
co-authored by Claude Opus 4.8
parent 164d20a10d
commit 7463efdc2d
14 changed files with 960 additions and 85 deletions
@@ -0,0 +1,186 @@
import { Component, computed, effect, inject, input } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
import { RadioGroupComponent } from '@shared/ui/radio-group/radio-group.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { SpinnerComponent } from '@shared/ui/spinner/spinner.component';
import { createStore } from '@shared/application/store';
import { BigProfileStore } from '@registratie/application/big-profile.store';
import {
IntakeState,
IntakeMsg,
Answers,
StepId,
initial,
reduce,
visibleSteps,
} from '@herregistratie/domain/intake.machine';
import { submitIntake } from '@herregistratie/application/submit-intake';
const STORAGE_KEY = 'intake-v1'; // ponytail: bump the suffix if the Answers shape changes; no migration.
const JA_NEE = [{ value: 'ja', label: 'Ja' }, { value: 'nee', label: 'Nee' }];
/** Organism: a BRANCHING intake questionnaire. All state lives in one signal
driven by the pure `reduce` (intake.machine.ts). Which step renders is derived
from the answers via `visibleSteps`, never stored — so editing an earlier
answer immediately changes the remaining steps. Answers are persisted to
localStorage so a page reload keeps the user's progress. */
@Component({
selector: 'app-intake-wizard',
imports: [FormsModule, FormFieldComponent, TextInputComponent, RadioGroupComponent, ButtonComponent, AlertComponent, SpinnerComponent],
template: `
@switch (state().tag) {
@case ('Answering') {
<p class="rhc-paragraph" style="color:var(--rhc-color-grijs-700)">Stap {{ cursor() + 1 }} van {{ steps().length }}</p>
<form (ngSubmit)="onPrimary()" style="max-width:30rem">
@switch (step()) {
@case ('buitenland') {
<app-form-field label="Heeft u de afgelopen 5 jaar buiten Nederland gewerkt?" fieldId="buitenland" [error]="err('buitenland')">
<app-radio-group name="buitenland" [options]="jaNee"
[ngModel]="answers().buitenlandGewerkt ?? ''" (ngModelChange)="set('buitenlandGewerkt', $event)" />
</app-form-field>
}
@case ('buitenlandDetails') {
<app-form-field label="In welk land?" fieldId="land" [error]="err('buitenlandDetails')">
<app-text-input inputId="land" [ngModel]="answers().land ?? ''" (ngModelChange)="set('land', $event)" name="land" placeholder="bijv. België" />
</app-form-field>
<app-form-field label="Hoeveel uur heeft u daar gewerkt?" fieldId="buitenlandseUren">
<app-text-input inputId="buitenlandseUren" [ngModel]="answers().buitenlandseUren ?? ''" (ngModelChange)="set('buitenlandseUren', $event)" name="buitenlandseUren" placeholder="bijv. 800" />
</app-form-field>
}
@case ('uren') {
<app-form-field label="Gewerkte uren in Nederland (afgelopen 5 jaar)" fieldId="uren" [error]="err('uren')">
<app-text-input inputId="uren" [ngModel]="answers().uren ?? ''" (ngModelChange)="set('uren', $event)" name="uren" placeholder="bijv. 4160" />
</app-form-field>
}
@case ('scholing') {
<app-form-field label="U werkte relatief weinig uren. Heeft u aanvullende scholing gevolgd?" fieldId="scholing" [error]="err('scholing')">
<app-radio-group name="scholing" [options]="jaNee"
[ngModel]="answers().scholingGevolgd ?? ''" (ngModelChange)="set('scholingGevolgd', $event)" />
</app-form-field>
}
@case ('punten') {
<app-form-field label="Behaalde nascholingspunten" fieldId="punten" [error]="err('punten')">
<app-text-input inputId="punten" [ngModel]="answers().punten ?? ''" (ngModelChange)="set('punten', $event)" name="punten" placeholder="bijv. 200" />
</app-form-field>
}
@case ('review') {
<app-alert type="info">Controleer uw antwoorden en dien de aanvraag in.</app-alert>
<dl class="rhc-data-summary rhc-data-summary--row" style="margin:1rem 0">
<div><dt>Buiten NL gewerkt</dt><dd>{{ answers().buitenlandGewerkt ?? '—' }}</dd></div>
@if (answers().buitenlandGewerkt === 'ja') {
<div><dt>Land</dt><dd>{{ answers().land }}</dd></div>
<div><dt>Buitenlandse uren</dt><dd>{{ answers().buitenlandseUren }}</dd></div>
}
<div><dt>Uren NL</dt><dd>{{ answers().uren }}</dd></div>
@if (steps().includes('scholing')) {
<div><dt>Aanvullende scholing</dt><dd>{{ answers().scholingGevolgd }}</dd></div>
}
<div><dt>Nascholingspunten</dt><dd>{{ answers().punten }}</dd></div>
</dl>
}
}
<div style="display:flex;gap:0.5rem;margin-top:1rem">
@if (cursor() > 0) {
<app-button type="button" variant="secondary" (click)="dispatch({ tag: 'Back' })">Vorige</app-button>
}
<app-button type="submit" variant="primary">{{ step() === 'review' ? 'Aanvraag indienen' : 'Volgende' }}</app-button>
</div>
</form>
}
@case ('Submitting') {
<app-spinner /> <span>Aanvraag wordt verwerkt…</span>
}
@case ('Submitted') {
<app-alert type="ok">Uw aanvraag tot herregistratie is ontvangen.</app-alert>
<div style="margin-top:1rem">
<app-button variant="secondary" (click)="restart()">Opnieuw beginnen</app-button>
</div>
}
@case ('Failed') {
<app-alert type="error">Indienen mislukt: {{ failedError() }}</app-alert>
<div style="margin-top:1rem">
<app-button variant="secondary" (click)="onRetry()">Opnieuw proberen</app-button>
</div>
}
}
`,
})
export class IntakeWizardComponent {
private profile = inject(BigProfileStore);
private store = createStore<IntakeState, IntakeMsg>(initial, reduce);
/** Optional seed so Storybook / the showcase can mount any state directly. */
seed = input<IntakeState>(initial);
readonly jaNee = JA_NEE;
readonly state = this.store.model;
readonly dispatch = this.store.dispatch;
private answering = computed(() => (this.state().tag === 'Answering' ? (this.state() as Extract<IntakeState, { tag: 'Answering' }>) : null));
/** Public so the showcase can render the live step list next to the wizard. */
readonly steps = computed<StepId[]>(() => visibleSteps(this.answering()?.answers ?? {}));
protected cursor = computed(() => this.answering()?.cursor ?? 0);
protected answers = computed<Answers>(() => this.answering()?.answers ?? {});
protected step = computed<StepId>(() => this.steps()[Math.min(this.cursor(), this.steps().length - 1)]);
protected failedError = computed(() => (this.state().tag === 'Failed' ? (this.state() as Extract<IntakeState, { tag: 'Failed' }>).error : ''));
protected err = (k: StepId) => this.answering()?.errors[k] ?? '';
protected set = (key: keyof Answers, value: string) => this.dispatch({ tag: 'SetAnswer', key, value });
constructor() {
// An explicit seed (stories) wins; otherwise resume from localStorage.
const seeded = this.seed();
queueMicrotask(() => this.dispatch({ tag: 'Seed', state: seeded !== initial ? seeded : (this.restore() ?? initial) }));
// Persist only while answering; clear once the flow is done.
effect(() => {
const s = this.state();
if (s.tag === 'Answering') localStorage.setItem(STORAGE_KEY, JSON.stringify(s));
else localStorage.removeItem(STORAGE_KEY);
});
}
private restore(): IntakeState | null {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
try {
return JSON.parse(raw) as IntakeState;
} catch {
return null; // ponytail: corrupt entry -> start fresh, no migration.
}
}
onPrimary() {
const s = this.state();
if (s.tag !== 'Answering') return;
this.dispatch(this.step() === 'review' ? { tag: 'Submit' } : { tag: 'Next' });
this.runIfSubmitting();
}
onRetry() {
this.dispatch({ tag: 'Retry' });
this.runIfSubmitting();
}
restart() {
this.dispatch({ tag: 'Seed', state: initial });
}
/** The effect: when we enter Submitting, call the backend, flip the optimistic
cross-page flag, then dispatch the outcome (and commit/rollback). */
private async runIfSubmitting() {
const s = this.state();
if (s.tag !== 'Submitting') return;
this.profile.beginHerregistratie();
const r = await submitIntake(s.data);
if (r.ok) {
this.dispatch({ tag: 'SubmitConfirmed' });
this.profile.confirmHerregistratie();
} else {
this.dispatch({ tag: 'SubmitFailed', error: r.error });
this.profile.rollbackHerregistratie();
}
}
}
@@ -0,0 +1,27 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { applicationConfig } from '@storybook/angular';
import { provideHttpClient } from '@angular/common/http';
import { IntakeWizardComponent } from './intake-wizard.component';
import { IntakeState, Answers } from '@herregistratie/domain/intake.machine';
import { Uren } from '@registratie/domain/value-objects/uren';
const validData = { werktBuitenland: false, uren: 4160 as Uren, punten: 200 as Uren };
const meta: Meta<IntakeWizardComponent> = {
title: 'Herregistratie/IntakeWizard',
component: IntakeWizardComponent,
// Injects BigProfileStore (optimistic flag) which creates httpResources.
decorators: [applicationConfig({ providers: [provideHttpClient()] })],
};
export default meta;
type Story = StoryObj<IntakeWizardComponent>;
const answering = (answers: Answers, cursor = 0): IntakeState => ({ tag: 'Answering', answers, cursor, errors: {} });
export const Start: Story = { args: { seed: answering({}) } };
export const AbroadBranch: Story = { args: { seed: answering({ buitenlandGewerkt: 'ja' }, 1) } };
export const LowHoursScholing: Story = { args: { seed: answering({ buitenlandGewerkt: 'nee', uren: '500' }, 2) } };
export const Review: Story = { args: { seed: answering({ buitenlandGewerkt: 'nee', uren: '4160', punten: '200' }, 3) } };
export const Submitting: Story = { args: { seed: { tag: 'Submitting', data: validData } } };
export const Submitted: Story = { args: { seed: { tag: 'Submitted', data: validData } } };
export const Failed: Story = { args: { seed: { tag: 'Failed', data: validData, error: 'Netwerkfout' } } };
+24
View File
@@ -0,0 +1,24 @@
import { Component } from '@angular/core';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { IntakeWizardComponent } from '@herregistratie/ui/intake-wizard/intake-wizard.component';
/** Page: the branching intake questionnaire. Built entirely from existing
building blocks (page shell + alert + the intake-wizard organism). */
@Component({
selector: 'app-intake-page',
imports: [PageShellComponent, AlertComponent, IntakeWizardComponent],
template: `
<app-page-shell heading="Herregistratie — intake" backLink="/dashboard">
<app-alert type="info">
Een paar vragen bepalen welke gegevens we nodig hebben. Afhankelijk van uw
antwoorden verschijnen er extra vragen. Uw antwoorden blijven bewaard als u
de pagina herlaadt.
</app-alert>
<div style="margin-top:1.5rem">
<app-intake-wizard />
</div>
</app-page-shell>
`,
})
export class IntakePage {}