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) { +
+ @for (q of actieveVragen(data); track q.id) { + + @if (q.type === 'ja-nee') { + + } @else { + + } + + } +
+ } + } +
+ + + +
+ + + @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) { -
- @for (q of actieveVragen(data); track q.id) { - - @if (q.type === 'ja-nee') { - - } @else { - - } - - } -
- } - } -
- - - -
- - - @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 |