Files
atomic-design-poc/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts
Edwin van den Houdt 7a582ae2fa Rijkshuisstijl restyle + wizard fixes
Chrome: two-tier Rijksoverheid header (white brand bar + lint-blue
breadcrumb bar, route-driven), dark multi-column footer, white page
surface. Session shown via a shared SESSION_PORT token (keeps shared/
free of the auth context).

Overview ("Mijn overzicht") rebuilt to the NL Design System #392 pattern:
side-nav + "Wat moet ik regelen" task list (derived) + "Mijn registratie"
cards. New shared components: card, task-list, side-nav; pure
tasksFromProfile (+spec).

Wizards: grey form panel, connected numbered stepper, form-field
"(verplicht)" markers + styled description/error, full-width inputs.
Propagated to login, detail, change-request, address-fields.

Bug fixes:
- wizard-shell: add FormsModule so NgForm intercepts submit (wizards now
  advance; no native GET leaking choices into the URL).
- wizard-shell: error-summary links focus the field instead of navigating
  (a fragment href resolved against <base href="/"> reloaded to "/" and
  bounced to login).
- wizard-shell: error-summary focus only on the rising edge, so typing
  while errors are shown no longer scrolls the page up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 13:21:54 +02:00

363 lines
19 KiB
TypeScript

