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>
363 lines
19 KiB
TypeScript
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 });
|
|
}
|
|
}
|