import { Component, computed, effect, inject, input, untracked } from '@angular/core'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component'; import { ConfirmationComponent } from '@shared/ui/molecules/confirmation/confirmation.component'; import { WizardShellComponent, WizardError, WizardPhase, naarStapLabel, } from '@shared/layout/wizard-shell/wizard-shell.component'; import { toWizardErrors } from '@shared/layout/wizard-shell/wizard-errors'; import { createStore } from '@shared/application/store'; import { whenTag } from '@shared/kernel/fp'; import { successOr } from '@shared/application/remote-data'; import { RegistratieLookupStore } from '@registratie/application/registratie-lookup.store'; import { DuoLookupDto } from '@registratie/contracts/duo-diplomas.dto'; import { RegistratieState, RegistratieMsg, Draft, Correspondentie, Errors, StepId, initial, reduce, hasProgress, STEPS, } from '@registratie/domain/registratie-wizard.machine'; import { createDraftSync } from '@registratie/application/draft-sync'; import { UploadState, initialUpload, deliveryRefs } from '@shared/domain/upload.machine'; 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 draft via an effect; the DUO diploma list renders through ; choosing a diploma reveals its server-derived beroep. The draft is persisted to the backend as a Concept aanvraag (createDraftSync) so a reload — or a"Verder gaan" from the dashboard via `?aanvraag=` — resumes progress. Built from existing atoms/molecules. */ @Component({ selector: 'app-registratie-wizard', imports: [ ButtonComponent, ConfirmationComponent, WizardShellComponent, AdresStep, BeroepStep, ControleStep, ], template: ` @switch (step()) { @case ('adres') { } @case ('beroep') { } @case ('controle') { } }

Uw referentienummer is {{ referentie() }}. Bewaar dit nummer voor uw administratie.

Nieuwe registratie starten
`, }) export class RegistratieWizardComponent { private lookup = inject(RegistratieLookupStore); // Effect fires once, on Invullen -> Indienen (RD-05's tag-transition rule; `Seed` is // exempt, so a story mounting straight into `Indienen` does not call the network). private store = createStore(initial, reduce, { Indienen: async (s, store) => { const r = await this.draftSync.submit({ diplomaHerkomst: s.data.diplomaHerkomst, documents: s.data.documents, }); if (r.ok) store.dispatch({ tag: 'SubmitConfirmed', referentie: r.value.referentie ?? '' }); else store.dispatch({ tag: 'SubmitFailed', error: r.error }); }, }); /** Optional seed so Storybook / tests can mount any state directly. */ seed = input(initial); readonly stepLabels = [ $localize`:@@regWizard.step.adres:Adres`, $localize`:@@regWizard.step.beroep:Beroep`, $localize`:@@regWizard.step.controle:Controle`, ]; // short labels for the stepper private stepTitles = [ $localize`:@@regWizard.title.adres:Adres en correspondentievoorkeur`, $localize`:@@regWizard.title.beroep:Beroep op basis van uw diploma`, $localize`:@@regWizard.title.controle:Controleren en indienen`, ]; readonly state = this.store.model; readonly dispatch = this.store.dispatch; 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); // 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({ type: 'registratie', snapshot: () => { const s = this.state(); if (s.tag !== 'Invullen' || !hasProgress(s)) return null; const documentIds = deliveryRefs(s.upload) .filter((r) => r.channel === 'digital' && r.documentId) .map((r) => r.documentId!); return { draft: s, stepIndex: s.cursor, stepCount: STEPS.length, documentIds }; }, onResume: (draft) => this.dispatch({ tag: 'Seed', state: draft as RegistratieState }), enabled: () => this.seed() === initial, }); protected step = computed(() => STEPS[Math.min(this.cursor(), STEPS.length - 1)]); protected stepTitle = computed( () => this.stepTitles[Math.min(this.cursor(), this.stepTitles.length - 1)], ); protected referentie = computed(() => whenTag(this.state(), 'Ingediend')?.referentie ?? ''); // --- Presentational wiring for the shared wizard shell --------------------- protected primaryLabel = computed(() => { if (this.step() === 'controle') return $localize`:@@regWizard.indienen:Registratie indienen`; const next = this.cursor() + 1; return naarStapLabel(next + 1, this.stepLabels[next]); }); /** Maps this machine's own tags onto the shell's `WizardPhase` vocabulary, composing the localized failure prefix so the `Failed` message arrives intact. */ protected phase = computed(() => { const s = this.state(); switch (s.tag) { case 'Invullen': return { tag: 'Editing' }; case 'Indienen': return { tag: 'Submitting' }; case 'Ingediend': return { tag: 'Submitted' }; case 'Mislukt': return { tag: 'Failed', message: $localize`:@@regWizard.indienenMislukt:Het indienen is niet gelukt:` + ` ${s.error}`, }; } }); /** Current step's errors (incl. per-question), flattened for the error summary. */ protected errorList = computed(() => { const e = this.invullen()?.errors ?? {}; return [...toWizardErrors(e), ...toWizardErrors(e.antwoorden ?? {}, 'vraag-')]; }); /** 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)); protected onDiplomaKeuze(id: string) { const data = this.duoData(); if (!data) return; if (id === HANDMATIG) { this.dispatch({ tag: 'KiesHandmatig', vraagIds: data.handmatig.policyQuestions.map((q) => q.id), }); return; } const d = data.diplomas.find((x) => x.id === id); if (d) this.dispatch({ tag: 'KiesDiploma', diplomaId: d.id, beroep: d.beroep, vraagIds: d.policyQuestions.map((q) => q.id), }); } /** 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. const seeded = this.seed(); queueMicrotask(() => seeded !== initial ? this.dispatch({ tag: 'Seed', state: seeded }) : this.draftSync.resume(), ); // Prefill the address from the BRP lookup as it arrives. Track only the facade's // parsed prefill signal; untrack the dispatch (it reads the state signal, which // would otherwise make this effect loop on its own write). Don't clobber // edits/restored data. A null prefill (loading/error/geen adres) leaves manual entry. effect(() => { const a = this.lookup.prefillAdres(); if (!a) return; untracked(() => { const s = this.state(); if (s.tag !== 'Invullen' || s.draft.straat) return; this.dispatch({ tag: 'PrefillAdres', straat: a.straat, postcode: a.postcode, woonplaats: a.woonplaats, }); }); }); // A11y: focus management (step heading on step change, error summary on a // failed submit) now lives in the shared WizardShellComponent. } /** Reset the wizard to a fresh start. Reload the BRP lookup so the address re-prefills, keeping the form and the"vooraf ingevuld" note consistent. */ restart() { this.draftSync.reset(); // discard the current Concept; a fresh one starts on next progress this.dispatch({ tag: 'Seed', state: initial }); this.lookup.reloadAdres(); } }