import { Component, computed, effect, inject, input, untracked } 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 { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
import { WizardShellComponent, WizardError, WizardStatus } from '@shared/layout/wizard-shell/wizard-shell.component';
import { ASYNC } from '@shared/ui/async/async.component';
import { AddressFieldsComponent } from '@registratie/ui/address-fields/address-fields.component';
import { createStore } from '@shared/application/store';
import { RemoteData, fromResource } from '@shared/application/remote-data';
import { BrpAdapter, parseBrpAddress } from '@registratie/infrastructure/brp.adapter';
import { DuoAdapter, parseDuoLookup } from '@registratie/infrastructure/duo.adapter';
import { DuoLookupDto, PolicyQuestionDto } from '@registratie/contracts/duo-diplomas.dto';
import {
RegistratieState,
RegistratieMsg,
Draft,
DraftField,
Correspondentie,
StepId,
initial,
reduce,
STEPS,
} from '@registratie/domain/registratie-wizard.machine';
import { submitRegistratie } from '@registratie/application/submit-registratie';
import { ApiClient } from '@shared/infrastructure/api-client';
const STORAGE_KEY = 'registratie-v1'; // ponytail: bump the suffix if the persisted shape changes; no migration.
const KANALEN = [{ value: 'email', label: 'E-mail' }, { value: 'post', label: 'Post' }];
const JA_NEE = [{ value: 'ja', label: 'Ja' }, { value: 'nee', label: 'Nee' }];
const HANDMATIG = '__handmatig__'; // sentinel option: "my diploma isn't listed"
/** Organism: the BIG-registration wizard. All state lives in one signal driven by
the pure `reduce` (registratie-wizard.machine.ts). The BRP address prefills the
draft via an effect; the DUO diploma list renders through <app-async>; choosing
a diploma reveals its server-derived beroep. The draft is persisted to
localStorage so a reload keeps the user's progress. Built entirely from existing
atoms/molecules. */
@Component({
selector: 'app-registratie-wizard',
imports: [
FormsModule, FormFieldComponent, TextInputComponent, RadioGroupComponent, ButtonComponent,
AlertComponent, SkeletonComponent, DataRowComponent, WizardShellComponent,
AddressFieldsComponent, ...ASYNC,
],
template: `
<app-wizard-shell
[steps]="stepLabels"
[current]="cursor()"
[stepTitle]="stepTitle()"
[status]="shellStatus()"
[primaryLabel]="primaryLabel()"
[canGoBack]="cursor() > 0"
[errors]="errorList()"
[errorMessage]="errorMessage()"
submittingLabel="Uw registratie wordt verwerkt…"
(primary)="onPrimary()"
(back)="dispatch({ tag: 'Back' })"
(cancel)="restart()"
(retry)="onRetry()">
@switch (step()) {
@case ('adres') {
@if (adresStatus() === 'laden') {
<app-skeleton height="2.5rem" [count]="4" />
} @else {
@switch (adresStatus()) {
@case ('gevonden') {
<app-alert type="info">Vooraf ingevuld op basis van de BRP. Controleer en pas zo nodig aan.</app-alert>
}
@case ('geen') {
<app-alert type="warning">We vonden geen adres in de BRP. Vul uw adres hieronder handmatig in.</app-alert>
}
@case ('fout') {
<app-alert type="warning">We konden de BRP nu niet bereiken. Vul uw adres hieronder handmatig in.</app-alert>
}
}
<app-address-fields
[value]="{ straat: draft().straat ?? '', postcode: draft().postcode ?? '', woonplaats: draft().woonplaats ?? '' }"
[errors]="{ straat: err('straat'), postcode: err('postcode'), woonplaats: err('woonplaats') }"
(fieldChange)="set($event.key, $event.value)" />
<app-form-field label="Hoe wilt u correspondentie ontvangen?" fieldId="correspondentie" required [error]="err('correspondentie')">
<app-radio-group name="correspondentie" [options]="kanalen" [invalid]="!!err('correspondentie')"
[ngModel]="draft().correspondentie ?? ''" (ngModelChange)="setKanaal($event)" />
</app-form-field>
@if (draft().correspondentie === 'email') {
<app-form-field label="E-mailadres" fieldId="email" required [error]="err('email')">
<app-text-input inputId="email" type="email" [invalid]="!!err('email')" [ngModel]="draft().email ?? ''" (ngModelChange)="set('email', $event)" name="email" placeholder="naam@voorbeeld.nl" />
</app-form-field>
}
}
}
@case ('beroep') {
<app-async [data]="lookupRd()">
<ng-template appAsyncLoaded let-data>
<app-form-field label="Kies het diploma waarmee u zich wilt registreren" fieldId="diploma" required [error]="err('diploma')">
<app-radio-group name="diploma" [options]="diplomaOptions($any(data))" [invalid]="!!err('diploma')"
[ngModel]="diplomaKeuze()" (ngModelChange)="onDiplomaKeuze($any(data), $event)" />
</app-form-field>
@if (handmatigActief()) {
<app-alert type="warning">Een handmatig ingevoerd diploma kan niet automatisch worden geverifieerd. Kies uw beroep en beantwoord de aanvullende vragen; uw aanvraag wordt daarna handmatig beoordeeld.</app-alert>
<app-form-field label="Voor welk beroep wilt u zich registreren?" fieldId="hm-beroep" [error]="err('diploma')">
<app-radio-group name="hm-beroep" [options]="beroepOptions($any(data))" [invalid]="!!err('diploma')"
[ngModel]="draft().beroep ?? ''" (ngModelChange)="dispatch({ tag: 'DeclareerBeroep', beroep: $event })" />
</app-form-field>
} @else if (draft().beroep) {
<dl class="rhc-data-summary rhc-data-summary--row app-section">
<app-data-row key="Beroep (afgeleid uit diploma)" [value]="draft().beroep ?? ''" />
</dl>
}
@for (q of actieveVragen($any(data)); track q.id) {
<app-form-field [label]="q.vraag" [fieldId]="'vraag-' + q.id" [error]="vraagErr(q.id)">
@if (q.type === 'ja-nee') {
<app-radio-group [name]="'vraag-' + q.id" [options]="jaNee" [invalid]="!!vraagErr(q.id)"
[ngModel]="draft().antwoorden[q.id] ?? ''" (ngModelChange)="dispatch({ tag: 'SetAntwoord', vraagId: q.id, value: $event })" [ngModelOptions]="{ standalone: true }" />
} @else {
<app-text-input [inputId]="'vraag-' + q.id" [invalid]="!!vraagErr(q.id)" [ngModel]="draft().antwoorden[q.id] ?? ''"
(ngModelChange)="dispatch({ tag: 'SetAntwoord', vraagId: q.id, value: $event })" [ngModelOptions]="{ standalone: true }" />
}
</app-form-field>
}
</ng-template>
<ng-template appAsyncLoading>
<app-skeleton height="2.5rem" [count]="3" />
</ng-template>
</app-async>
}
@case ('controle') {
<app-alert type="info">Controleer uw gegevens en dien de registratie in.</app-alert>
<dl class="rhc-data-summary rhc-data-summary--row app-section">
<app-data-row key="Adres" [value]="adresSamenvatting()" />
<app-data-row key="Herkomst adres" [value]="adresHerkomstLabel()" />
<app-data-row key="Correspondentie" [value]="correspondentieLabel()" />
@if (draft().correspondentie === 'email') {
<app-data-row key="E-mailadres" [value]="draft().email ?? ''" />
}
<app-data-row key="Beroep" [value]="draft().beroep ?? ''" />
<app-data-row key="Herkomst diploma" [value]="diplomaHerkomstLabel()" />
@for (item of samenvattingVragen(); track item.vraag) {
<app-data-row [key]="item.vraag" [value]="item.antwoord" />
}
</dl>
<div class="app-button-row app-section">
<app-button type="button" variant="subtle" (click)="dispatch({ tag: 'GaNaarStap', cursor: 0 })">Adres wijzigen</app-button>
<app-button type="button" variant="subtle" (click)="dispatch({ tag: 'GaNaarStap', cursor: 1 })">Diploma wijzigen</app-button>
</div>
}
}
<div wizardSuccess>
<app-alert type="ok">Uw registratie is ontvangen. Uw referentienummer is {{ referentie() }}. Bewaar dit nummer voor uw administratie.</app-alert>
<div class="app-section">
<app-button variant="secondary" (click)="restart()">Nieuwe registratie starten</app-button>
</div>
</div>
</app-wizard-shell>
`,
})
export class RegistratieWizardComponent {
private brp = inject(BrpAdapter);
private duo = inject(DuoAdapter);
private apiClient = inject(ApiClient);
private store = createStore<RegistratieState, RegistratieMsg>(initial, reduce);
protected adresRes = this.brp.adresResource();
private diplomasRes = this.duo.diplomasResource();
/** Optional seed so Storybook / tests can mount any state directly. */
seed = input<RegistratieState>(initial);
readonly kanalen = KANALEN;
readonly stepLabels = ['Adres', 'Beroep', 'Controle']; // short labels for the stepper
private stepTitles = ['Adres en correspondentievoorkeur', 'Beroep op basis van uw diploma', 'Controleren en indienen'];
readonly state = this.store.model;
readonly dispatch = this.store.dispatch;
private invullen = computed(() => (this.state().tag === 'Invullen' ? (this.state() as Extract<RegistratieState, { tag: 'Invullen' }>) : null));
protected cursor = computed(() => this.invullen()?.cursor ?? 0);
protected draft = computed<Draft>(() => this.invullen()?.draft ?? { antwoorden: {} });
protected step = computed<StepId>(() => STEPS[Math.min(this.cursor(), STEPS.length - 1)]);
protected stepTitle = computed(() => this.stepTitles[Math.min(this.cursor(), this.stepTitles.length - 1)]);
protected referentie = computed(() => (this.state().tag === 'Ingediend' ? (this.state() as Extract<RegistratieState, { tag: 'Ingediend' }>).referentie : ''));
protected failedError = computed(() => (this.state().tag === 'Mislukt' ? (this.state() as Extract<RegistratieState, { tag: 'Mislukt' }>).error : ''));
// --- Presentational wiring for the shared wizard shell ---------------------
protected primaryLabel = computed(() => (this.step() === 'controle' ? 'Registratie indienen' : 'Volgende'));
protected errorMessage = computed(() => `Het indienen is niet gelukt: ${this.failedError()}`);
protected shellStatus = computed<WizardStatus>(() => {
switch (this.state().tag) {
case 'Invullen': return 'editing';
case 'Indienen': return 'submitting';
case 'Ingediend': return 'submitted';
case 'Mislukt': return 'failed';
}
});
/** Current step's errors (incl. per-question), flattened for the error summary. */
protected errorList = computed<WizardError[]>(() => {
const e = this.invullen()?.errors ?? {};
const out: WizardError[] = [];
for (const [k, v] of Object.entries(e)) {
if (k !== 'antwoorden' && typeof v === 'string' && v) out.push({ id: k, message: v });
}
for (const [qid, msg] of Object.entries(e.antwoorden ?? {})) {
if (msg) out.push({ id: 'vraag-' + qid, message: msg });
}
return out;
});
protected adresSamenvatting = computed(() => {
const d = this.draft();
return [d.straat, [d.postcode, d.woonplaats].filter(Boolean).join(' ')].filter(Boolean).join(', ');
});
// Readable labels for the controle summary (instead of raw enum values).
protected adresHerkomstLabel = computed(() => ({ brp: 'Automatisch uit de BRP', handmatig: 'Handmatig ingevoerd' }[this.draft().adresHerkomst ?? 'handmatig']));
protected correspondentieLabel = computed(() => ({ email: 'Per e-mail', post: 'Per post' }[this.draft().correspondentie ?? 'post']));
protected diplomaHerkomstLabel = computed(() => ({ duo: 'Geverifieerd via DUO', handmatig: 'Handmatig ingevoerd (wordt beoordeeld)' }[this.draft().diplomaHerkomst ?? 'handmatig']));
/** BRP lookup outcome. A failure or "geen adres" never blocks the wizard — both
fall back to manual entry (PRD §7). 'geen' covers both no-address-found and a
malformed response; 'fout' is an unreachable BRP. */
protected adresStatus = computed<'laden' | 'gevonden' | 'geen' | 'fout'>(() => {
const st = this.adresRes.status();
if (st === 'loading' || st === 'reloading') return 'laden';
if (st === 'error') return 'fout';
const json = this.adresRes.value();
const parsed = json !== undefined ? parseBrpAddress(json) : null;
return parsed && parsed.ok && parsed.value.gevonden ? 'gevonden' : 'geen';
});
/** The DUO lookup (diplomas + manual fallback), validated at the trust boundary. */
protected lookupRd = computed<RemoteData<Error | undefined, DuoLookupDto>>(() => {
const rd = fromResource(this.diplomasRes);
if (rd.tag !== 'Success') return rd;
const parsed = parseDuoLookup(rd.value);
return parsed.ok ? { tag: 'Success', value: parsed.value } : { tag: 'Failure', error: new Error(parsed.error) };
});
/** Parsed lookup as a plain value (or null) — used outside the beroep step (the
controle summary) where the <app-async> template variable isn't in scope. */
private duoData = computed<DuoLookupDto | null>(() => {
const rd = this.lookupRd();
return rd.tag === 'Success' ? rd.value : null;
});
readonly jaNee = JA_NEE;
protected err = (k: DraftField | 'correspondentie' | 'diploma') => this.invullen()?.errors[k] ?? '';
protected vraagErr = (id: string) => this.invullen()?.errors.antwoorden?.[id] ?? '';
protected set = (key: DraftField, value: string) => this.dispatch({ tag: 'SetField', key, value });
protected setKanaal = (value: string) => this.dispatch({ tag: 'SetCorrespondentie', value: value as Correspondentie });
/** True while the user is entering a diploma manually (not in the DUO list). */
protected handmatigActief = computed(() => this.draft().diplomaHerkomst === 'handmatig');
/** The radio selection: a diploma id, or the "not listed" sentinel in manual mode. */
protected diplomaKeuze = computed(() => (this.handmatigActief() ? HANDMATIG : this.draft().diplomaId ?? ''));
protected diplomaOptions = (data: DuoLookupDto) => [
...data.diplomas.map((d) => ({ value: d.id, label: `${d.naam}${d.instelling} (${d.jaar})` })),
{ value: HANDMATIG, label: 'Mijn diploma staat er niet bij' },
];
protected beroepOptions = (data: DuoLookupDto) => data.handmatig.beroepen.map((b) => ({ value: b, label: b }));
/** The policy questions that apply to the current choice (server-decided). */
protected actieveVragen = (data: DuoLookupDto): PolicyQuestionDto[] => {
if (this.handmatigActief()) return data.handmatig.policyQuestions;
return data.diplomas.find((d) => d.id === this.draft().diplomaId)?.policyQuestions ?? [];
};
/** Answered policy questions for the controle summary (question text + answer). */
protected samenvattingVragen = computed(() => {
const data = this.duoData();
const d = this.draft();
if (!data) return [] as { vraag: string; antwoord: string }[];
const alle = [...data.diplomas.flatMap((x) => x.policyQuestions), ...data.handmatig.policyQuestions];
return (d.vraagIds ?? []).map((id) => ({ vraag: alle.find((q) => q.id === id)?.vraag ?? id, antwoord: d.antwoorden[id] ?? '' }));
});
protected onDiplomaKeuze(data: DuoLookupDto, id: string) {
if (id === HANDMATIG) {
this.dispatch({ tag: 'KiesHandmatig', vraagIds: data.handmatig.policyQuestions.map((q) => q.id) });
return;
}
const d = data.diplomas.find((x) => x.id === id);
if (d) this.dispatch({ tag: 'KiesDiploma', diplomaId: d.id, beroep: d.beroep, vraagIds: d.policyQuestions.map((q) => q.id) });
}
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 filling in; clear once the flow is done.
effect(() => {
const s = this.state();
if (s.tag === 'Invullen') localStorage.setItem(STORAGE_KEY, JSON.stringify(s));
else localStorage.removeItem(STORAGE_KEY);
});
// Prefill the address from the BRP lookup as it arrives. Track only the resource
// value; untrack the dispatch (it reads the state signal, which would otherwise
// make this effect loop on its own write). Don't clobber edits/restored data.
effect(() => {
const json = this.adresRes.value();
if (!json) return;
const parsed = parseBrpAddress(json);
untracked(() => {
const s = this.state();
if (s.tag !== 'Invullen' || s.draft.straat) return;
// ponytail: slice 2 handles gevonden === false -> manual entry stays handmatig.
if (parsed.ok && parsed.value.gevonden && parsed.value.adres) {
const a = parsed.value.adres;
this.dispatch({ tag: 'PrefillAdres', straat: a.straat, postcode: a.postcode, woonplaats: a.woonplaats });
}
});
});
// A11y: focus management (step heading on step change, error summary on a
// failed submit) now lives in the shared WizardShellComponent.
}
private restore(): RegistratieState | null {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
try {
return JSON.parse(raw) as RegistratieState;
} catch {
return null; // ponytail: corrupt entry -> start fresh, no migration.
}
}
onPrimary() {
const s = this.state();
if (s.tag !== 'Invullen') return;
this.dispatch(this.step() === 'controle' ? { tag: 'Submit' } : { tag: 'Next' });
this.runIfIndienen();
}
onRetry() {
this.dispatch({ tag: 'Retry' });
this.runIfIndienen();
}
/** Reset the wizard to a fresh start. Reload the BRP lookup so the address
re-prefills, keeping the form and the "vooraf ingevuld" note consistent. */
restart() {
this.dispatch({ tag: 'Seed', state: initial });
this.adresRes.reload();
}
/** The effect: when we enter Indienen, call the backend, then dispatch the
outcome (success carries the confirmation reference). */
private async runIfIndienen() {
const s = this.state();
if (s.tag !== 'Indienen') return;
const r = await submitRegistratie(this.apiClient, s.data);
if (r.ok) this.dispatch({ tag: 'SubmitConfirmed', referentie: r.value });
else this.dispatch({ tag: 'SubmitFailed', error: r.error });
}
}