import { Component, computed, effect, inject, input, untracked } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { FormFieldComponent } from '@shared/ui/form-field/form-field.component'; import { TextInputComponent } from '@shared/ui/text-input/text-input.component'; import { RadioGroupComponent } from '@shared/ui/radio-group/radio-group.component'; import { ButtonComponent } from '@shared/ui/button/button.component'; import { AlertComponent } from '@shared/ui/alert/alert.component'; import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component'; import { DataRowComponent } from '@shared/ui/data-row/data-row.component'; import { WizardShellComponent, WizardError, WizardStatus } from '@shared/layout/wizard-shell/wizard-shell.component'; import { ASYNC } from '@shared/ui/async/async.component'; import { AddressFieldsComponent } from '@registratie/ui/address-fields/address-fields.component'; import { createStore } from '@shared/application/store'; import { RemoteData, fromResource } from '@shared/application/remote-data'; import { BrpAdapter, parseBrpAddress } from '@registratie/infrastructure/brp.adapter'; import { DuoAdapter, parseDuoLookup } from '@registratie/infrastructure/duo.adapter'; import { DuoLookupDto, PolicyQuestionDto } from '@registratie/contracts/duo-diplomas.dto'; import { RegistratieState, RegistratieMsg, Draft, DraftField, Correspondentie, StepId, initial, reduce, STEPS, } from '@registratie/domain/registratie-wizard.machine'; import { submitRegistratie } from '@registratie/application/submit-registratie'; import { ApiClient } from '@shared/infrastructure/api-client'; const STORAGE_KEY = 'registratie-v1'; // ponytail: bump the suffix if the persisted shape changes; no migration. const KANALEN = [{ value: 'email', label: 'E-mail' }, { value: 'post', label: 'Post' }]; const JA_NEE = [{ value: 'ja', label: 'Ja' }, { value: 'nee', label: 'Nee' }]; const HANDMATIG = '__handmatig__'; // sentinel option: "my diploma isn't listed" /** Organism: the BIG-registration wizard. All state lives in one signal driven by the pure `reduce` (registratie-wizard.machine.ts). The BRP address prefills the draft via an effect; the DUO diploma list renders through ; choosing a diploma reveals its server-derived beroep. The draft is persisted to localStorage so a reload keeps the user's progress. Built entirely from existing atoms/molecules. */ @Component({ selector: 'app-registratie-wizard', imports: [ FormsModule, FormFieldComponent, TextInputComponent, RadioGroupComponent, ButtonComponent, AlertComponent, SkeletonComponent, DataRowComponent, WizardShellComponent, AddressFieldsComponent, ...ASYNC, ], template: ` @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 (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) {
} @for (q of actieveVragen($any(data)); track q.id) { @if (q.type === 'ja-nee') { } @else { } }
} @case ('controle') { Controleer uw gegevens en dien de registratie in.
@if (draft().correspondentie === 'email') { } @for (item of samenvattingVragen(); track item.vraag) { }
Adres wijzigen Diploma wijzigen
} }
Uw registratie is ontvangen. Uw referentienummer is {{ referentie() }}. Bewaar dit nummer voor uw administratie.
Nieuwe registratie starten
`, }) export class RegistratieWizardComponent { private brp = inject(BrpAdapter); private duo = inject(DuoAdapter); private apiClient = inject(ApiClient); private store = createStore(initial, reduce); protected adresRes = this.brp.adresResource(); private diplomasRes = this.duo.diplomasResource(); /** Optional seed so Storybook / tests can mount any state directly. */ seed = input(initial); readonly kanalen = KANALEN; readonly stepLabels = ['Adres', 'Beroep', 'Controle']; // short labels for the stepper private stepTitles = ['Adres en correspondentievoorkeur', 'Beroep op basis van uw diploma', 'Controleren en indienen']; readonly state = this.store.model; readonly dispatch = this.store.dispatch; private invullen = computed(() => (this.state().tag === 'Invullen' ? (this.state() as Extract) : null)); protected cursor = computed(() => this.invullen()?.cursor ?? 0); protected draft = computed(() => this.invullen()?.draft ?? { antwoorden: {} }); 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(() => (this.state().tag === 'Ingediend' ? (this.state() as Extract).referentie : '')); protected failedError = computed(() => (this.state().tag === 'Mislukt' ? (this.state() as Extract).error : '')); // --- Presentational wiring for the shared wizard shell --------------------- protected primaryLabel = computed(() => (this.step() === 'controle' ? 'Registratie indienen' : 'Volgende')); protected errorMessage = computed(() => `Het indienen is niet gelukt: ${this.failedError()}`); protected shellStatus = computed(() => { switch (this.state().tag) { case 'Invullen': return 'editing'; case 'Indienen': return 'submitting'; case 'Ingediend': return 'submitted'; case 'Mislukt': return 'failed'; } }); /** Current step's errors (incl. per-question), flattened for the error summary. */ protected errorList = computed(() => { const e = this.invullen()?.errors ?? {}; const out: WizardError[] = []; for (const [k, v] of Object.entries(e)) { if (k !== 'antwoorden' && typeof v === 'string' && v) out.push({ id: k, message: v }); } for (const [qid, msg] of Object.entries(e.antwoorden ?? {})) { if (msg) out.push({ id: 'vraag-' + qid, message: msg }); } return out; }); protected adresSamenvatting = computed(() => { const d = this.draft(); return [d.straat, [d.postcode, d.woonplaats].filter(Boolean).join(' ')].filter(Boolean).join(', '); }); // Readable labels for the controle summary (instead of raw enum values). protected adresHerkomstLabel = computed(() => ({ brp: 'Automatisch uit de BRP', handmatig: 'Handmatig ingevoerd' }[this.draft().adresHerkomst ?? 'handmatig'])); protected correspondentieLabel = computed(() => ({ email: 'Per e-mail', post: 'Per post' }[this.draft().correspondentie ?? 'post'])); protected diplomaHerkomstLabel = computed(() => ({ duo: 'Geverifieerd via DUO', handmatig: 'Handmatig ingevoerd (wordt beoordeeld)' }[this.draft().diplomaHerkomst ?? 'handmatig'])); /** BRP lookup outcome. A failure or "geen adres" never blocks the wizard — both fall back to manual entry (PRD §7). 'geen' covers both no-address-found and a malformed response; 'fout' is an unreachable BRP. */ protected adresStatus = computed<'laden' | 'gevonden' | 'geen' | 'fout'>(() => { const st = this.adresRes.status(); if (st === 'loading' || st === 'reloading') return 'laden'; if (st === 'error') return 'fout'; const json = this.adresRes.value(); const parsed = json !== undefined ? parseBrpAddress(json) : null; return parsed && parsed.ok && parsed.value.gevonden ? 'gevonden' : 'geen'; }); /** The DUO lookup (diplomas + manual fallback), validated at the trust boundary. */ protected lookupRd = computed>(() => { const rd = fromResource(this.diplomasRes); if (rd.tag !== 'Success') return rd; const parsed = parseDuoLookup(rd.value); return parsed.ok ? { tag: 'Success', value: parsed.value } : { tag: 'Failure', error: new Error(parsed.error) }; }); /** Parsed lookup as a plain value (or null) — used outside the beroep step (the controle summary) where the template variable isn't in scope. */ private duoData = computed(() => { const rd = this.lookupRd(); return rd.tag === 'Success' ? rd.value : null; }); readonly jaNee = JA_NEE; protected err = (k: DraftField | 'correspondentie' | 'diploma') => this.invullen()?.errors[k] ?? ''; protected vraagErr = (id: string) => this.invullen()?.errors.antwoorden?.[id] ?? ''; protected set = (key: DraftField, value: string) => this.dispatch({ tag: 'SetField', key, value }); protected setKanaal = (value: string) => this.dispatch({ tag: 'SetCorrespondentie', value: value as Correspondentie }); /** True while the user is entering a diploma manually (not in the DUO list). */ protected handmatigActief = computed(() => this.draft().diplomaHerkomst === 'handmatig'); /** The radio selection: a diploma id, or the "not listed" sentinel in manual mode. */ protected diplomaKeuze = computed(() => (this.handmatigActief() ? HANDMATIG : this.draft().diplomaId ?? '')); protected diplomaOptions = (data: DuoLookupDto) => [ ...data.diplomas.map((d) => ({ value: d.id, label: `${d.naam} — ${d.instelling} (${d.jaar})` })), { value: HANDMATIG, label: 'Mijn diploma staat er niet bij' }, ]; protected beroepOptions = (data: DuoLookupDto) => data.handmatig.beroepen.map((b) => ({ value: b, label: b })); /** The policy questions that apply to the current choice (server-decided). */ protected actieveVragen = (data: DuoLookupDto): PolicyQuestionDto[] => { if (this.handmatigActief()) return data.handmatig.policyQuestions; return data.diplomas.find((d) => d.id === this.draft().diplomaId)?.policyQuestions ?? []; }; /** Answered policy questions for the controle summary (question text + answer). */ protected samenvattingVragen = computed(() => { const data = this.duoData(); const d = this.draft(); if (!data) return [] as { vraag: string; antwoord: string }[]; const alle = [...data.diplomas.flatMap((x) => x.policyQuestions), ...data.handmatig.policyQuestions]; return (d.vraagIds ?? []).map((id) => ({ vraag: alle.find((q) => q.id === id)?.vraag ?? id, antwoord: d.antwoorden[id] ?? '' })); }); protected onDiplomaKeuze(data: DuoLookupDto, id: string) { if (id === HANDMATIG) { this.dispatch({ tag: 'KiesHandmatig', vraagIds: data.handmatig.policyQuestions.map((q) => q.id) }); return; } const d = data.diplomas.find((x) => x.id === id); if (d) this.dispatch({ tag: 'KiesDiploma', diplomaId: d.id, beroep: d.beroep, vraagIds: d.policyQuestions.map((q) => q.id) }); } constructor() { // An explicit seed (stories) wins; otherwise resume from localStorage. const seeded = this.seed(); queueMicrotask(() => this.dispatch({ tag: 'Seed', state: seeded !== initial ? seeded : (this.restore() ?? initial) })); // Persist only while filling in; clear once the flow is done. effect(() => { const s = this.state(); if (s.tag === 'Invullen') localStorage.setItem(STORAGE_KEY, JSON.stringify(s)); else localStorage.removeItem(STORAGE_KEY); }); // Prefill the address from the BRP lookup as it arrives. Track only the resource // value; untrack the dispatch (it reads the state signal, which would otherwise // make this effect loop on its own write). Don't clobber edits/restored data. effect(() => { const json = this.adresRes.value(); if (!json) return; const parsed = parseBrpAddress(json); untracked(() => { const s = this.state(); if (s.tag !== 'Invullen' || s.draft.straat) return; // ponytail: slice 2 handles gevonden === false -> manual entry stays handmatig. if (parsed.ok && parsed.value.gevonden && parsed.value.adres) { const a = parsed.value.adres; this.dispatch({ tag: 'PrefillAdres', straat: a.straat, postcode: a.postcode, woonplaats: a.woonplaats }); } }); }); // A11y: focus management (step heading on step change, error summary on a // failed submit) now lives in the shared WizardShellComponent. } private restore(): RegistratieState | null { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return null; try { return JSON.parse(raw) as RegistratieState; } catch { return null; // ponytail: corrupt entry -> start fresh, no migration. } } onPrimary() { const s = this.state(); if (s.tag !== 'Invullen') return; this.dispatch(this.step() === 'controle' ? { tag: 'Submit' } : { tag: 'Next' }); this.runIfIndienen(); } onRetry() { this.dispatch({ tag: 'Retry' }); this.runIfIndienen(); } /** Reset the wizard to a fresh start. Reload the BRP lookup so the address re-prefills, keeping the form and the "vooraf ingevuld" note consistent. */ restart() { this.dispatch({ tag: 'Seed', state: initial }); this.adresRes.reload(); } /** The effect: when we enter Indienen, call the backend, then dispatch the outcome (success carries the confirmation reference). */ private async runIfIndienen() { const s = this.state(); if (s.tag !== 'Indienen') return; const r = await submitRegistratie(this.apiClient, s.data); if (r.ok) this.dispatch({ tag: 'SubmitConfirmed', referentie: r.value }); else this.dispatch({ tag: 'SubmitFailed', error: r.error }); } }