Add ASP.NET Core backend hosting business rules; FE consumes via typed client
Move the authoritative business rules off the frontend into a real backend,
realising the BFF-lite + decision-DTO design (ADR-0001) that until now lived
only in static mock JSON.
Backend (backend/):
- ASP.NET Core (.NET 10) minimal API, contract-first, Swagger UI at /swagger.
- DDD Domain/ rules layer: profession derivation + applicable policy questions
(DiplomaRules), herregistratie eligibility + reason (HerregistratieRule),
scholing threshold (IntakePolicy), submit rejections + reference generation
(SubmissionRules). In-memory seeded data, ProblemDetails (RFC 7807) errors.
- 27 xUnit tests: rule units + endpoint integration incl. BRP no-address and
DUO not-found fallbacks and 422 submit paths.
Frontend (only infrastructure/ + contracts/ change, as the architecture promised):
- NSwag-generated typed client (api-client.ts), routed through Angular HttpClient
via a small fetch adapter so the ?scenario= interceptor still applies.
- GET adapters use resource({ loader: client.x }); submit commands call the client
and map ProblemDetails -> err. The hardcoded uren==0 / manual-diploma rules are
deleted (now server-side). Domain, stores, UI and format validators unchanged.
- Deleted the now-dead public/mock/*.json.
Tooling/docs:
- npm start proxies /api -> backend; npm run gen:api regenerates the client;
docker compose up runs both (bind mounts use :z for SELinux/Fedora).
- backend/README.md walkthrough: adding a policy question is a one-file backend
change, no FE change, no client regen. Updated CLAUDE.md + ARCHITECTURE.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -42,9 +42,10 @@ export class BigProfileStore {
|
||||
readonly decisions = computed<RemoteData<Err, HerregistratieDecisions>>(() => map(this.view(), (v) => v.decisions));
|
||||
|
||||
/** Specialisms/notes stay a separate stream (they have their own empty state). */
|
||||
readonly aantekeningen = computed<RemoteData<Err, Aantekening[]>>(() =>
|
||||
fromResource(this.aantekeningenRes, (v) => v.length === 0),
|
||||
);
|
||||
readonly aantekeningen = computed<RemoteData<Err, Aantekening[]>>(() => {
|
||||
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);
|
||||
|
||||
27
src/app/registratie/application/submit-registratie.spec.ts
Normal file
27
src/app/registratie/application/submit-registratie.spec.ts
Normal file
@@ -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') });
|
||||
});
|
||||
});
|
||||
@@ -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<Result<string, string>> {
|
||||
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<Result<string, string>> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<Aantekening[]>(() => '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 ?? '' };
|
||||
}
|
||||
|
||||
@@ -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<BrpAddressDto>(() => 'mock/brp-address.json');
|
||||
return resource({ loader: () => this.client.address() });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<DashboardViewDto>(() => 'mock/dashboard-view.json');
|
||||
return resource({ loader: () => this.client.dashboardView() });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<DuoLookupDto>(() => 'mock/duo-diplomas.json', { defaultValue: EMPTY });
|
||||
return resource({ loader: () => this.client.diplomas() });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,61 +18,69 @@ import { BigProfileStore } from '@registratie/application/big-profile.store';
|
||||
],
|
||||
template: `
|
||||
<app-page-shell heading="Mijn BIG-registratie">
|
||||
@if (store.pendingHerregistratie()) {
|
||||
<app-alert type="info">Uw herregistratie-aanvraag is in behandeling.</app-alert>
|
||||
}
|
||||
<div class="app-stack">
|
||||
@if (store.pendingHerregistratie()) {
|
||||
<app-alert type="info">Uw herregistratie-aanvraag is in behandeling.</app-alert>
|
||||
}
|
||||
|
||||
<!-- ONE state for two combined services (BIG-register + BRP). -->
|
||||
<app-async [data]="store.profile()">
|
||||
<ng-template appAsyncLoaded let-p>
|
||||
<app-registration-summary [reg]="$any(p).registration" />
|
||||
<div class="rhc-card rhc-card--default" style="margin-top:1rem">
|
||||
<app-heading [level]="2">Persoonsgegevens (BRP)</app-heading>
|
||||
<dl class="rhc-data-summary rhc-data-summary--row">
|
||||
<app-data-row key="Straat" [value]="$any(p).person.adres.straat" />
|
||||
<app-data-row key="Postcode" [value]="$any(p).person.adres.postcode" />
|
||||
<app-data-row key="Woonplaats" [value]="$any(p).person.adres.woonplaats" />
|
||||
</dl>
|
||||
</div>
|
||||
</ng-template>
|
||||
<ng-template appAsyncLoading>
|
||||
<app-skeleton height="2.5rem" [count]="6" />
|
||||
</ng-template>
|
||||
</app-async>
|
||||
|
||||
<div style="margin-top:2rem">
|
||||
<app-heading [level]="2">Specialismen en aantekeningen</app-heading>
|
||||
<app-async [data]="store.aantekeningen()">
|
||||
<ng-template appAsyncLoaded let-r>
|
||||
<app-registration-table [rows]="$any(r)" />
|
||||
<!-- ONE state for two combined services (BIG-register + BRP). -->
|
||||
<app-async [data]="store.profile()">
|
||||
<ng-template appAsyncLoaded let-p>
|
||||
<app-registration-summary [reg]="$any(p).registration" />
|
||||
<div class="rhc-card rhc-card--default app-section">
|
||||
<app-heading [level]="2">Persoonsgegevens (BRP)</app-heading>
|
||||
<dl class="rhc-data-summary rhc-data-summary--row">
|
||||
<app-data-row key="Straat" [value]="$any(p).person.adres.straat" />
|
||||
<app-data-row key="Postcode" [value]="$any(p).person.adres.postcode" />
|
||||
<app-data-row key="Woonplaats" [value]="$any(p).person.adres.woonplaats" />
|
||||
</dl>
|
||||
</div>
|
||||
</ng-template>
|
||||
<ng-template appAsyncLoading>
|
||||
<app-skeleton height="2.5rem" [count]="3" />
|
||||
</ng-template>
|
||||
<ng-template appAsyncEmpty>
|
||||
<p class="rhc-paragraph">U heeft nog geen specialismen of aantekeningen.</p>
|
||||
<app-skeleton height="2.5rem" [count]="6" />
|
||||
</ng-template>
|
||||
</app-async>
|
||||
</div>
|
||||
|
||||
<p style="margin-top:2rem">
|
||||
<app-link to="/registreren">Inschrijven in het BIG-register (registratiewizard) →</app-link>
|
||||
</p>
|
||||
<p>
|
||||
<app-link to="/registratie">Gegevens bekijken of een wijziging doorgeven →</app-link>
|
||||
</p>
|
||||
<p>
|
||||
<app-link to="/herregistratie">Herregistratie aanvragen →</app-link>
|
||||
</p>
|
||||
<p>
|
||||
<app-link to="/intake">Herregistratie-intake (vragenlijst met vertakkingen) →</app-link>
|
||||
</p>
|
||||
<p>
|
||||
<app-link to="/concepts">Functionele patronen (impossible states) →</app-link>
|
||||
</p>
|
||||
<section>
|
||||
<app-heading [level]="2">Specialismen en aantekeningen</app-heading>
|
||||
<app-async [data]="store.aantekeningen()">
|
||||
<ng-template appAsyncLoaded let-r>
|
||||
<app-registration-table [rows]="$any(r)" />
|
||||
</ng-template>
|
||||
<ng-template appAsyncLoading>
|
||||
<app-skeleton height="2.5rem" [count]="3" />
|
||||
</ng-template>
|
||||
<ng-template appAsyncEmpty>
|
||||
<p class="rhc-paragraph">U heeft nog geen specialismen of aantekeningen.</p>
|
||||
</ng-template>
|
||||
</app-async>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<app-heading [level]="2">Wat wilt u doen?</app-heading>
|
||||
<ul class="app-card-grid app-section">
|
||||
@for (a of acties; track a.to) {
|
||||
<li class="rhc-card rhc-card--default">
|
||||
<app-heading [level]="3">{{ a.titel }}</app-heading>
|
||||
<p class="rhc-paragraph">{{ a.tekst }}</p>
|
||||
<app-link [to]="a.to">{{ a.actie }} →</app-link>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</app-page-shell>
|
||||
`,
|
||||
})
|
||||
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' },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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') {
|
||||
<app-stepper class="app-section" [steps]="stepLabels" [current]="cursor()" />
|
||||
<h2 #stepHeading tabindex="-1" class="rhc-heading nl-heading--level-2 app-section">{{ stepTitle() }}</h2>
|
||||
<form (ngSubmit)="onPrimary()" class="app-form">
|
||||
@switch (step()) {
|
||||
@case ('adres') {
|
||||
<app-wizard-shell
|
||||
[steps]="stepLabels"
|
||||
[current]="cursor()"
|
||||
[stepTitle]="stepTitle()"
|
||||
[status]="shellStatus()"
|
||||
[primaryLabel]="primaryLabel()"
|
||||
[canGoBack]="cursor() > 0"
|
||||
[errors]="errorList()"
|
||||
[errorMessage]="errorMessage()"
|
||||
submittingLabel="Uw registratie wordt verwerkt…"
|
||||
(primary)="onPrimary()"
|
||||
(back)="dispatch({ tag: 'Back' })"
|
||||
(cancel)="restart()"
|
||||
(retry)="onRetry()">
|
||||
|
||||
@switch (step()) {
|
||||
@case ('adres') {
|
||||
@if (adresStatus() === 'laden') {
|
||||
<app-skeleton height="2.5rem" [count]="4" />
|
||||
} @else {
|
||||
@@ -140,38 +150,22 @@ const HANDMATIG = '__handmatig__'; // sentinel option: "my diploma isn't listed"
|
||||
<app-button type="button" variant="subtle" (click)="dispatch({ tag: 'GaNaarStap', cursor: 0 })">Adres wijzigen</app-button>
|
||||
<app-button type="button" variant="subtle" (click)="dispatch({ tag: 'GaNaarStap', cursor: 1 })">Diploma wijzigen</app-button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
<div class="app-button-row app-button-row--spaced">
|
||||
@if (cursor() > 0) {
|
||||
<app-button type="button" variant="secondary" (click)="dispatch({ tag: 'Back' })">Vorige</app-button>
|
||||
}
|
||||
<app-button type="submit" variant="primary">{{ step() === 'controle' ? 'Registratie indienen' : 'Volgende' }}</app-button>
|
||||
<app-button type="button" variant="subtle" (click)="restart()">Annuleren</app-button>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
}
|
||||
@case ('Indienen') {
|
||||
<app-spinner /> <span>Uw registratie wordt verwerkt…</span>
|
||||
}
|
||||
@case ('Ingediend') {
|
||||
|
||||
<div wizardSuccess>
|
||||
<app-alert type="ok">Uw registratie is ontvangen. Uw referentienummer is {{ referentie() }}. Bewaar dit nummer voor uw administratie.</app-alert>
|
||||
<div class="app-section">
|
||||
<app-button variant="secondary" (click)="restart()">Nieuwe registratie starten</app-button>
|
||||
</div>
|
||||
}
|
||||
@case ('Mislukt') {
|
||||
<app-alert type="error">Het indienen is niet gelukt: {{ failedError() }}</app-alert>
|
||||
<div class="app-section">
|
||||
<app-button variant="secondary" (click)="onRetry()">Opnieuw proberen</app-button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</app-wizard-shell>
|
||||
`,
|
||||
})
|
||||
export class RegistratieWizardComponent {
|
||||
private brp = inject(BrpAdapter);
|
||||
private duo = inject(DuoAdapter);
|
||||
private apiClient = inject(ApiClient);
|
||||
private store = createStore<RegistratieState, RegistratieMsg>(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<ElementRef<HTMLElement>>('stepHeading');
|
||||
|
||||
private invullen = computed(() => (this.state().tag === 'Invullen' ? (this.state() as Extract<RegistratieState, { tag: 'Invullen' }>) : null));
|
||||
protected cursor = computed(() => this.invullen()?.cursor ?? 0);
|
||||
protected draft = computed<Draft>(() => 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<RegistratieState, { tag: 'Ingediend' }>).referentie : ''));
|
||||
protected failedError = computed(() => (this.state().tag === 'Mislukt' ? (this.state() as Extract<RegistratieState, { tag: 'Mislukt' }>).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<WizardStatus>(() => {
|
||||
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<WizardError[]>(() => {
|
||||
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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user