+
} @else {
diff --git a/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts b/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts
index c835d3f..6b5d0dc 100644
--- a/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts
+++ b/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts
@@ -1,12 +1,12 @@
-import { Component, computed, effect, inject, input, untracked } from '@angular/core';
-import { httpResource } from '@angular/common/http';
+import { Component, computed, effect, inject, input, resource, 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 { SpinnerComponent } from '@shared/ui/spinner/spinner.component';
+import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
+import { WizardShellComponent, WizardError, WizardStatus } from '@shared/layout/wizard-shell/wizard-shell.component';
import { createStore } from '@shared/application/store';
import { BigProfileStore } from '@registratie/application/big-profile.store';
import {
@@ -20,8 +20,8 @@ import {
lageUren,
SCHOLING_THRESHOLD_DEFAULT,
} from '@herregistratie/domain/intake.machine';
-import { IntakePolicyDto } from '@herregistratie/contracts/intake-policy.dto';
import { submitIntake } from '@herregistratie/application/submit-intake';
+import { ApiClient } from '@shared/infrastructure/api-client';
const STORAGE_KEY = 'intake-v3'; // ponytail: bump the suffix if the persisted state shape changes; no migration.
const JA_NEE = [{ value: 'ja', label: 'Ja' }, { value: 'nee', label: 'Nee' }];
@@ -33,97 +33,90 @@ const JA_NEE = [{ value: 'ja', label: 'Ja' }, { value: 'nee', label: 'Nee' }];
localStorage so a page reload keeps the user's progress. */
@Component({
selector: 'app-intake-wizard',
- imports: [FormsModule, FormFieldComponent, TextInputComponent, RadioGroupComponent, ButtonComponent, AlertComponent, SpinnerComponent],
+ imports: [FormsModule, FormFieldComponent, TextInputComponent, RadioGroupComponent, ButtonComponent, AlertComponent, DataRowComponent, WizardShellComponent],
template: `
- @switch (state().tag) {
- @case ('Answering') {
-
Stap {{ cursor() + 1 }} van {{ steps.length }}
-
+
+ @if (scholingZichtbaar()) {
+
+ }
+ @if (answers().scholingGevolgd === 'ja') {
+
+ }
+
+ }
}
- @case ('Submitting') {
-
Aanvraag wordt verwerkt…
- }
- @case ('Submitted') {
+
+
Uw aanvraag tot herregistratie is ontvangen.
-
+
- }
- @case ('Failed') {
-
Indienen mislukt: {{ failedError() }}
-
- }
- }
+
+
`,
})
export class IntakeWizardComponent {
private profile = inject(BigProfileStore);
+ private apiClient = inject(ApiClient);
private store = createStore
(initial, reduce);
- // Server-owned policy: the scholing threshold is fetched, not hardcoded. The
- // backend stays the authority and re-validates on submit.
- private policyRes = httpResource(() => 'mock/intake-policy.json', {
- defaultValue: { scholingThreshold: SCHOLING_THRESHOLD_DEFAULT },
- });
+ // Server-owned policy: the scholing threshold is fetched from the backend
+ // (`GET /api/intake/policy`), not hardcoded. The backend stays the authority
+ // and re-validates on submit.
+ private policyRes = resource({ loader: () => this.apiClient.policy() });
/** Optional seed so Storybook / the showcase can mount any state directly. */
seed = input(initial);
@@ -144,6 +137,33 @@ export class IntakeWizardComponent {
protected scholingZichtbaar = computed(() => lageUren(this.answers(), this.scholingThreshold()));
protected failedError = computed(() => (this.state().tag === 'Failed' ? (this.state() as Extract).error : ''));
+ // --- Presentational wiring for the shared wizard shell ---------------------
+ readonly stepLabels = ['Buitenland', 'Werk', 'Controle'];
+ private stepTitles: Record = {
+ buitenland: 'Werken in het buitenland',
+ werk: 'Werkervaring in Nederland',
+ review: 'Controleren en indienen',
+ };
+ protected stepTitle = computed(() => this.stepTitles[this.step()]);
+ protected primaryLabel = computed(() => (this.step() === 'review' ? 'Aanvraag indienen' : 'Volgende'));
+ protected errorMessage = computed(() => `Indienen mislukt: ${this.failedError()}`);
+ protected shellStatus = computed(() => {
+ switch (this.state().tag) {
+ case 'Answering': return 'editing';
+ case 'Submitting': return 'submitting';
+ case 'Submitted': return 'submitted';
+ case 'Failed': return 'failed';
+ }
+ });
+ /** Current step's field errors, flattened for the shell's error summary. The
+ field ids match the answer keys, so the summary anchors jump to the field. */
+ protected errorList = computed(() => {
+ const e = this.answering()?.errors ?? {};
+ return (Object.keys(e) as (keyof Answers)[])
+ .filter((k) => e[k])
+ .map((k) => ({ id: k, message: e[k]! }));
+ });
+
protected err = (k: keyof Answers) => this.answering()?.errors[k] ?? '';
protected set = (key: keyof Answers, value: string) => this.dispatch({ tag: 'SetAnswer', key, value });
@@ -161,7 +181,7 @@ export class IntakeWizardComponent {
// only the policy value; untrack the dispatch (it reads the state signal
// internally, which would otherwise make this effect loop on its own write).
effect(() => {
- const scholingThreshold = this.policyRes.value().scholingThreshold;
+ const scholingThreshold = this.policyRes.value()?.scholingThreshold ?? SCHOLING_THRESHOLD_DEFAULT;
untracked(() => this.dispatch({ tag: 'SetPolicy', scholingThreshold }));
});
}
@@ -198,7 +218,7 @@ export class IntakeWizardComponent {
const s = this.state();
if (s.tag !== 'Submitting') return;
this.profile.beginHerregistratie();
- const r = await submitIntake(s.data);
+ const r = await submitIntake(this.apiClient, s.data);
if (r.ok) {
this.dispatch({ tag: 'SubmitConfirmed' });
this.profile.confirmHerregistratie();
diff --git a/src/app/herregistratie/ui/intake.page.ts b/src/app/herregistratie/ui/intake.page.ts
index 058820d..dbd5a7f 100644
--- a/src/app/herregistratie/ui/intake.page.ts
+++ b/src/app/herregistratie/ui/intake.page.ts
@@ -15,7 +15,7 @@ import { IntakeWizardComponent } from '@herregistratie/ui/intake-wizard/intake-w
antwoorden verschijnen er extra vragen. Uw antwoorden blijven bewaard als u
de pagina herlaadt.
-
+
diff --git a/src/app/registratie/application/big-profile.store.ts b/src/app/registratie/application/big-profile.store.ts
index ccffdf8..74e4e5a 100644
--- a/src/app/registratie/application/big-profile.store.ts
+++ b/src/app/registratie/application/big-profile.store.ts
@@ -42,9 +42,10 @@ export class BigProfileStore {
readonly decisions = computed
>(() => map(this.view(), (v) => v.decisions));
/** Specialisms/notes stay a separate stream (they have their own empty state). */
- readonly aantekeningen = computed>(() =>
- fromResource(this.aantekeningenRes, (v) => v.length === 0),
- );
+ readonly aantekeningen = computed>(() => {
+ const rd = fromResource(this.aantekeningenRes, (v) => !v || v.length === 0);
+ return rd.tag === 'Success' ? { tag: 'Success', value: rd.value ?? [] } : rd;
+ });
// --- Optimistic herregistratie state, shared with the dashboard -----------
private pending = signal(false);
diff --git a/src/app/registratie/application/submit-registratie.spec.ts b/src/app/registratie/application/submit-registratie.spec.ts
new file mode 100644
index 0000000..d07573b
--- /dev/null
+++ b/src/app/registratie/application/submit-registratie.spec.ts
@@ -0,0 +1,27 @@
+import { describe, it, expect } from 'vitest';
+import { submitRegistratie } from './submit-registratie';
+import { ApiClient } from '@shared/infrastructure/api-client';
+import { ValidRegistratie } from '@registratie/domain/registratie-wizard.machine';
+
+// Mocked at the client boundary: the rule itself lives server-side now, so these
+// tests only assert that the command maps the client's response onto a Result.
+const data = { diplomaHerkomst: 'duo' } as unknown as ValidRegistratie;
+
+describe('submitRegistratie', () => {
+ it('returns ok with the server reference on success', async () => {
+ const client = { registrations: async () => ({ referentie: 'BIG-2026-123456' }) } as unknown as ApiClient;
+ const r = await submitRegistratie(client, data);
+ expect(r).toEqual({ ok: true, value: 'BIG-2026-123456' });
+ });
+
+ it('returns err with the ProblemDetails detail when the server rejects (422)', async () => {
+ const client = {
+ registrations: async () => {
+ throw { detail: 'Handmatig diploma kan niet automatisch worden geverifieerd.', status: 422 };
+ },
+ } as unknown as ApiClient;
+ const r = await submitRegistratie(client, data);
+ expect(r.ok).toBe(false);
+ expect(r).toMatchObject({ error: expect.stringContaining('Handmatig diploma') });
+ });
+});
diff --git a/src/app/registratie/application/submit-registratie.ts b/src/app/registratie/application/submit-registratie.ts
index db1253c..40183f4 100644
--- a/src/app/registratie/application/submit-registratie.ts
+++ b/src/app/registratie/application/submit-registratie.ts
@@ -1,19 +1,20 @@
import { Result, ok, err } from '@shared/kernel/fp';
import { ValidRegistratie } from '@registratie/domain/registratie-wizard.machine';
+import { ApiClient } from '@shared/infrastructure/api-client';
+import { problemDetail } from '@shared/infrastructure/api-error';
/**
- * Command: send the completed registration to the backend, which invokes the
- * domain command to create/finalize the registration aggregate. Returns a Result
- * so the caller branches on success/failure without try/catch; on success it
- * yields the confirmation reference (PRD §9). ponytail: faked with a timer; swap
- * for a real POST when there's an API. The "manually entered diploma" rule lets
- * the demo show the failure path.
+ * Command: POST the completed registration to the backend (`/api/registrations`),
+ * which re-validates and decides. The business rule that a manually entered
+ * diploma cannot be auto-verified now lives server-side; the backend returns it as
+ * a 422 ProblemDetails, which we surface as the error. On success it yields the
+ * server-generated confirmation reference (PRD §9).
*/
-export async function submitRegistratie(data: ValidRegistratie): Promise> {
- await new Promise((r) => setTimeout(r, 800));
- if (data.diplomaHerkomst === 'handmatig') {
- return err('Een handmatig ingevoerd diploma kan niet automatisch worden geverifieerd. Uw aanvraag is doorgestuurd voor handmatige beoordeling.');
+export async function submitRegistratie(client: ApiClient, data: ValidRegistratie): Promise> {
+ try {
+ const res = await client.registrations({ diplomaHerkomst: data.diplomaHerkomst });
+ return ok(res.referentie ?? '');
+ } catch (e) {
+ return err(problemDetail(e, 'Het indienen is niet gelukt. Probeer het later opnieuw.'));
}
- const referentie = 'BIG-2026-' + Math.floor(100000 + Math.random() * 900000);
- return ok(referentie);
}
diff --git a/src/app/registratie/infrastructure/big-register.adapter.ts b/src/app/registratie/infrastructure/big-register.adapter.ts
index 01e2e52..c4aa828 100644
--- a/src/app/registratie/infrastructure/big-register.adapter.ts
+++ b/src/app/registratie/infrastructure/big-register.adapter.ts
@@ -1,19 +1,26 @@
-import { Injectable } from '@angular/core';
-import { httpResource } from '@angular/common/http';
-import { Aantekening } from '../domain/registration';
+import { Injectable, inject, resource } from '@angular/core';
+import { Aantekening, AantekeningType } from '../domain/registration';
+import { ApiClient, AantekeningDto } from '@shared/infrastructure/api-client';
/**
* Infrastructure adapter for the BIG-register source. Exposes signal-based
- * resources (Angular's httpResource); each returns a Resource with
- * status()/value()/error()/reload(). Call from an injection context
- * (a field initializer in the store).
+ * resources (Angular's `resource` over the generated typed client); each returns
+ * a Resource with status()/value()/error()/reload(). Call from an injection
+ * context (a field initializer in the store).
*
* Note: registration + person are now served via the aggregated dashboard-view
* endpoint (see DashboardViewAdapter). Only the notes stream remains separate.
*/
@Injectable({ providedIn: 'root' })
export class BigRegisterAdapter {
+ private client = inject(ApiClient);
+
aantekeningenResource() {
- return httpResource(() => 'mock/notes.json', { defaultValue: [] });
+ return resource({ loader: () => this.client.notes().then((ns) => ns.map(toAantekening)) });
}
}
+
+/** Map the wire DTO (all fields optional) onto our domain type. */
+function toAantekening(n: AantekeningDto): Aantekening {
+ return { type: n.type as AantekeningType, omschrijving: n.omschrijving ?? '', datum: n.datum ?? '' };
+}
diff --git a/src/app/registratie/infrastructure/brp.adapter.ts b/src/app/registratie/infrastructure/brp.adapter.ts
index ae7a198..5211a71 100644
--- a/src/app/registratie/infrastructure/brp.adapter.ts
+++ b/src/app/registratie/infrastructure/brp.adapter.ts
@@ -1,19 +1,20 @@
-import { Injectable } from '@angular/core';
-import { httpResource } from '@angular/common/http';
+import { Injectable, inject, resource } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp';
import { BrpAddressDto } from '@registratie/contracts/brp-address.dto';
+import { ApiClient } from '@shared/infrastructure/api-client';
/**
* Infrastructure adapter for the BRP address lookup, reached only through our own
- * ("BFF-lite") endpoint — the anti-corruption boundary. In this POC the endpoint
- * is a static mock; pointing at a real backend touches only this file + the DTO.
+ * ("BFF-lite") endpoint — the anti-corruption boundary. Data comes from the .NET
+ * backend (`GET /api/brp/address`) via the generated typed client.
*/
@Injectable({ providedIn: 'root' })
export class BrpAdapter {
- // Typed as the DTO for ergonomics, but the value is untrusted JSON until
- // parseBrpAddress validates it.
+ private client = inject(ApiClient);
+
+ // The value is untrusted JSON until parseBrpAddress validates it.
adresResource() {
- return httpResource(() => 'mock/brp-address.json');
+ return resource({ loader: () => this.client.address() });
}
}
diff --git a/src/app/registratie/infrastructure/dashboard-view.adapter.ts b/src/app/registratie/infrastructure/dashboard-view.adapter.ts
index 995cfde..aec6497 100644
--- a/src/app/registratie/infrastructure/dashboard-view.adapter.ts
+++ b/src/app/registratie/infrastructure/dashboard-view.adapter.ts
@@ -1,20 +1,22 @@
-import { Injectable } from '@angular/core';
-import { httpResource } from '@angular/common/http';
+import { Injectable, inject, resource } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp';
import { DashboardViewDto, DashboardView } from '@registratie/contracts/dashboard-view.dto';
+import { ApiClient } from '@shared/infrastructure/api-client';
/**
* Infrastructure adapter for the screen-shaped ("BFF-lite") dashboard endpoint.
- * ONE call returns registration + person + server-computed decisions.
- * (In this POC the endpoint is a static mock; the decisions are precomputed to
- * stand in for what the backend would compute.)
+ * ONE call returns registration + person + server-computed decisions. The data
+ * comes from the .NET backend (`GET /api/dashboard-view`) via the generated typed
+ * client; the decisions (e.g. herregistratie eligibility) are computed there.
*/
@Injectable({ providedIn: 'root' })
export class DashboardViewAdapter {
- // Typed as the DTO for ergonomics, but the value is still untrusted JSON —
- // parseDashboardView validates it at the boundary before the app uses it.
+ private client = inject(ApiClient);
+
+ // The value is still untrusted JSON — parseDashboardView validates it at the
+ // boundary and maps DTO → domain before the app uses it.
dashboardViewResource() {
- return httpResource(() => 'mock/dashboard-view.json');
+ return resource({ loader: () => this.client.dashboardView() });
}
}
diff --git a/src/app/registratie/infrastructure/duo.adapter.ts b/src/app/registratie/infrastructure/duo.adapter.ts
index 7e49a02..570473c 100644
--- a/src/app/registratie/infrastructure/duo.adapter.ts
+++ b/src/app/registratie/infrastructure/duo.adapter.ts
@@ -1,21 +1,21 @@
-import { Injectable } from '@angular/core';
-import { httpResource } from '@angular/common/http';
+import { Injectable, inject, resource } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp';
import { DuoLookupDto, DuoDiplomaDto, PolicyQuestionDto, ManualDiplomaPolicyDto } from '@registratie/contracts/duo-diplomas.dto';
-
-const EMPTY: DuoLookupDto = { diplomas: [], handmatig: { beroepen: [], policyQuestions: [] } };
+import { ApiClient } from '@shared/infrastructure/api-client';
/**
* Infrastructure adapter for the DUO diploma lookup, reached only through our own
* ("BFF-lite") endpoint — the anti-corruption boundary. The response carries the
* user's diplomas (each with its server-computed beroep + policy questions) and
- * the manual-entry fallback policy. The frontend renders; it does not derive. In
- * this POC the endpoint is a static mock.
+ * the manual-entry fallback policy. The frontend renders; it does not derive.
+ * Data comes from the .NET backend (`GET /api/duo/diplomas`) via the typed client.
*/
@Injectable({ providedIn: 'root' })
export class DuoAdapter {
+ private client = inject(ApiClient);
+
diplomasResource() {
- return httpResource(() => 'mock/duo-diplomas.json', { defaultValue: EMPTY });
+ return resource({ loader: () => this.client.diplomas() });
}
}
diff --git a/src/app/registratie/ui/dashboard.page.ts b/src/app/registratie/ui/dashboard.page.ts
index 55b6b3b..4586777 100644
--- a/src/app/registratie/ui/dashboard.page.ts
+++ b/src/app/registratie/ui/dashboard.page.ts
@@ -18,61 +18,69 @@ import { BigProfileStore } from '@registratie/application/big-profile.store';
],
template: `
- @if (store.pendingHerregistratie()) {
- Uw herregistratie-aanvraag is in behandeling.
- }
+
+ @if (store.pendingHerregistratie()) {
+
Uw herregistratie-aanvraag is in behandeling.
+ }
-
-
-
-
-
-
Persoonsgegevens (BRP)
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Specialismen en aantekeningen
-
-
-
+
+
+
+
+
+
Persoonsgegevens (BRP)
+
+
+
+
+
+
-
-
-
- U heeft nog geen specialismen of aantekeningen.
+
-
-
- Inschrijven in het BIG-register (registratiewizard) →
-
-
- Gegevens bekijken of een wijziging doorgeven →
-
-
- Herregistratie aanvragen →
-
-
- Herregistratie-intake (vragenlijst met vertakkingen) →
-
-
- Functionele patronen (impossible states) →
-
+
+ Specialismen en aantekeningen
+
+
+
+
+
+
+
+
+ U heeft nog geen specialismen of aantekeningen.
+
+
+
+
+
+ Wat wilt u doen?
+
+ @for (a of acties; track a.to) {
+ -
+ {{ a.titel }}
+
{{ a.tekst }}
+ {{ a.actie }} →
+
+ }
+
+
+
`,
})
export class DashboardPage {
protected store = inject(BigProfileStore);
+
+ /** Primary actions, rendered as an accessible card grid. */
+ protected readonly acties = [
+ { to: '/registreren', titel: 'Inschrijven', tekst: 'Schrijf u in in het BIG-register via de registratiewizard.', actie: 'Start inschrijving' },
+ { to: '/registratie', titel: 'Mijn gegevens', tekst: 'Bekijk uw gegevens of geef een wijziging door.', actie: 'Bekijk gegevens' },
+ { to: '/herregistratie', titel: 'Herregistratie', tekst: 'Vraag uw herregistratie aan.', actie: 'Vraag aan' },
+ { to: '/intake', titel: 'Herregistratie-intake', tekst: 'Vragenlijst met vertakkingen.', actie: 'Start intake' },
+ { to: '/concepts', titel: 'Functionele patronen', tekst: 'Onmogelijke toestanden onmogelijk maken.', actie: 'Bekijk patronen' },
+ ];
}
diff --git a/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts b/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts
index 67c97e1..5f7aba9 100644
--- a/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts
+++ b/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts
@@ -1,14 +1,13 @@
-import { Component, ElementRef, computed, effect, inject, input, untracked, viewChild } from '@angular/core';
+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 { SpinnerComponent } from '@shared/ui/spinner/spinner.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
-import { StepperComponent } from '@shared/ui/stepper/stepper.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';
@@ -28,6 +27,7 @@ import {
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' }];
@@ -44,17 +44,27 @@ const HANDMATIG = '__handmatig__'; // sentinel option: "my diploma isn't listed"
selector: 'app-registratie-wizard',
imports: [
FormsModule, FormFieldComponent, TextInputComponent, RadioGroupComponent, ButtonComponent,
- AlertComponent, SpinnerComponent, SkeletonComponent, DataRowComponent, StepperComponent,
+ AlertComponent, SkeletonComponent, DataRowComponent, WizardShellComponent,
AddressFieldsComponent, ...ASYNC,
],
template: `
- @switch (state().tag) {
- @case ('Invullen') {
-
- {{ stepTitle() }}
-
- }
- }
-
-
+ }
}
- @case ('Indienen') {
- Uw registratie wordt verwerkt…
- }
- @case ('Ingediend') {
+
+
Uw registratie is ontvangen. Uw referentienummer is {{ referentie() }}. Bewaar dit nummer voor uw administratie.
Nieuwe registratie starten
- }
- @case ('Mislukt') {
-
Het indienen is niet gelukt: {{ failedError() }}
-
- }
- }
+
+
`,
})
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();
@@ -186,9 +180,6 @@ export class RegistratieWizardComponent {
readonly state = this.store.model;
readonly dispatch = this.store.dispatch;
- /** Focus target: the step heading receives focus on step change (a11y). */
- private stepHeading = viewChild>('stepHeading');
-
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: {} });
@@ -196,6 +187,30 @@ export class RegistratieWizardComponent {
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(', ');
@@ -302,18 +317,8 @@ export class RegistratieWizardComponent {
}
});
});
- // A11y: move focus to the step heading when the STEP changes (depends only on
- // cursor(), which is value-stable across keystrokes — so typing never steals
- // focus). Skip the first run so we don't grab focus on initial load.
- let firstStep = true;
- effect(() => {
- this.cursor();
- if (firstStep) { firstStep = false; return; }
- untracked(() => {
- if (this.state().tag !== 'Invullen') return;
- queueMicrotask(() => this.stepHeading()?.nativeElement.focus());
- });
- });
+ // A11y: focus management (step heading on step change, error summary on a
+ // failed submit) now lives in the shared WizardShellComponent.
}
private restore(): RegistratieState | null {
@@ -350,7 +355,7 @@ export class RegistratieWizardComponent {
private async runIfIndienen() {
const s = this.state();
if (s.tag !== 'Indienen') return;
- const r = await submitRegistratie(s.data);
+ 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 });
}
diff --git a/src/app/shared/infrastructure/api-client.provider.ts b/src/app/shared/infrastructure/api-client.provider.ts
new file mode 100644
index 0000000..de4a978
--- /dev/null
+++ b/src/app/shared/infrastructure/api-client.provider.ts
@@ -0,0 +1,49 @@
+import { Provider } from '@angular/core';
+import { HttpClient, HttpErrorResponse } from '@angular/common/http';
+import { firstValueFrom } from 'rxjs';
+import { ApiClient, ProblemDetails } from './api-client';
+
+/**
+ * Adapts Angular's HttpClient to the fetch-shaped interface the NSwag-generated
+ * client expects, so every API call flows through HttpClient interceptors — the
+ * `?scenario=` toggle (scenario.interceptor.ts) and any future auth header. The
+ * generated client is the only place HTTP shapes are known; this is the only
+ * place it meets Angular's HTTP stack.
+ */
+function httpClientFetch(http: HttpClient) {
+ return {
+ async fetch(url: RequestInfo, init?: RequestInit): Promise {
+ const method = (init?.method ?? 'GET').toUpperCase();
+ const headers = (init?.headers ?? {}) as Record;
+ try {
+ const res = await firstValueFrom(
+ http.request(method, url as string, {
+ body: init?.body as string | undefined,
+ headers,
+ observe: 'response',
+ responseType: 'text',
+ }),
+ );
+ return new Response(res.body ?? '', { status: res.status || 200 });
+ } catch (e) {
+ const err = e as HttpErrorResponse;
+ const body = typeof err.error === 'string' ? err.error : JSON.stringify(err.error ?? {});
+ // ponytail: clamp to a Response-constructible status (an aborted/interceptor
+ // request reports status 0, which `new Response` rejects).
+ const status = err.status >= 200 && err.status <= 599 ? err.status : 500;
+ return new Response(body, { status });
+ }
+ },
+ };
+}
+
+/** Provide a root ApiClient that talks through HttpClient (relative `/api` base URL). */
+export function provideApiClient(): Provider {
+ return {
+ provide: ApiClient,
+ useFactory: (http: HttpClient) => new ApiClient('', httpClientFetch(http)),
+ deps: [HttpClient],
+ };
+}
+
+export type { ProblemDetails };
diff --git a/src/app/shared/infrastructure/api-client.ts b/src/app/shared/infrastructure/api-client.ts
new file mode 100644
index 0000000..5d4f07b
--- /dev/null
+++ b/src/app/shared/infrastructure/api-client.ts
@@ -0,0 +1,474 @@
+//----------------------
+//
+// Generated using the NSwag toolchain v14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0)) (http://NSwag.org)
+//
+//----------------------
+
+/* eslint-disable */
+// ReSharper disable InconsistentNaming
+
+export class ApiClient {
+ private http: { fetch(url: RequestInfo, init?: RequestInit): Promise };
+ private baseUrl: string;
+ protected jsonParseReviver: ((key: string, value: any) => any) | undefined = undefined;
+
+ constructor(baseUrl?: string, http?: { fetch(url: RequestInfo, init?: RequestInit): Promise }) {
+ this.http = http ? http : window as any;
+ this.baseUrl = baseUrl ?? "";
+ }
+
+ /**
+ * @return OK
+ */
+ dashboardView(): Promise {
+ let url_ = this.baseUrl + "/api/dashboard-view";
+ url_ = url_.replace(/[?&]$/, "");
+
+ let options_: RequestInit = {
+ method: "GET",
+ headers: {
+ "Accept": "application/json"
+ }
+ };
+
+ return this.http.fetch(url_, options_).then((_response: Response) => {
+ return this.processDashboardView(_response);
+ });
+ }
+
+ protected processDashboardView(response: Response): Promise {
+ const status = response.status;
+ let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
+ if (status === 200) {
+ return response.text().then((_responseText) => {
+ let result200: any = null;
+ result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as DashboardViewDto;
+ return result200;
+ });
+ } else if (status !== 200 && status !== 204) {
+ return response.text().then((_responseText) => {
+ return throwException("An unexpected server error occurred.", status, _responseText, _headers);
+ });
+ }
+ return Promise.resolve(null as any);
+ }
+
+ /**
+ * @return OK
+ */
+ notes(): Promise {
+ let url_ = this.baseUrl + "/api/notes";
+ url_ = url_.replace(/[?&]$/, "");
+
+ let options_: RequestInit = {
+ method: "GET",
+ headers: {
+ "Accept": "application/json"
+ }
+ };
+
+ return this.http.fetch(url_, options_).then((_response: Response) => {
+ return this.processNotes(_response);
+ });
+ }
+
+ protected processNotes(response: Response): Promise {
+ const status = response.status;
+ let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
+ if (status === 200) {
+ return response.text().then((_responseText) => {
+ let result200: any = null;
+ result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as AantekeningDto[];
+ return result200;
+ });
+ } else if (status !== 200 && status !== 204) {
+ return response.text().then((_responseText) => {
+ return throwException("An unexpected server error occurred.", status, _responseText, _headers);
+ });
+ }
+ return Promise.resolve(null as any);
+ }
+
+ /**
+ * @return OK
+ */
+ address(): Promise {
+ let url_ = this.baseUrl + "/api/brp/address";
+ url_ = url_.replace(/[?&]$/, "");
+
+ let options_: RequestInit = {
+ method: "GET",
+ headers: {
+ "Accept": "application/json"
+ }
+ };
+
+ return this.http.fetch(url_, options_).then((_response: Response) => {
+ return this.processAddress(_response);
+ });
+ }
+
+ protected processAddress(response: Response): Promise {
+ const status = response.status;
+ let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
+ if (status === 200) {
+ return response.text().then((_responseText) => {
+ let result200: any = null;
+ result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as BrpAddressDto;
+ return result200;
+ });
+ } else if (status !== 200 && status !== 204) {
+ return response.text().then((_responseText) => {
+ return throwException("An unexpected server error occurred.", status, _responseText, _headers);
+ });
+ }
+ return Promise.resolve(null as any);
+ }
+
+ /**
+ * @return OK
+ */
+ diplomas(): Promise {
+ let url_ = this.baseUrl + "/api/duo/diplomas";
+ url_ = url_.replace(/[?&]$/, "");
+
+ let options_: RequestInit = {
+ method: "GET",
+ headers: {
+ "Accept": "application/json"
+ }
+ };
+
+ return this.http.fetch(url_, options_).then((_response: Response) => {
+ return this.processDiplomas(_response);
+ });
+ }
+
+ protected processDiplomas(response: Response): Promise {
+ const status = response.status;
+ let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
+ if (status === 200) {
+ return response.text().then((_responseText) => {
+ let result200: any = null;
+ result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as DuoLookupDto;
+ return result200;
+ });
+ } else if (status !== 200 && status !== 204) {
+ return response.text().then((_responseText) => {
+ return throwException("An unexpected server error occurred.", status, _responseText, _headers);
+ });
+ }
+ return Promise.resolve(null as any);
+ }
+
+ /**
+ * @return OK
+ */
+ policy(): Promise {
+ let url_ = this.baseUrl + "/api/intake/policy";
+ url_ = url_.replace(/[?&]$/, "");
+
+ let options_: RequestInit = {
+ method: "GET",
+ headers: {
+ "Accept": "application/json"
+ }
+ };
+
+ return this.http.fetch(url_, options_).then((_response: Response) => {
+ return this.processPolicy(_response);
+ });
+ }
+
+ protected processPolicy(response: Response): Promise {
+ const status = response.status;
+ let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
+ if (status === 200) {
+ return response.text().then((_responseText) => {
+ let result200: any = null;
+ result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as IntakePolicyDto;
+ return result200;
+ });
+ } else if (status !== 200 && status !== 204) {
+ return response.text().then((_responseText) => {
+ return throwException("An unexpected server error occurred.", status, _responseText, _headers);
+ });
+ }
+ return Promise.resolve(null as any);
+ }
+
+ /**
+ * @return OK
+ */
+ registrations(body: RegistratieRequest): Promise {
+ let url_ = this.baseUrl + "/api/registrations";
+ url_ = url_.replace(/[?&]$/, "");
+
+ const content_ = JSON.stringify(body);
+
+ let options_: RequestInit = {
+ body: content_,
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Accept": "application/json"
+ }
+ };
+
+ return this.http.fetch(url_, options_).then((_response: Response) => {
+ return this.processRegistrations(_response);
+ });
+ }
+
+ protected processRegistrations(response: Response): Promise {
+ const status = response.status;
+ let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
+ if (status === 200) {
+ return response.text().then((_responseText) => {
+ let result200: any = null;
+ result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ReferentieResponse;
+ return result200;
+ });
+ } else if (status === 422) {
+ return response.text().then((_responseText) => {
+ let result422: any = null;
+ result422 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
+ return throwException("Unprocessable Content", status, _responseText, _headers, result422);
+ });
+ } else if (status !== 200 && status !== 204) {
+ return response.text().then((_responseText) => {
+ return throwException("An unexpected server error occurred.", status, _responseText, _headers);
+ });
+ }
+ return Promise.resolve(null as any);
+ }
+
+ /**
+ * @return OK
+ */
+ herregistraties(body: HerregistratieRequest): Promise {
+ let url_ = this.baseUrl + "/api/herregistraties";
+ url_ = url_.replace(/[?&]$/, "");
+
+ const content_ = JSON.stringify(body);
+
+ let options_: RequestInit = {
+ body: content_,
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Accept": "application/json"
+ }
+ };
+
+ return this.http.fetch(url_, options_).then((_response: Response) => {
+ return this.processHerregistraties(_response);
+ });
+ }
+
+ protected processHerregistraties(response: Response): Promise {
+ const status = response.status;
+ let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
+ if (status === 200) {
+ return response.text().then((_responseText) => {
+ let result200: any = null;
+ result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ReferentieResponse;
+ return result200;
+ });
+ } else if (status === 422) {
+ return response.text().then((_responseText) => {
+ let result422: any = null;
+ result422 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
+ return throwException("Unprocessable Content", status, _responseText, _headers, result422);
+ });
+ } else if (status !== 200 && status !== 204) {
+ return response.text().then((_responseText) => {
+ return throwException("An unexpected server error occurred.", status, _responseText, _headers);
+ });
+ }
+ return Promise.resolve(null as any);
+ }
+
+ /**
+ * @return OK
+ */
+ intakes(body: IntakeRequest): Promise {
+ let url_ = this.baseUrl + "/api/intakes";
+ url_ = url_.replace(/[?&]$/, "");
+
+ const content_ = JSON.stringify(body);
+
+ let options_: RequestInit = {
+ body: content_,
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Accept": "application/json"
+ }
+ };
+
+ return this.http.fetch(url_, options_).then((_response: Response) => {
+ return this.processIntakes(_response);
+ });
+ }
+
+ protected processIntakes(response: Response): Promise {
+ const status = response.status;
+ let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
+ if (status === 200) {
+ return response.text().then((_responseText) => {
+ let result200: any = null;
+ result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ReferentieResponse;
+ return result200;
+ });
+ } else if (status === 422) {
+ return response.text().then((_responseText) => {
+ let result422: any = null;
+ result422 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
+ return throwException("Unprocessable Content", status, _responseText, _headers, result422);
+ });
+ } else if (status !== 200 && status !== 204) {
+ return response.text().then((_responseText) => {
+ return throwException("An unexpected server error occurred.", status, _responseText, _headers);
+ });
+ }
+ return Promise.resolve(null as any);
+ }
+}
+
+export interface AantekeningDto {
+ type?: string | undefined;
+ omschrijving?: string | undefined;
+ datum?: string | undefined;
+}
+
+export interface AdresDto {
+ straat?: string | undefined;
+ postcode?: string | undefined;
+ woonplaats?: string | undefined;
+}
+
+export interface BrpAddressDto {
+ gevonden?: boolean;
+ adres?: AdresDto;
+}
+
+export interface DashboardViewDto {
+ registration?: RegistrationDto;
+ person?: PersonDto;
+ decisions?: HerregistratieDecisionsDto;
+}
+
+export interface DuoDiplomaDto {
+ id?: string | undefined;
+ naam?: string | undefined;
+ instelling?: string | undefined;
+ jaar?: number;
+ beroep?: string | undefined;
+ policyQuestions?: PolicyQuestionDto[] | undefined;
+}
+
+export interface DuoLookupDto {
+ diplomas?: DuoDiplomaDto[] | undefined;
+ handmatig?: ManualDiplomaPolicyDto;
+}
+
+export interface HerregistratieDecisionsDto {
+ eligibleForHerregistratie?: boolean;
+ herregistratieReason?: string | undefined;
+}
+
+export interface HerregistratieRequest {
+ uren?: number;
+}
+
+export interface IntakePolicyDto {
+ scholingThreshold?: number;
+}
+
+export interface IntakeRequest {
+ uren?: number;
+}
+
+export interface ManualDiplomaPolicyDto {
+ beroepen?: string[] | undefined;
+ policyQuestions?: PolicyQuestionDto[] | undefined;
+}
+
+export interface PersonDto {
+ naam?: string | undefined;
+ geboortedatum?: string | undefined;
+ adres?: AdresDto;
+}
+
+export interface PolicyQuestionDto {
+ id?: string | undefined;
+ vraag?: string | undefined;
+ type?: string | undefined;
+}
+
+export interface ProblemDetails {
+ type?: string | undefined;
+ title?: string | undefined;
+ status?: number | undefined;
+ detail?: string | undefined;
+ instance?: string | undefined;
+
+ [key: string]: any;
+}
+
+export interface ReferentieResponse {
+ referentie?: string | undefined;
+}
+
+export interface RegistratieRequest {
+ diplomaHerkomst?: string | undefined;
+}
+
+export interface RegistrationDto {
+ bigNummer?: string | undefined;
+ naam?: string | undefined;
+ beroep?: string | undefined;
+ registratiedatum?: string | undefined;
+ geboortedatum?: string | undefined;
+ status?: RegistrationStatusDto;
+}
+
+export interface RegistrationStatusDto {
+ tag?: string | undefined;
+ herregistratieDatum?: string | undefined;
+ geschorstTot?: string | undefined;
+ reden?: string | undefined;
+ doorgehaaldOp?: string | undefined;
+}
+
+export class SwaggerException extends Error {
+ override message: string;
+ status: number;
+ response: string;
+ headers: { [key: string]: any; };
+ result: any;
+
+ constructor(message: string, status: number, response: string, headers: { [key: string]: any; }, result: any) {
+ super();
+
+ this.message = message;
+ this.status = status;
+ this.response = response;
+ this.headers = headers;
+ this.result = result;
+ }
+
+ protected isSwaggerException = true;
+
+ static isSwaggerException(obj: any): obj is SwaggerException {
+ return obj.isSwaggerException === true;
+ }
+}
+
+function throwException(message: string, status: number, response: string, headers: { [key: string]: any; }, result?: any): any {
+ if (result !== null && result !== undefined)
+ throw result;
+ else
+ throw new SwaggerException(message, status, response, headers, null);
+}
\ No newline at end of file
diff --git a/src/app/shared/infrastructure/api-error.spec.ts b/src/app/shared/infrastructure/api-error.spec.ts
new file mode 100644
index 0000000..8572810
--- /dev/null
+++ b/src/app/shared/infrastructure/api-error.spec.ts
@@ -0,0 +1,14 @@
+import { describe, it, expect } from 'vitest';
+import { problemDetail } from './api-error';
+
+describe('problemDetail', () => {
+ it('extracts the detail from an RFC-7807 ProblemDetails', () => {
+ expect(problemDetail({ detail: 'Afgewezen: 0 uren.', status: 422 }, 'fallback')).toBe('Afgewezen: 0 uren.');
+ });
+
+ it('falls back when there is no detail', () => {
+ expect(problemDetail(new Error('boom'), 'fallback')).toBe('fallback');
+ expect(problemDetail({ status: 500 }, 'fallback')).toBe('fallback');
+ expect(problemDetail(undefined, 'fallback')).toBe('fallback');
+ });
+});
diff --git a/src/app/shared/infrastructure/api-error.ts b/src/app/shared/infrastructure/api-error.ts
new file mode 100644
index 0000000..f65dbb4
--- /dev/null
+++ b/src/app/shared/infrastructure/api-error.ts
@@ -0,0 +1,14 @@
+import { ProblemDetails } from './api-client';
+
+/**
+ * Extract a human-readable message from a rejected API call. A 4xx/5xx with a
+ * ProblemDetails body (RFC 7807) is thrown by the generated client as the parsed
+ * object; anything else falls back to the given message.
+ */
+export function problemDetail(e: unknown, fallback: string): string {
+ if (e && typeof e === 'object' && 'detail' in e) {
+ const detail = (e as ProblemDetails).detail;
+ if (typeof detail === 'string' && detail) return detail;
+ }
+ return fallback;
+}
diff --git a/src/app/shared/infrastructure/scenario.interceptor.ts b/src/app/shared/infrastructure/scenario.interceptor.ts
index cc9cbe7..86fc607 100644
--- a/src/app/shared/infrastructure/scenario.interceptor.ts
+++ b/src/app/shared/infrastructure/scenario.interceptor.ts
@@ -4,12 +4,12 @@ import { delay } from 'rxjs/operators';
import { currentScenario } from './scenario';
/**
- * Demo-only: rewrites the timing/outcome of mock data requests based on
+ * Demo-only: rewrites the timing/outcome of API data requests based on
* ?scenario= so loading / empty / error states can be shown on demand.
- * Real requests are untouched.
+ * Non-API requests are untouched.
*/
export const scenarioInterceptor: HttpInterceptorFn = (req, next) => {
- if (!req.url.includes('mock/')) return next(req);
+ if (!req.url.includes('/api/')) return next(req);
switch (currentScenario()) {
case 'slow':
@@ -17,7 +17,8 @@ export const scenarioInterceptor: HttpInterceptorFn = (req, next) => {
case 'loading':
return next(req).pipe(delay(600_000)); // effectively never resolves
case 'empty':
- return of(new HttpResponse({ status: 200, body: [] })).pipe(delay(400));
+ // '[]' so the typed client parses it to an empty array (notes → Empty state).
+ return of(new HttpResponse({ status: 200, body: '[]' })).pipe(delay(400));
case 'error':
return timer(400).pipe(
switchMap(() => throwError(() =>
diff --git a/src/app/shared/layout/wizard-shell/wizard-shell.component.ts b/src/app/shared/layout/wizard-shell/wizard-shell.component.ts
new file mode 100644
index 0000000..a07b935
--- /dev/null
+++ b/src/app/shared/layout/wizard-shell/wizard-shell.component.ts
@@ -0,0 +1,115 @@
+import { Component, ElementRef, effect, input, output, untracked, viewChild } from '@angular/core';
+import { ButtonComponent } from '@shared/ui/button/button.component';
+import { AlertComponent } from '@shared/ui/alert/alert.component';
+import { SpinnerComponent } from '@shared/ui/spinner/spinner.component';
+import { StepperComponent } from '@shared/ui/stepper/stepper.component';
+
+/** A flat validation error pointing at a field: `id` matches the field's anchor. */
+export interface WizardError {
+ readonly id: string;
+ readonly message: string;
+}
+
+export type WizardStatus = 'editing' | 'submitting' | 'submitted' | 'failed';
+
+/**
+ * Template: the canonical shell every wizard renders into, so they cannot drift.
+ * It owns the consistent outline — stepper + focusable step heading + error
+ * summary + the
+ }
+ @case ('submitting') {
+ {{ submittingLabel() }}
+ }
+ @case ('submitted') {
+
+ }
+ @case ('failed') {
+ {{ errorMessage() }}
+
+ }
+ }
+ `,
+})
+export class WizardShellComponent {
+ steps = input.required();
+ current = input.required();
+ stepTitle = input.required();
+ status = input.required();
+ primaryLabel = input.required();
+ canGoBack = input(false);
+ errors = input([]);
+ errorMessage = input('');
+ submittingLabel = input('Aanvraag wordt verwerkt…');
+
+ primary = output();
+ back = output();
+ cancel = output();
+ retry = output();
+
+ private stepHeading = viewChild>('stepHeading');
+ private errorSummary = viewChild>('errorSummary');
+
+ constructor() {
+ // A11y: move focus to the step heading when the step changes (skip first run
+ // so we don't grab focus on initial load). Tracks current(), which is value-
+ // stable across keystrokes, so typing never steals focus.
+ let firstStep = true;
+ effect(() => {
+ this.current();
+ if (firstStep) { firstStep = false; return; }
+ untracked(() => queueMicrotask(() => this.stepHeading()?.nativeElement.focus()));
+ });
+ // A11y: when validation errors appear (after a failed submit), move focus to
+ // the error summary so it's announced. The reducer returns a fresh errors
+ // object per attempt, so resubmitting the same invalid step re-announces.
+ let firstErr = true;
+ effect(() => {
+ const has = this.errors().length > 0;
+ if (firstErr) { firstErr = false; return; }
+ if (has) untracked(() => queueMicrotask(() => this.errorSummary()?.nativeElement.focus()));
+ });
+ }
+}
diff --git a/src/app/shared/layout/wizard-shell/wizard-shell.stories.ts b/src/app/shared/layout/wizard-shell/wizard-shell.stories.ts
new file mode 100644
index 0000000..29f3a09
--- /dev/null
+++ b/src/app/shared/layout/wizard-shell/wizard-shell.stories.ts
@@ -0,0 +1,37 @@
+import type { Meta, StoryObj } from '@storybook/angular';
+import { WizardShellComponent } from './wizard-shell.component';
+
+const meta: Meta = {
+ title: 'Templates/WizardShell',
+ component: WizardShellComponent,
+ render: (args) => ({
+ props: args,
+ template: `
+
+ Voorbeeld-stapinhoud (de stapvelden worden hier geprojecteerd).
+ Uw aanvraag is ontvangen.
+ `,
+ }),
+};
+export default meta;
+type Story = StoryObj;
+
+const steps = ['Adres', 'Beroep', 'Controle'];
+const base = { steps, current: 1, stepTitle: 'Beroep op basis van uw diploma', primaryLabel: 'Volgende', canGoBack: true, errors: [], errorMessage: '' };
+
+export const Editing: Story = { args: { ...base, status: 'editing' } };
+export const EditingMetFouten: Story = {
+ args: {
+ ...base,
+ status: 'editing',
+ errors: [
+ { id: 'uren', message: 'Vul het aantal gewerkte uren in.' },
+ { id: 'diploma', message: 'Kies een diploma.' },
+ ],
+ },
+};
+export const Submitting: Story = { args: { ...base, status: 'submitting' } };
+export const Submitted: Story = { args: { ...base, status: 'submitted' } };
+export const Failed: Story = { args: { ...base, status: 'failed', errorMessage: 'Het indienen is niet gelukt: netwerkfout.' } };
diff --git a/src/styles.scss b/src/styles.scss
index 9ac751d..5094da2 100644
--- a/src/styles.scss
+++ b/src/styles.scss
@@ -31,6 +31,15 @@ html, body { margin: 0; min-height: 100%; }
.app-stack > * + * { margin-block-start: var(--rhc-space-max-xl); }
.app-section { margin-block-start: var(--rhc-space-max-2xl); }
.app-text-subtle { color: var(--rhc-color-foreground-subtle); }
+/* responsive card grid: cards wrap and stretch, gap from the spacing scale */
+.app-card-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
+ gap: var(--rhc-space-max-xl);
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
/* Route transitions (withViewTransitions): cross-fade the routed CONTENT only.
The chrome gets its own stable view-transition-name so it's lifted out of the