Files
atomic-design-poc/apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts
T
ehoandClaude Sonnet 5 43dc3210cd refactor: move libs/shared/src/ui/ into atoms/molecules/organisms (RD-27)
The folder now equals the layer, as CLAUDE.md decision 2 requires. 33
directories move by git mv (25 flat, plus upload/'s 8 subfolders split
across all three layers). 28 distinct @shared/ui/* specifiers rewrite
across 73 files, longest-first. Five relative imports inside upload/
become @shared/ui aliases because their sibling now lives in a
different layer; two stay relative because both ends stay in the same
layer. Four .mdx docs get their seven broken story imports fixed;
atomic-design.mdx's page-shell import is untouched, because layout/
does not move.

No component, template, story title, or layer-tag comment changes.
That is RD-28's job.

Verified against the ticket's acceptance commands: the 26 flat
directories become exactly 3 layer folders with the counts the ticket
names, only three @shared/ui/* prefixes remain (atoms, molecules,
organisms), the .mdx import count holds at 7, and the relative-import
count inside ui/ drops from 7 to 2 as decision 4 requires. The
@shared/ui/ occurrence count moves from 200 to 205: decision 4
mandates turning 5 of those 7 relative imports into @shared/ui/*
aliases, which decision 3's "200 before, 200 after" check does not
account for. The 5-occurrence gap is exactly the 5 conversions decision
4 names, not a lost or duplicated specifier.

npm run ci --full passes: lint, typecheck, dep:check, format, tokens,
seam, both apps' + both libraries' tests, both apps' localized build,
audit, backend tests, all three generated-artifact drift checks, and
both Storybook instances' build + axe-core a11y suite (67+45 suites,
198+112 tests, all green).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 08:14:10 +02:00

275 lines
11 KiB
TypeScript

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 <app-async>; 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=<id>` — resumes progress. Built from existing
atoms/molecules. */
@Component({
selector: 'app-registratie-wizard',
imports: [
ButtonComponent,
ConfirmationComponent,
WizardShellComponent,
AdresStep,
BeroepStep,
ControleStep,
],
template: `
<app-wizard-shell
[steps]="stepLabels"
[current]="cursor()"
[stepTitle]="stepTitle()"
i18n-processName="@@regWizard.processName"
processName="Inschrijven in het BIG-register"
[phase]="phase()"
[primaryLabel]="primaryLabel()"
[canGoBack]="cursor() > 0"
[errors]="errorList()"
i18n-submittingLabel="@@regWizard.submitting"
submittingLabel="Uw registratie wordt verwerkt…"
(primary)="dispatch({ tag: 'Primary' })"
(back)="dispatch({ tag: 'Back' })"
(cancel)="restart()"
(retry)="dispatch({ tag: 'Retry' })"
(goToStep)="dispatch({ tag: 'GaNaarStap', cursor: $event })"
>
@switch (step()) {
@case ('adres') {
<app-reg-adres-step
[draft]="draft()"
[errors]="errors()"
(fieldChange)="dispatch({ tag: 'SetField', key: $event.key, value: $event.value })"
(kanaalChange)="onKanaalChange($event)"
/>
}
@case ('beroep') {
<app-reg-beroep-step
[draft]="draft()"
[errors]="errors()"
[upload]="upload()"
(uploadMsg)="dispatch({ tag: 'Upload', msg: $event })"
(antwoordChange)="
dispatch({ tag: 'SetAntwoord', vraagId: $event.vraagId, value: $event.value })
"
(diplomaChosen)="onDiplomaKeuze($event)"
(beroepDeclared)="dispatch({ tag: 'DeclareerBeroep', beroep: $event })"
/>
}
@case ('controle') {
<app-reg-controle-step
[draft]="draft()"
(edit)="dispatch({ tag: 'GaNaarStap', cursor: $event })"
/>
}
}
<div wizardSuccess>
<app-confirmation
i18n-title="@@regWizard.success.title"
title="Uw registratie is ontvangen"
>
<p class="app-section" i18n="@@regWizard.success.referentie">
Uw referentienummer is {{ referentie() }}. Bewaar dit nummer voor uw administratie.
</p>
<div class="app-section">
<app-button variant="secondary" (click)="restart()" i18n="@@regWizard.nieuweRegistratie"
>Nieuwe registratie starten</app-button
>
</div>
</app-confirmation>
</div>
</app-wizard-shell>
`,
})
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<RegistratieState, RegistratieMsg>(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<RegistratieState>(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<Draft>(() => this.invullen()?.draft ?? { antwoorden: {} });
protected errors = computed<Errors>(() => this.invullen()?.errors ?? {});
protected upload = computed<UploadState>(() => 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<StepId>(() => 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<WizardPhase>(() => {
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<WizardError[]>(() => {
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<DuoLookupDto | null>(() => 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=<id>`), 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();
}
}