diff --git a/apps/ssp/src/app/registratie/ui/registratie-wizard/adres.step.ts b/apps/ssp/src/app/registratie/ui/registratie-wizard/adres.step.ts
new file mode 100644
index 0000000..e22f7ed
--- /dev/null
+++ b/apps/ssp/src/app/registratie/ui/registratie-wizard/adres.step.ts
@@ -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') {
+
+ } @else {
+ @switch (adresStatus()) {
+ @case ('gevonden') {
+ Vooraf ingevuld op basis van de BRP. Controleer en pas zo nodig aan.
+ }
+ @case ('geen') {
+ We vonden geen adres in de BRP. Vul uw adres hieronder handmatig in.
+ }
+ @case ('fout') {
+ We konden de BRP nu niet bereiken. Vul uw adres hieronder handmatig in.
+ }
+ }
+
+
+ @if (draft().correspondentie === 'email') {
+
+ }
+ }
+ `,
+})
+export class AdresStep {
+ private lookup = inject(RegistratieLookupStore);
+
+ draft = input.required();
+ errors = input.required();
+ fieldChange = output<{ key: DraftField; value: string }>();
+ kanaalChange = output();
+
+ protected adresStatus = this.lookup.adresStatus;
+ readonly kanalen = KANALEN;
+ protected err = (k: DraftField | 'correspondentie') => this.errors()[k] ?? '';
+}
diff --git a/apps/ssp/src/app/registratie/ui/registratie-wizard/beroep.step.ts b/apps/ssp/src/app/registratie/ui/registratie-wizard/beroep.step.ts
new file mode 100644
index 0000000..6649d8e
--- /dev/null
+++ b/apps/ssp/src/app/registratie/ui/registratie-wizard/beroep.step.ts
@@ -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 `` 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: `
+
+
+ @if (duoData(); as data) {
+
+
+ @if (handmatigActief()) {
+ Een handmatig ingevoerd diploma kan niet automatisch worden geverifieerd. Kies uw
+ beroep en beantwoord de aanvullende vragen; uw aanvraag wordt daarna handmatig
+ beoordeeld.
+
+ } @else if (draft().beroep) {
+
+
+
+ }
+
+ @if (actieveVragen(data).length) {
+
+ }
+ }
+
+
+
+
+
+
+
+ @if (err('documenten')) {
+ {{ err('documenten') }}
+ }
+ `,
+})
+export class BeroepStep {
+ private lookup = inject(RegistratieLookupStore);
+
+ draft = input.required();
+ errors = input.required();
+ upload = input.required();
+
+ uploadMsg = output();
+ antwoordChange = output<{ vraagId: string; value: string }>();
+ diplomaChosen = output();
+ beroepDeclared = output();
+
+ /** 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 = this.lookup.duoLookup;
+ protected duoData = computed(() => 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 ?? [];
+ };
+}
diff --git a/apps/ssp/src/app/registratie/ui/registratie-wizard/controle.step.ts b/apps/ssp/src/app/registratie/ui/registratie-wizard/controle.step.ts
new file mode 100644
index 0000000..02614c1
--- /dev/null
+++ b/apps/ssp/src/app/registratie/ui/registratie-wizard/controle.step.ts
@@ -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: `
+ Controleer uw gegevens en dien de registratie in.
+
+
+
+
+ @if (draft().correspondentie === 'email') {
+
+ }
+
+
+
+
+ @for (item of samenvattingVragen(); track item.vraag) {
+
+ }
+
+ `,
+})
+export class ControleStep {
+ private lookup = inject(RegistratieLookupStore);
+
+ draft = input.required();
+ edit = output();
+
+ /** 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(() => 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] ?? '',
+ }));
+ });
+}
diff --git a/apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts b/apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts
index 21e75b4..09e78c3 100644
--- a/apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts
+++ b/apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts
@@ -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: `
@switch (step()) {
@case ('adres') {
- @if (adresStatus() === 'laden') {
-
- } @else {
- @switch (adresStatus()) {
- @case ('gevonden') {
- Vooraf ingevuld op basis van de BRP. Controleer en pas zo nodig aan.
- }
- @case ('geen') {
- We vonden geen adres in de BRP. Vul uw adres hieronder handmatig in.
- }
- @case ('fout') {
- We konden de BRP nu niet bereiken. Vul uw adres hieronder handmatig
- in.
- }
- }
-
-
- @if (draft().correspondentie === 'email') {
-
- }
- }
+
}
@case ('beroep') {
-
-
- @if (duoData(); as data) {
-
-
- @if (handmatigActief()) {
- Een handmatig ingevoerd diploma kan niet automatisch worden geverifieerd. Kies
- uw beroep en beantwoord de aanvullende vragen; uw aanvraag wordt daarna
- handmatig beoordeeld.
-
- } @else if (draft().beroep) {
-
-
-
- }
-
- @if (actieveVragen(data).length) {
-
- }
- }
-
-
-
-
-
-
-
- @if (err('documenten')) {
- {{ err('documenten') }}
- }
}
@case ('controle') {
- Controleer uw gegevens en dien de registratie in.
-
-
-
-
- @if (draft().correspondentie === 'email') {
-
- }
-
-
-
-
- @for (item of samenvattingVragen(); track item.vraag) {
-
- }
-
+
}
}
@@ -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(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(() => this.invullen()?.draft ?? { antwoorden: {} });
+ protected errors = computed(() => this.invullen()?.errors ?? {});
protected upload = computed(() => 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 = 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(() => successOr(this.lookup.duoLookup(), null));
- /** Parsed lookup as a plain value (or null) — used outside the beroep step (the
- controle summary) where the template variable isn't in scope, and
- inside it too: ``'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(() => 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=`), or start fresh. Persistence is the draftSync controller's job.
diff --git a/docs/project/readable-codebase/RD-23-registratie-steps.md b/docs/project/readable-codebase/RD-23-registratie-steps.md
new file mode 100644
index 0000000..5477224
--- /dev/null
+++ b/docs/project/readable-codebase/RD-23-registratie-steps.md
@@ -0,0 +1,235 @@
+# RD-23 — Split `registratie-wizard` into three steps, and move the upload controller
+
+Status: done
+Source: PLAN.md 3c, order step 5
+
+## Why
+
+`registratie-wizard.component.ts` measures ~568 effective lines against a limit of 250 — the
+largest file in the arc, and more than twice the budget. It carries
+`/* eslint-disable max-lines */`.
+
+It is the same shape as RD-22's intake wizard: one `@switch`, three `@case` blocks, three
+screens in one file. It is harder in one way that PLAN calls out — **moving the upload
+controller is what gets the parent under 250**, and the controller is a stateful thing, not
+markup.
+
+RD-22 already set the `*.step.ts` convention. Follow it.
+
+## Read first
+
+- `apps/ssp/src/app/herregistratie/ui/intake-wizard/buitenland.step.ts` and `review.step.ts` —
+ **the shape to copy.** RD-22 built them one ticket ago; match their header comments, their
+ `input.required` style and their output naming.
+- `registratie-wizard.component.ts:100-348` — the three `@case` blocks.
+- `registratie-wizard.component.ts:411-422` — `createUploadController`, and the `dispatch`
+ callback that decision 4 rewires.
+- `registratie-wizard.component.ts:568-585` — `onDiplomaKeuze`, which decision 5 reshapes.
+- `apps/ssp/src/app/registratie/ui/address-fields/address-fields.component.ts:13-18` — the
+ contract the whole family follows.
+
+## Decisions (pre-made, don't relitigate)
+
+1. **Three new files beside the parent, named as RD-22 named its own:**
+
+ | File | Class | Selector | Case |
+ | ------------------ | -------------- | ----------------------- | ------------- |
+ | `adres.step.ts` | `AdresStep` | `app-reg-adres-step` | lines 100-175 |
+ | `beroep.step.ts` | `BeroepStep` | `app-reg-beroep-step` | lines 176-285 |
+ | `controle.step.ts` | `ControleStep` | `app-reg-controle-step` | lines 286-348 |
+
+2. **A step may inject `RegistratieLookupStore` directly. This is the sanctioned exception.**
+ It is `providedIn: 'root'`, so every injection is the same instance, and PLAN names this "the
+ one place the dashboard's axis does apply": the step owns its own async presentation rather
+ than making the parent a pass-through for four lookup signals.
+
+ - `adres` injects it for `adresStatus` (the BRP lookup banner).
+ - `beroep` injects it for the DUO lookup, and owns its own `` over it.
+ - `controle` injects it to build `samenvattingVragen`.
+
+ The parent keeps its own injection too — the BRP prefill effect needs it (decision 6).
+
+3. **Inputs down, narrow outputs up, `dispatch` never passed down:**
+
+ | Step | Inputs | Outputs |
+ | ---------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
+ | `adres` | `draft`, `errors` | `fieldChange: { key: DraftField; value: string }`, `kanaalChange: string` |
+ | `beroep` | `draft`, `errors`, `upload` | `uploadMsg: UploadMsg`, `antwoordChange: { vraagId: string; value: string }`, `diplomaChosen: string`, `beroepDeclared: string` |
+ | `controle` | `draft` | `edit: number` |
+
+ Four outputs on `beroep` is correct: they are four distinct user intents, and each maps to one
+ message in the parent. That is not the same thing as handing the step a `dispatch`.
+
+4. **The upload controller moves into `beroep.step.ts` and emits instead of dispatching.**
+ `createUploadController` takes a `dispatch` callback, so the step builds its own:
+
+ ```ts
+ protected uploadCtl = createUploadController({
+ wizardId: 'registratie',
+ getUpload: () => this.upload(),
+ dispatch: (msg) => this.uploadMsg.emit(msg),
+ getCategoryParams: () => ({ … }), // unchanged, reads this.draft()
+ });
+ ```
+
+ The parent maps it back with `(uploadMsg)="dispatch({ tag: 'Upload', msg: $event })"`. This
+ is what collapses five template handlers into one output. `previewUrlFor` moves with the
+ controller — it is `uploadCtl.previewUrlFor` and the child takes it as a function reference.
+
+5. **`onDiplomaKeuze` stays in the parent, and loses its `data` parameter.** The step emits only
+ the chosen id (`diplomaChosen`). The parent keeps its `duoData` computed and reads it inside
+ the method instead of receiving it as an argument:
+
+ ```ts
+ protected onDiplomaKeuze(id: string) {
+ const data = this.duoData();
+ if (!data) return;
+ … // body otherwise unchanged
+ }
+ ```
+
+ Building a `KiesDiploma`/`KiesHandmatig` message needs the DUO payload to map an id to a
+ beroep and its question ids. That is machine-message construction, and it belongs in the
+ container.
+
+6. **The BRP prefill `effect` stays in the parent**, exactly as written, including its
+ `untracked` call. It writes to the machine, so it belongs where the machine lives. Do not move
+ it into `adres.step.ts`.
+
+7. **The parent keeps** the shell wiring, the store and its effect map, `draftSync`, the seed
+ constructor, `phase`, `primaryLabel`, `stepTitle`, `stepLabels`, `errorList`, `referentie`,
+ `cursor`, `step`, `draft`, `upload`, `duoData`, `onDiplomaKeuze`, and the `wizardSuccess`
+ block. It gains one computed, as RD-22's parent did:
+
+ ```ts
+ protected errors = computed(() => this.invullen()?.errors ?? {});
+ ```
+
+ Everything else in the list below moves out with the markup that used it: `uploadCtl`,
+ `previewUrlFor`, `kanalen`, `err`, `vraagErr`, `antwoord`, `set`, `setKanaal`,
+ `handmatigActief`, `diplomaKeuze`, `diplomaOptions`, `beroepOptions`, `actieveVragen`,
+ `samenvattingVragen`, `adresSamenvatting`, `adresHerkomstLabel`, `correspondentieLabel`,
+ `diplomaHerkomstLabel`, `adresStatus`, `lookupRd`.
+
+8. **Delete `/* eslint-disable max-lines */` from the parent.** Mandatory:
+ `reportUnusedDisableDirectives` is `error`, so the two rules pin each other in both
+ directions.
+
+9. **No stories for the new steps** (PLAN's corollary). `registratie-wizard.stories.ts` already
+ mounts every step by seeding the machine, and the parent's public API does not move.
+
+10. **Move the markup, do not improve it.** Every `i18n` id, label, placeholder, `fieldId` and
+ `aria` string stays byte-identical. `Errors` is already exported from the machine — RD-20
+ made it a type alias — so no machine change is needed this time.
+
+## Files
+
+- `apps/ssp/src/app/registratie/ui/registratie-wizard/adres.step.ts` (new)
+- `apps/ssp/src/app/registratie/ui/registratie-wizard/beroep.step.ts` (new)
+- `apps/ssp/src/app/registratie/ui/registratie-wizard/controle.step.ts` (new)
+- `apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts`
+
+No machine file changes. No shared-library changes.
+
+## Steps
+
+1. Write `adres.step.ts` — the smallest, and the one that proves the injection pattern.
+2. Write `controle.step.ts` — read-only markup plus one output.
+3. Write `beroep.step.ts` last: it carries the async lookup, the policy questions and the upload
+ controller.
+4. Replace the `@switch` with the three elements and wire the outputs per decision 3.
+5. Delete the members listed in decision 7, add the `errors` computed, reshape `onDiplomaKeuze`
+ per decision 5, prune `imports:`.
+6. Delete the disable (decision 8).
+7. `git add -A`, then run the acceptance commands.
+8. Update this ticket's `Status:` to `done` and the README's RD-23 row to `done`.
+9. Commit all of it together.
+
+## Acceptance criteria
+
+Measured against the tree before handover. Run after `git add -A`.
+
+```bash
+D=apps/ssp/src/app/registratie/ui/registratie-wizard
+P=$D/registratie-wizard.component.ts
+git ls-files "$D/*.step.ts" | wc -l # is 0 -> MUST be 3
+git grep -c "eslint-disable max-lines" -- $P # is 1 -> MUST be 0
+```
+
+The upload controller moved, and the store did not follow the markup down:
+
+```bash
+git grep -c "uploadCtl" -- $P # is 7 -> MUST be 0
+git grep -c "createUploadController" -- $D/beroep.step.ts # MUST be 2
+git grep -c "dispatch" -- "$D/*.step.ts" | awk -F: '{s+=$NF} END {print s+0}' # MUST be 1
+```
+
+Two corrections found while running these before handover (recorded here per the README's
+rule 4 on ticket-writing misses):
+
+- `createUploadController` is **2**, not 1: `git grep -c` counts matching lines, and an
+ import plus its one call site are always two lines (same shape as `createStore` in
+ `intake-wizard.component.ts`, which is 2, and `createDraftSync` in this same parent, which
+ is 3). A count of 1 is unreachable without an import alias that would exist only to dodge
+ the check.
+- `dispatch` is **1**, not 0: decision 4's mandated snippet is
+ `dispatch: (msg) => this.uploadMsg.emit(msg),` — the `UploadControllerDeps.dispatch`
+ property name is not the machine's `dispatch`, but it is the same string. Satisfying
+ decision 4 verbatim and satisfying a target of 0 are mutually exclusive.
+
+The three per-step concerns left the parent:
+
+```bash
+git grep -c "adresStatus" -- $P # is 3 -> MUST be 0
+git grep -c "samenvattingVragen" -- $P # is 2 -> MUST be 0
+git grep -c "previewUrlFor" -- $P # is 3 -> MUST be 0
+```
+
+The copy did not drift (decision 10):
+
+```bash
+git grep -ho "@@[a-zA-Z0-9_.]*" -- $D/ | sort -u | wc -l # is 43 -> MUST still be 43
+```
+
+```bash
+npm run ci --full # exits 0
+```
+
+## Verification
+
+The `@@` id count is **43** across the whole `registratie-wizard/` directory, so the three new
+files are included. `ng build --localize` fails on an id that is _added_ without a translation
+but never on one silently _lost_; the count is the only check that catches a loss.
+
+**`--full` is required.** The existing story mounts all three steps, and the axe run over it is
+what proves the projected markup kept its labels, its error wiring and its `aria` strings.
+
+**Do not add a line-count command.** `npm run lint` is the exact check (decision 8).
+
+If `dotnet test` fails with `SQLite Error 1: 'no such table: …'`, that is the stale-database
+trap, not your change. See the README's Troubleshooting section — RD-22 hit it.
+
+## Out of scope
+
+- `herregistratie-wizard`. PLAN: do not split it for symmetry.
+- Changing the upload controller itself, or `upload.machine.ts`.
+- Moving the BRP prefill effect (decision 6).
+- Adding stories (decision 9).
+- Any validation, message or `i18n` change.
+
+## Risks
+
+- **The upload controller is the hard part, and the reason this ticket exists.** Five template
+ handlers become one `uploadMsg` output. Get the `dispatch: (msg) => this.uploadMsg.emit(msg)`
+ wiring right and the rest is markup movement.
+- **`previewUrlFor` is passed to a child as a function reference**, not called in the template.
+ Keep it an arrow property on the step, or the binding silently loses its `this`.
+- **`onDiplomaKeuze` must not move into the step** (decision 5). It builds machine messages from
+ the DUO payload.
+- **Four outputs on `beroep` is the design, not a smell** (decision 3). Do not collapse them into
+ a single message-shaped output — that is passing `dispatch` up under another name, and it
+ moves message construction into the step.
+- **Deleting the disable is mandatory** (decision 8); its failure message reads like an
+ unrelated error.
+- **This is the largest single diff in the arc.** Work step by step in the order given, and let
+ the type-checker confirm each before moving on.
diff --git a/docs/project/readable-codebase/README.md b/docs/project/readable-codebase/README.md
index f056f86..97189bb 100644
--- a/docs/project/readable-codebase/README.md
+++ b/docs/project/readable-codebase/README.md
@@ -117,7 +117,7 @@ two. Note that RD-15 exists because 22 abandoned agent worktrees are still on di
| RD-20 | `wizard-errors.ts` + spec, adopted by all 3 wizards | 02 | | done |
| RD-21 | `rich-text-dom.ts` helpers + spec cases | 02 | yes | done |
| RD-22 | `intake-wizard` to 3 step components | 08, 20 | yes | done |
-| RD-23 | `registratie-wizard` to 3 steps + the upload-controller move | 08, 20 | yes | todo |
+| RD-23 | `registratie-wizard` to 3 steps + the upload-controller move | 08, 20 | yes | done |
| RD-24 | `concepts.page` to 6 sections + `concept-card` + globals + code tokens | 02 | yes | todo |
| RD-25 | `org-template-editor` to `sample-letter.ts` + labels + 2 children | 02 | yes | todo |
| RD-26 | `letter-canvas`: inline the labels + `letter-line`; keep one disable | 02 | yes | todo |