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>
275 lines
11 KiB
TypeScript
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();
|
|
}
|
|
}
|