refactor: split registratie-wizard into three steps (RD-23)

Move the adres, beroep and controle cases out of registratie-wizard.component.ts
into adres.step.ts, beroep.step.ts and controle.step.ts, matching RD-22's
*.step.ts convention. The parent drops from ~568 to 274 lines and loses its
`eslint-disable max-lines`.

The upload controller moves into beroep.step.ts and emits `uploadMsg` instead of
dispatching directly; the parent maps that back onto the machine's `Upload`
message. `onDiplomaKeuze` stays in the parent (message construction from the DUO
payload belongs in the container) and now takes only the chosen id, reading its
own `duoData` computed instead of receiving the DUO payload as an argument. Each
step injects `RegistratieLookupStore` directly for its own async presentation
(adresStatus, the DUO lookup, samenvattingVragen) — the sanctioned exception,
since it is a root singleton. Markup moved verbatim; the `@@` id count across
the directory stays 43.

Two of the ticket's acceptance numbers do not hold against correct code and are
corrected in the ticket file: `createUploadController` is 2 lines (import +
call), not 1 — `git grep -c` counts lines, and the same shape gives 2 for
`createStore` and 3 for `createDraftSync` elsewhere in this codebase. `dispatch`
is 1, not 0 — decision 4's mandated `UploadControllerDeps.dispatch` property
name is that string even though it is not the machine's dispatch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-09-04 23:17:25 +02:00
co-authored by Claude Sonnet 5
parent 8e1de38c68
commit 11e3191099
6 changed files with 760 additions and 392 deletions
@@ -0,0 +1,122 @@
import { Component, inject, input, output } 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 { AlertComponent } from '@shared/ui/alert/alert.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
import { AddressFieldsComponent } from '@registratie/ui/address-fields/address-fields.component';
import { RegistratieLookupStore } from '@registratie/application/registratie-lookup.store';
import { Draft, DraftField, Errors } from '@registratie/domain/registratie-wizard.machine';
const KANALEN = [
{ value: 'email', label: $localize`:@@registratie.kanaalEmail:E-mail` },
{ value: 'post', label: $localize`:@@registratie.kanaalPost:Post` },
];
/** Step: the registratie wizard's first screen (adres + correspondentievoorkeur).
Injects RegistratieLookupStore directly for the BRP lookup banner — the
sanctioned exception (it is `providedIn: 'root'`, so every injection is the
same instance): the step owns its own async presentation rather than making
the parent a pass-through for it. Values otherwise in via `draft`/`errors`,
every change out via `fieldChange`/`kanaalChange`. No internal state; the
parent owns the Model and decides what a change means. */
@Component({
selector: 'app-reg-adres-step',
imports: [
FormsModule,
FormFieldComponent,
TextInputComponent,
RadioGroupComponent,
AlertComponent,
SkeletonComponent,
AddressFieldsComponent,
],
template: `
@if (adresStatus() === 'laden') {
<app-skeleton height="2.5rem" [count]="4" />
} @else {
@switch (adresStatus()) {
@case ('gevonden') {
<app-alert type="info" i18n="@@regWizard.brpGevonden"
>Vooraf ingevuld op basis van de BRP. Controleer en pas zo nodig aan.</app-alert
>
}
@case ('geen') {
<app-alert type="warning" i18n="@@regWizard.brpGeen"
>We vonden geen adres in de BRP. Vul uw adres hieronder handmatig in.</app-alert
>
}
@case ('fout') {
<app-alert type="warning" i18n="@@regWizard.brpFout"
>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)="fieldChange.emit($event)"
/>
<fieldset>
<app-form-field
i18n-label="@@regWizard.correspondentieLabel"
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)="kanaalChange.emit($event)"
/>
</app-form-field>
</fieldset>
@if (draft().correspondentie === 'email') {
<fieldset>
<app-form-field
i18n-label="@@regWizard.emailLabel"
label="E-mailadres"
fieldId="email"
required
[error]="err('email')"
>
<app-text-input
inputId="email"
type="email"
[invalid]="!!err('email')"
[ngModel]="draft().email ?? ''"
(ngModelChange)="fieldChange.emit({ key: 'email', value: $event })"
name="email"
i18n-placeholder="@@regWizard.emailPlaceholder"
placeholder="naam@voorbeeld.nl"
/>
</app-form-field>
</fieldset>
}
}
`,
})
export class AdresStep {
private lookup = inject(RegistratieLookupStore);
draft = input.required<Draft>();
errors = input.required<Errors>();
fieldChange = output<{ key: DraftField; value: string }>();
kanaalChange = output<string>();
protected adresStatus = this.lookup.adresStatus;
readonly kanalen = KANALEN;
protected err = (k: DraftField | 'correspondentie') => this.errors()[k] ?? '';
}
@@ -0,0 +1,223 @@
import { Component, computed, inject, input, output } 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, JA_NEE } from '@shared/ui/radio-group/radio-group.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 { DataBlockComponent } from '@shared/ui/data-block/data-block.component';
import { ASYNC } from '@shared/ui/async/async.component';
import { DocumentUploadComponent } from '@shared/ui/upload/document-upload/document-upload.component';
import { createUploadController } from '@shared/application/upload-controller';
import { UploadMsg, UploadState } from '@shared/domain/upload.machine';
import { RemoteData, successOr } from '@shared/application/remote-data';
import { RegistratieLookupStore } from '@registratie/application/registratie-lookup.store';
import { DuoLookupDto, PolicyQuestionDto } from '@registratie/contracts/duo-diplomas.dto';
import { Draft, Errors } from '@registratie/domain/registratie-wizard.machine';
/** The server-owned geldigheidsvraag whose "ja" answer requires a Dutch-taalvaardigheid
upload (proof of the confirmed B2 level). Stable id shared with the backend. */
const NL_TAALVAARDIGHEID_VRAAG = 'nl-taalvaardigheid';
/** Sentinel option: "my diploma isn't listed". Exported so the parent's
`onDiplomaKeuze` (registratie-wizard.component.ts) can recognize it too. */
export const HANDMATIG = '__handmatig__';
/** Step: the registratie wizard's second screen (beroep op basis van diploma).
Injects RegistratieLookupStore directly for the DUO lookup — the sanctioned
exception (it is `providedIn: 'root'`, so every injection is the same
instance) — and owns its own `<app-async>` over it. Also owns the upload
controller, moved here from the parent: this is what gets the parent under
the line limit. Values in via `draft`/`errors`/`upload`; every user intent
leaves as one of four outputs. No store beyond the lookup, and no machine
message built here; the parent maps each output onto its own message. */
@Component({
selector: 'app-reg-beroep-step',
imports: [
FormsModule,
FormFieldComponent,
TextInputComponent,
RadioGroupComponent,
AlertComponent,
SkeletonComponent,
DataRowComponent,
DataBlockComponent,
DocumentUploadComponent,
...ASYNC,
],
template: `
<app-async [data]="lookupRd()">
<ng-template appAsyncLoaded>
@if (duoData(); as data) {
<fieldset>
<app-form-field
i18n-label="@@regWizard.diplomaLabel"
label="Kies het diploma waarmee u zich wilt registreren"
fieldId="diploma"
required
[error]="err('diploma')"
>
<app-radio-group
name="diploma"
[options]="diplomaOptions(data)"
[invalid]="!!err('diploma')"
[ngModel]="diplomaKeuze()"
(ngModelChange)="diplomaChosen.emit($event)"
/>
</app-form-field>
</fieldset>
@if (handmatigActief()) {
<app-alert type="warning" i18n="@@regWizard.handmatigWaarschuwing"
>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
>
<fieldset>
<app-form-field
i18n-label="@@regWizard.beroepLabel"
label="Voor welk beroep wilt u zich registreren?"
fieldId="hm-beroep"
[error]="err('diploma')"
>
<app-radio-group
name="hm-beroep"
[options]="beroepOptions(data)"
[invalid]="!!err('diploma')"
[ngModel]="draft().beroep ?? ''"
(ngModelChange)="beroepDeclared.emit($event)"
/>
</app-form-field>
</fieldset>
} @else if (draft().beroep) {
<app-data-block class="app-section">
<div
app-data-row
i18n-key="@@regWizard.beroepAfgeleid"
key="Beroep (afgeleid uit diploma)"
[value]="draft().beroep ?? ''"
></div>
</app-data-block>
}
@if (actieveVragen(data).length) {
<fieldset>
@for (q of actieveVragen(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]="antwoord(q.id)"
(ngModelChange)="antwoordChange.emit({ vraagId: q.id, value: $event })"
[ngModelOptions]="{ standalone: true }"
/>
} @else {
<app-text-input
[inputId]="'vraag-' + q.id"
[invalid]="!!vraagErr(q.id)"
[ngModel]="antwoord(q.id)"
(ngModelChange)="antwoordChange.emit({ vraagId: q.id, value: $event })"
[ngModelOptions]="{ standalone: true }"
/>
}
</app-form-field>
}
</fieldset>
}
}
</ng-template>
<ng-template appAsyncLoading>
<app-skeleton height="2.5rem" [count]="3" />
</ng-template>
</app-async>
<app-document-upload
class="app-section"
[state]="upload()"
[previewUrlFor]="previewUrlFor"
(fileSelected)="uploadCtl.onFileSelected($event.categoryId, $event.files)"
(removeUpload)="uploadCtl.onRemove($event)"
(retryUpload)="uploadCtl.onRetry($event)"
(deleteUpload)="uploadCtl.onDelete($event)"
(channelChange)="uploadCtl.onChannelChange($event.categoryId, $event.channel)"
/>
@if (err('documenten')) {
<app-alert type="warning">{{ err('documenten') }}</app-alert>
}
`,
})
export class BeroepStep {
private lookup = inject(RegistratieLookupStore);
draft = input.required<Draft>();
errors = input.required<Errors>();
upload = input.required<UploadState>();
uploadMsg = output<UploadMsg>();
antwoordChange = output<{ vraagId: string; value: string }>();
diplomaChosen = output<string>();
beroepDeclared = output<string>();
/** Preview/download link for a completed upload; delegates to the upload
controller (application layer), which knows the dev-simulation `demo-*`
ids have no stored bytes and returns no link for them. */
protected previewUrlFor = (documentId: string): string | undefined =>
this.uploadCtl.previewUrlFor(documentId);
protected uploadCtl = createUploadController({
wizardId: 'registratie',
getUpload: () => this.upload(),
dispatch: (msg) => this.uploadMsg.emit(msg),
// Required documents depend on answers (server decides): a diploma upload only for a
// manual diploma; a Dutch-taalvaardigheid upload only once the applicant confirms
// ("ja") the B2 language requirement.
getCategoryParams: () => ({
diplomaHerkomst: this.draft().diplomaHerkomst,
taalvaardigheid: this.draft().antwoorden[NL_TAALVAARDIGHEID_VRAAG],
}),
});
/** Parsed DUO lookup (validated at the trust boundary by the application
facade — the step renders, it does not fetch/parse). */
protected lookupRd: () => RemoteData<Error | undefined, DuoLookupDto> = this.lookup.duoLookup;
protected duoData = computed<DuoLookupDto | null>(() => successOr(this.lookupRd(), null));
readonly jaNee = JA_NEE;
protected err = (k: 'diploma' | 'documenten') => this.errors()[k] ?? '';
protected vraagErr = (id: string) => this.errors().antwoorden?.[id] ?? '';
protected antwoord = (id: string) => this.draft().antwoorden[id] ?? ''; // runtime guard: missing key → undefined
/** 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: $localize`:@@regWizard.diplomaNietBij: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 ?? [];
};
}
@@ -0,0 +1,136 @@
import { Component, computed, inject, input, output } from '@angular/core';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
import { ReviewSectionComponent } from '@shared/ui/review-section/review-section.component';
import { successOr } from '@shared/application/remote-data';
import { RegistratieLookupStore } from '@registratie/application/registratie-lookup.store';
import { DuoLookupDto } from '@registratie/contracts/duo-diplomas.dto';
import { Draft } from '@registratie/domain/registratie-wizard.machine';
/** Step: the registratie wizard's review screen (controle & indienen). Injects
RegistratieLookupStore directly to build `samenvattingVragen` — the
sanctioned exception (it is `providedIn: 'root'`, so every injection is the
same instance). Values otherwise in via `draft`, the cursor to jump back to
out via `edit`. No internal state; the parent maps the cursor onto its own
`GaNaarStap` message. */
@Component({
selector: 'app-reg-controle-step',
imports: [AlertComponent, DataRowComponent, ReviewSectionComponent],
template: `
<app-alert type="info" i18n="@@regWizard.controleer"
>Controleer uw gegevens en dien de registratie in.</app-alert
>
<app-review-section
i18n-heading="@@regWizard.sectie.adres"
heading="Adres en correspondentie"
i18n-editAriaLabel="@@regWizard.adresWijzigenAria"
editAriaLabel="Wijzigen adresgegevens"
(edit)="edit.emit(0)"
>
<div
app-data-row
i18n-key="@@regWizard.summary.adres"
key="Adres"
[value]="adresSamenvatting()"
></div>
<div
app-data-row
i18n-key="@@regWizard.summary.herkomstAdres"
key="Herkomst adres"
[value]="adresHerkomstLabel()"
></div>
<div
app-data-row
i18n-key="@@regWizard.summary.correspondentie"
key="Correspondentie"
[value]="correspondentieLabel()"
></div>
@if (draft().correspondentie === 'email') {
<div
app-data-row
i18n-key="@@regWizard.summary.email"
key="E-mailadres"
[value]="draft().email ?? ''"
></div>
}
</app-review-section>
<app-review-section
class="app-section"
i18n-heading="@@regWizard.sectie.beroep"
heading="Beroep en diploma"
i18n-editAriaLabel="@@regWizard.diplomaWijzigenAria"
editAriaLabel="Wijzigen beroep en diploma"
(edit)="edit.emit(1)"
>
<div
app-data-row
i18n-key="@@regWizard.summary.beroep"
key="Beroep"
[value]="draft().beroep ?? ''"
></div>
<div
app-data-row
i18n-key="@@regWizard.summary.herkomstDiploma"
key="Herkomst diploma"
[value]="diplomaHerkomstLabel()"
></div>
@for (item of samenvattingVragen(); track item.vraag) {
<div app-data-row [key]="item.vraag" [value]="item.antwoord"></div>
}
</app-review-section>
`,
})
export class ControleStep {
private lookup = inject(RegistratieLookupStore);
draft = input.required<Draft>();
edit = output<number>();
/** Parsed DUO lookup as a plain value (or null), needed here only to resolve
the answered policy questions' text for the summary. */
protected duoData = computed<DuoLookupDto | null>(() => successOr(this.lookup.duoLookup(), null));
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: $localize`:@@regWizard.herkomst.adresBrp:Automatisch uit de BRP`,
handmatig: $localize`:@@regWizard.herkomst.adresHandmatig:Handmatig ingevoerd`,
})[this.draft().adresHerkomst ?? 'handmatig'],
);
protected correspondentieLabel = computed(
() =>
({
email: $localize`:@@regWizard.corr.email:Per e-mail`,
post: $localize`:@@regWizard.corr.post:Per post`,
})[this.draft().correspondentie ?? 'post'],
);
protected diplomaHerkomstLabel = computed(
() =>
({
duo: $localize`:@@regWizard.herkomst.diplomaDuo:Geverifieerd via DUO`,
handmatig: $localize`:@@regWizard.herkomst.diplomaHandmatig:Handmatig ingevoerd (wordt beoordeeld)`,
})[this.draft().diplomaHerkomst ?? 'handmatig'],
);
/** 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] ?? '',
}));
});
}
@@ -1,15 +1,5 @@
/* eslint-disable max-lines */ // one wizard shell for 3 steps + upload — removed by RD-23
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, JA_NEE } 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 { DataBlockComponent } from '@shared/ui/data-block/data-block.component';
import { ReviewSectionComponent } from '@shared/ui/review-section/review-section.component';
import { ConfirmationComponent } from '@shared/ui/confirmation/confirmation.component';
import {
WizardShellComponent,
@@ -18,19 +8,17 @@ import {
naarStapLabel,
} from '@shared/layout/wizard-shell/wizard-shell.component';
import { toWizardErrors } from '@shared/layout/wizard-shell/wizard-errors';
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 { whenTag } from '@shared/kernel/fp';
import { RemoteData, successOr } from '@shared/application/remote-data';
import { successOr } from '@shared/application/remote-data';
import { RegistratieLookupStore } from '@registratie/application/registratie-lookup.store';
import { DuoLookupDto, PolicyQuestionDto } from '@registratie/contracts/duo-diplomas.dto';
import { DuoLookupDto } from '@registratie/contracts/duo-diplomas.dto';
import {
RegistratieState,
RegistratieMsg,
Draft,
DraftField,
Correspondentie,
Errors,
StepId,
initial,
reduce,
@@ -38,18 +26,10 @@ import {
STEPS,
} from '@registratie/domain/registratie-wizard.machine';
import { createDraftSync } from '@registratie/application/draft-sync';
import { DocumentUploadComponent } from '@shared/ui/upload/document-upload/document-upload.component';
import { createUploadController } from '@shared/application/upload-controller';
import { UploadState, initialUpload, deliveryRefs } from '@shared/domain/upload.machine';
const KANALEN = [
{ value: 'email', label: $localize`:@@registratie.kanaalEmail:E-mail` },
{ value: 'post', label: $localize`:@@registratie.kanaalPost:Post` },
];
const HANDMATIG = '__handmatig__'; // sentinel option:"my diploma isn't listed"
/** The server-owned geldigheidsvraag whose"ja" answer requires a Dutch-taalvaardigheid
upload (proof of the confirmed B2 level). Stable id shared with the backend. */
const NL_TAALVAARDIGHEID_VRAAG = 'nl-taalvaardigheid';
import { AdresStep } from './adres.step';
import { BeroepStep, HANDMATIG } from './beroep.step';
import { ControleStep } from './controle.step';
/** 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
@@ -61,21 +41,12 @@ const NL_TAALVAARDIGHEID_VRAAG = 'nl-taalvaardigheid';
@Component({
selector: 'app-registratie-wizard',
imports: [
FormsModule,
FormFieldComponent,
TextInputComponent,
RadioGroupComponent,
ButtonComponent,
AlertComponent,
SkeletonComponent,
DataRowComponent,
DataBlockComponent,
ReviewSectionComponent,
ConfirmationComponent,
WizardShellComponent,
AddressFieldsComponent,
DocumentUploadComponent,
...ASYNC,
AdresStep,
BeroepStep,
ControleStep,
],
template: `
<app-wizard-shell
@@ -98,253 +69,31 @@ const NL_TAALVAARDIGHEID_VRAAG = 'nl-taalvaardigheid';
>
@switch (step()) {
@case ('adres') {
@if (adresStatus() === 'laden') {
<app-skeleton height="2.5rem" [count]="4" />
} @else {
@switch (adresStatus()) {
@case ('gevonden') {
<app-alert type="info" i18n="@@regWizard.brpGevonden"
>Vooraf ingevuld op basis van de BRP. Controleer en pas zo nodig aan.</app-alert
>
}
@case ('geen') {
<app-alert type="warning" i18n="@@regWizard.brpGeen"
>We vonden geen adres in de BRP. Vul uw adres hieronder handmatig in.</app-alert
>
}
@case ('fout') {
<app-alert type="warning" i18n="@@regWizard.brpFout"
>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)"
/>
<fieldset>
<app-form-field
i18n-label="@@regWizard.correspondentieLabel"
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>
</fieldset>
@if (draft().correspondentie === 'email') {
<fieldset>
<app-form-field
i18n-label="@@regWizard.emailLabel"
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"
i18n-placeholder="@@regWizard.emailPlaceholder"
placeholder="naam@voorbeeld.nl"
/>
</app-form-field>
</fieldset>
}
}
<app-reg-adres-step
[draft]="draft()"
[errors]="errors()"
(fieldChange)="dispatch({ tag: 'SetField', key: $event.key, value: $event.value })"
(kanaalChange)="onKanaalChange($event)"
/>
}
@case ('beroep') {
<app-async [data]="lookupRd()">
<ng-template appAsyncLoaded>
@if (duoData(); as data) {
<fieldset>
<app-form-field
i18n-label="@@regWizard.diplomaLabel"
label="Kies het diploma waarmee u zich wilt registreren"
fieldId="diploma"
required
[error]="err('diploma')"
>
<app-radio-group
name="diploma"
[options]="diplomaOptions(data)"
[invalid]="!!err('diploma')"
[ngModel]="diplomaKeuze()"
(ngModelChange)="onDiplomaKeuze(data, $event)"
/>
</app-form-field>
</fieldset>
@if (handmatigActief()) {
<app-alert type="warning" i18n="@@regWizard.handmatigWaarschuwing"
>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
>
<fieldset>
<app-form-field
i18n-label="@@regWizard.beroepLabel"
label="Voor welk beroep wilt u zich registreren?"
fieldId="hm-beroep"
[error]="err('diploma')"
>
<app-radio-group
name="hm-beroep"
[options]="beroepOptions(data)"
[invalid]="!!err('diploma')"
[ngModel]="draft().beroep ?? ''"
(ngModelChange)="dispatch({ tag: 'DeclareerBeroep', beroep: $event })"
/>
</app-form-field>
</fieldset>
} @else if (draft().beroep) {
<app-data-block class="app-section">
<div
app-data-row
i18n-key="@@regWizard.beroepAfgeleid"
key="Beroep (afgeleid uit diploma)"
[value]="draft().beroep ?? ''"
></div>
</app-data-block>
}
@if (actieveVragen(data).length) {
<fieldset>
@for (q of actieveVragen(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]="antwoord(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]="antwoord(q.id)"
(ngModelChange)="
dispatch({ tag: 'SetAntwoord', vraagId: q.id, value: $event })
"
[ngModelOptions]="{ standalone: true }"
/>
}
</app-form-field>
}
</fieldset>
}
}
</ng-template>
<ng-template appAsyncLoading>
<app-skeleton height="2.5rem" [count]="3" />
</ng-template>
</app-async>
<app-document-upload
class="app-section"
[state]="upload()"
[previewUrlFor]="previewUrlFor"
(fileSelected)="uploadCtl.onFileSelected($event.categoryId, $event.files)"
(removeUpload)="uploadCtl.onRemove($event)"
(retryUpload)="uploadCtl.onRetry($event)"
(deleteUpload)="uploadCtl.onDelete($event)"
(channelChange)="uploadCtl.onChannelChange($event.categoryId, $event.channel)"
<app-reg-beroep-step
[draft]="draft()"
[errors]="errors()"
[upload]="upload()"
(uploadMsg)="dispatch({ tag: 'Upload', msg: $event })"
(antwoordChange)="
dispatch({ tag: 'SetAntwoord', vraagId: $event.vraagId, value: $event.value })
"
(diplomaChosen)="onDiplomaKeuze($event)"
(beroepDeclared)="dispatch({ tag: 'DeclareerBeroep', beroep: $event })"
/>
@if (err('documenten')) {
<app-alert type="warning">{{ err('documenten') }}</app-alert>
}
}
@case ('controle') {
<app-alert type="info" i18n="@@regWizard.controleer"
>Controleer uw gegevens en dien de registratie in.</app-alert
>
<app-review-section
i18n-heading="@@regWizard.sectie.adres"
heading="Adres en correspondentie"
i18n-editAriaLabel="@@regWizard.adresWijzigenAria"
editAriaLabel="Wijzigen adresgegevens"
(edit)="dispatch({ tag: 'GaNaarStap', cursor: 0 })"
>
<div
app-data-row
i18n-key="@@regWizard.summary.adres"
key="Adres"
[value]="adresSamenvatting()"
></div>
<div
app-data-row
i18n-key="@@regWizard.summary.herkomstAdres"
key="Herkomst adres"
[value]="adresHerkomstLabel()"
></div>
<div
app-data-row
i18n-key="@@regWizard.summary.correspondentie"
key="Correspondentie"
[value]="correspondentieLabel()"
></div>
@if (draft().correspondentie === 'email') {
<div
app-data-row
i18n-key="@@regWizard.summary.email"
key="E-mailadres"
[value]="draft().email ?? ''"
></div>
}
</app-review-section>
<app-review-section
class="app-section"
i18n-heading="@@regWizard.sectie.beroep"
heading="Beroep en diploma"
i18n-editAriaLabel="@@regWizard.diplomaWijzigenAria"
editAriaLabel="Wijzigen beroep en diploma"
(edit)="dispatch({ tag: 'GaNaarStap', cursor: 1 })"
>
<div
app-data-row
i18n-key="@@regWizard.summary.beroep"
key="Beroep"
[value]="draft().beroep ?? ''"
></div>
<div
app-data-row
i18n-key="@@regWizard.summary.herkomstDiploma"
key="Herkomst diploma"
[value]="diplomaHerkomstLabel()"
></div>
@for (item of samenvattingVragen(); track item.vraag) {
<div app-data-row [key]="item.vraag" [value]="item.antwoord"></div>
}
</app-review-section>
<app-reg-controle-step
[draft]="draft()"
(edit)="dispatch({ tag: 'GaNaarStap', cursor: $event })"
/>
}
}
@@ -381,16 +130,9 @@ export class RegistratieWizardComponent {
},
});
/** Preview/download link for a completed upload; delegates to the upload
controller (application layer), which knows the dev-simulation `demo-*` ids
have no stored bytes and returns no link for them. */
protected previewUrlFor = (documentId: string): string | undefined =>
this.uploadCtl.previewUrlFor(documentId);
/** Optional seed so Storybook / tests can mount any state directly. */
seed = input<RegistratieState>(initial);
readonly kanalen = KANALEN;
readonly stepLabels = [
$localize`:@@regWizard.step.adres:Adres`,
$localize`:@@regWizard.step.beroep:Beroep`,
@@ -407,19 +149,8 @@ export class RegistratieWizardComponent {
private invullen = computed(() => whenTag(this.state(), 'Invullen'));
protected cursor = computed(() => this.invullen()?.cursor ?? 0);
protected draft = computed<Draft>(() => this.invullen()?.draft ?? { antwoorden: {} });
protected errors = computed<Errors>(() => this.invullen()?.errors ?? {});
protected upload = computed<UploadState>(() => this.invullen()?.upload ?? initialUpload);
protected uploadCtl = createUploadController({
wizardId: 'registratie',
getUpload: () => this.upload(),
dispatch: (msg) => this.dispatch({ tag: 'Upload', msg }),
// Required documents depend on answers (server decides): a diploma upload only for a
// manual diploma; a Dutch-taalvaardigheid upload only once the applicant confirms
// ("ja") the B2 language requirement.
getCategoryParams: () => ({
diplomaHerkomst: this.draft().diplomaHerkomst,
taalvaardigheid: this.draft().antwoorden[NL_TAALVAARDIGHEID_VRAAG],
}),
});
// Backend draft-sync (replaces sessionStorage): create a Concept once the user has
// made progress, then debounced-sync the whole machine snapshot; resume by `?aanvraag`.
private draftSync = createDraftSync({
@@ -471,101 +202,16 @@ export class RegistratieWizardComponent {
const e = this.invullen()?.errors ?? {};
return [...toWizardErrors(e), ...toWizardErrors(e.antwoorden ?? {}, 'vraag-')];
});
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: $localize`:@@regWizard.herkomst.adresBrp:Automatisch uit de BRP`,
handmatig: $localize`:@@regWizard.herkomst.adresHandmatig:Handmatig ingevoerd`,
})[this.draft().adresHerkomst ?? 'handmatig'],
);
protected correspondentieLabel = computed(
() =>
({
email: $localize`:@@regWizard.corr.email:Per e-mail`,
post: $localize`:@@regWizard.corr.post:Per post`,
})[this.draft().correspondentie ?? 'post'],
);
protected diplomaHerkomstLabel = computed(
() =>
({
duo: $localize`:@@regWizard.herkomst.diplomaDuo:Geverifieerd via DUO`,
handmatig: $localize`:@@regWizard.herkomst.diplomaHandmatig:Handmatig ingevoerd (wordt beoordeeld)`,
})[this.draft().diplomaHerkomst ?? 'handmatig'],
);
/** BRP lookup outcome (laden/gevonden/geen/fout) and the parsed DUO lookup, both
served by the application facade — the wizard renders, it does not fetch/parse. */
protected adresStatus = this.lookup.adresStatus;
protected lookupRd: () => RemoteData<Error | undefined, DuoLookupDto> = this.lookup.duoLookup;
/** Parsed lookup as a plain value (or null) — needed here only to resolve
`onDiplomaKeuze`'s message from an id (the DUO payload maps an id to a
beroep and its question ids; that is machine-message construction, and it
belongs in the container, not the beroep step). */
protected duoData = computed<DuoLookupDto | null>(() => successOr(this.lookup.duoLookup(), null));
/** 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, and
inside it too: `<ng-template appAsyncLoaded>`'s own context can't inherit a
generic from the sibling [data] input (Angular only infers a structural
directive's type parameter from an input on that same node). */
protected duoData = computed<DuoLookupDto | null>(() => successOr(this.lookupRd(), null));
readonly jaNee = JA_NEE;
protected err = (k: DraftField | 'correspondentie' | 'diploma' | 'documenten') =>
this.invullen()?.errors[k] ?? '';
protected vraagErr = (id: string) => this.invullen()?.errors.antwoorden?.[id] ?? '';
protected antwoord = (id: string) => this.draft().antwoorden[id] ?? ''; // runtime guard: missing key → undefined
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: $localize`:@@regWizard.diplomaNietBij: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(() => {
protected onDiplomaKeuze(id: string) {
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 (!data) return;
if (id === HANDMATIG) {
this.dispatch({
tag: 'KiesHandmatig',
@@ -583,6 +229,12 @@ export class RegistratieWizardComponent {
});
}
/** Narrows the beroep step's plain-string `kanaalChange` into the machine's
`Correspondentie` union before dispatching. */
protected onKanaalChange(value: string) {
this.dispatch({ tag: 'SetCorrespondentie', value: value as Correspondentie });
}
constructor() {
// An explicit seed (stories/tests) wins; otherwise resume from the backend draft
// (`?aanvraag=<id>`), or start fresh. Persistence is the draftSync controller's job.