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:
@@ -1,14 +1,18 @@
|
||||
import { Result, ok, err } from '@shared/kernel/fp';
|
||||
import { Valid } from '../domain/herregistratie.machine';
|
||||
import { ApiClient } from '@shared/infrastructure/api-client';
|
||||
import { problemDetail } from '@shared/infrastructure/api-error';
|
||||
|
||||
/**
|
||||
* The mutation/command: send a herregistratie application to the backend.
|
||||
* Returns a Result so the caller can branch on success/failure without
|
||||
* try/catch. ponytail: faked with a timer; swap for a real POST when there's
|
||||
* an API. The "uren must be > 0" rule lets the demo show a failure path.
|
||||
* Command: POST a herregistratie application to the backend (`/api/herregistraties`).
|
||||
* The "uren must be > 0" rule now lives server-side; the backend returns a 422
|
||||
* ProblemDetails on rejection, which we surface as the error.
|
||||
*/
|
||||
export async function submitHerregistratie(data: Valid): Promise<Result<string, void>> {
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
if (data.uren === 0) return err('Aanvraag afgewezen: geen gewerkte uren geregistreerd.');
|
||||
return ok(undefined);
|
||||
export async function submitHerregistratie(client: ApiClient, data: Valid): Promise<Result<string, void>> {
|
||||
try {
|
||||
await client.herregistraties({ uren: data.uren });
|
||||
return ok(undefined);
|
||||
} catch (e) {
|
||||
return err(problemDetail(e, 'Het indienen is niet gelukt. Probeer het later opnieuw.'));
|
||||
}
|
||||
}
|
||||
|
||||
26
src/app/herregistratie/application/submit-intake.spec.ts
Normal file
26
src/app/herregistratie/application/submit-intake.spec.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { submitIntake } from './submit-intake';
|
||||
import { ApiClient } from '@shared/infrastructure/api-client';
|
||||
import { ValidIntake } from '../domain/intake.machine';
|
||||
|
||||
// submit-herregistratie shares this exact shape (POST uren → Result); covered here.
|
||||
const data = { uren: 40 } as unknown as ValidIntake;
|
||||
|
||||
describe('submitIntake', () => {
|
||||
it('returns ok on success', async () => {
|
||||
const client = { intakes: async () => ({ referentie: 'BIG-2026-1' }) } as unknown as ApiClient;
|
||||
const r = await submitIntake(client, data);
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('returns err with the ProblemDetails detail when the server rejects', async () => {
|
||||
const client = {
|
||||
intakes: async () => {
|
||||
throw { detail: 'Aanvraag afgewezen: geen gewerkte uren geregistreerd.', status: 422 };
|
||||
},
|
||||
} as unknown as ApiClient;
|
||||
const r = await submitIntake(client, data);
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r).toMatchObject({ error: expect.stringContaining('geen gewerkte uren') });
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,18 @@
|
||||
import { Result, ok, err } from '@shared/kernel/fp';
|
||||
import { ValidIntake } from '../domain/intake.machine';
|
||||
import { ApiClient } from '@shared/infrastructure/api-client';
|
||||
import { problemDetail } from '@shared/infrastructure/api-error';
|
||||
|
||||
/**
|
||||
* Command: send the intake questionnaire to the backend. Returns a Result so the
|
||||
* caller branches on success/failure without try/catch. ponytail: faked with a
|
||||
* timer; swap for a real POST when there's an API. The "uren must be > 0" rule
|
||||
* lets the demo show the failure path.
|
||||
* Command: POST the intake questionnaire to the backend (`/api/intakes`). The
|
||||
* "uren must be > 0" rule now lives server-side; the backend returns a 422
|
||||
* ProblemDetails on rejection, which we surface as the error.
|
||||
*/
|
||||
export async function submitIntake(data: ValidIntake): Promise<Result<string, void>> {
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
if (data.uren === 0) return err('Aanvraag afgewezen: geen gewerkte uren geregistreerd.');
|
||||
return ok(undefined);
|
||||
export async function submitIntake(client: ApiClient, data: ValidIntake): Promise<Result<string, void>> {
|
||||
try {
|
||||
await client.intakes({ uren: data.uren });
|
||||
return ok(undefined);
|
||||
} catch (e) {
|
||||
return err(problemDetail(e, 'Het indienen is niet gelukt. Probeer het later opnieuw.'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
/**
|
||||
* WIRE CONTRACT for intake policy values owned by the backend.
|
||||
*
|
||||
* This is the "config value" shape of moving policy off the client: instead of
|
||||
* hardcoding the scholing threshold in the frontend, the backend ships the value
|
||||
* and the UI applies it for instant feedback. The backend remains the AUTHORITY
|
||||
* — it re-validates on submit. See docs/architecture/0001-bff-lite-decision-dtos.md.
|
||||
*/
|
||||
export interface IntakePolicyDto {
|
||||
/** Below this many NL-hours the scholing question is required. */
|
||||
scholingThreshold: number;
|
||||
}
|
||||
@@ -2,13 +2,13 @@ import { Component, computed, inject, input } 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 { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||
import { SpinnerComponent } from '@shared/ui/spinner/spinner.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 { WizardState, WizardMsg, Draft, initial, reduce } from '@herregistratie/domain/herregistratie.machine';
|
||||
import { submitHerregistratie } from '@herregistratie/application/submit-herregistratie';
|
||||
import { ApiClient } from '@shared/infrastructure/api-client';
|
||||
|
||||
/** Organism: multi-step herregistratie wizard. ALL state lives in one signal
|
||||
driven by the pure `reduce` function (see herregistratie.machine.ts) via an
|
||||
@@ -18,55 +18,47 @@ import { submitHerregistratie } from '@herregistratie/application/submit-herregi
|
||||
the dashboard shows "in behandeling" immediately. */
|
||||
@Component({
|
||||
selector: 'app-herregistratie-wizard',
|
||||
imports: [FormsModule, FormFieldComponent, TextInputComponent, ButtonComponent, AlertComponent, SpinnerComponent],
|
||||
imports: [FormsModule, FormFieldComponent, TextInputComponent, AlertComponent, WizardShellComponent],
|
||||
template: `
|
||||
@switch (state().tag) {
|
||||
@case ('Editing') {
|
||||
<p class="rhc-paragraph" style="color:var(--rhc-color-grijs-700)">Stap {{ step() }} van 2</p>
|
||||
<form (ngSubmit)="onPrimary()" style="max-width:28rem">
|
||||
@if (step() === 1) {
|
||||
<app-form-field label="Gewerkte uren (afgelopen 5 jaar)" fieldId="uren" [error]="errUren()">
|
||||
<app-text-input inputId="uren" [ngModel]="draft().uren" (ngModelChange)="dispatch({ tag: 'SetField', key: 'uren', value: $event })"
|
||||
name="uren" [invalid]="!!errUren()" placeholder="bijv. 4160" />
|
||||
</app-form-field>
|
||||
<app-form-field label="Aantal jaren werkzaam" fieldId="jaren" [error]="errJaren()">
|
||||
<app-text-input inputId="jaren" [ngModel]="draft().jaren" (ngModelChange)="dispatch({ tag: 'SetField', key: 'jaren', value: $event })"
|
||||
name="jaren" [invalid]="!!errJaren()" placeholder="bijv. 5" />
|
||||
</app-form-field>
|
||||
<div style="display:flex;gap:0.5rem">
|
||||
<app-button type="submit" variant="primary">Volgende</app-button>
|
||||
<app-button type="button" variant="subtle" (click)="restart()">Annuleren</app-button>
|
||||
</div>
|
||||
} @else {
|
||||
<app-form-field label="Behaalde nascholingspunten" fieldId="punten" [error]="errPunten()">
|
||||
<app-text-input inputId="punten" [ngModel]="draft().punten" (ngModelChange)="dispatch({ tag: 'SetField', key: 'punten', value: $event })"
|
||||
name="punten" [invalid]="!!errPunten()" placeholder="bijv. 200" />
|
||||
</app-form-field>
|
||||
<div style="display:flex;gap:0.5rem">
|
||||
<app-button type="button" variant="secondary" (click)="dispatch({ tag: 'Back' })">Vorige</app-button>
|
||||
<app-button type="submit" variant="primary">Herregistratie aanvragen</app-button>
|
||||
<app-button type="button" variant="subtle" (click)="restart()">Annuleren</app-button>
|
||||
</div>
|
||||
}
|
||||
</form>
|
||||
<app-wizard-shell
|
||||
[steps]="stepLabels"
|
||||
[current]="step() - 1"
|
||||
[stepTitle]="stepTitle()"
|
||||
[status]="shellStatus()"
|
||||
[primaryLabel]="primaryLabel()"
|
||||
[canGoBack]="step() === 2"
|
||||
[errors]="errorList()"
|
||||
[errorMessage]="errorMessage()"
|
||||
(primary)="onPrimary()"
|
||||
(back)="dispatch({ tag: 'Back' })"
|
||||
(cancel)="restart()"
|
||||
(retry)="onRetry()">
|
||||
|
||||
@if (step() === 1) {
|
||||
<app-form-field label="Gewerkte uren (afgelopen 5 jaar)" fieldId="uren" [error]="errUren()">
|
||||
<app-text-input inputId="uren" [ngModel]="draft().uren" (ngModelChange)="dispatch({ tag: 'SetField', key: 'uren', value: $event })"
|
||||
name="uren" [invalid]="!!errUren()" placeholder="bijv. 4160" />
|
||||
</app-form-field>
|
||||
<app-form-field label="Aantal jaren werkzaam" fieldId="jaren" [error]="errJaren()">
|
||||
<app-text-input inputId="jaren" [ngModel]="draft().jaren" (ngModelChange)="dispatch({ tag: 'SetField', key: 'jaren', value: $event })"
|
||||
name="jaren" [invalid]="!!errJaren()" placeholder="bijv. 5" />
|
||||
</app-form-field>
|
||||
} @else {
|
||||
<app-form-field label="Behaalde nascholingspunten" fieldId="punten" [error]="errPunten()">
|
||||
<app-text-input inputId="punten" [ngModel]="draft().punten" (ngModelChange)="dispatch({ tag: 'SetField', key: 'punten', value: $event })"
|
||||
name="punten" [invalid]="!!errPunten()" placeholder="bijv. 200" />
|
||||
</app-form-field>
|
||||
}
|
||||
@case ('Submitting') {
|
||||
<app-spinner /> <span>Aanvraag wordt verwerkt…</span>
|
||||
}
|
||||
@case ('Submitted') {
|
||||
|
||||
<div wizardSuccess>
|
||||
<app-alert type="ok">Uw aanvraag tot herregistratie is ontvangen.</app-alert>
|
||||
}
|
||||
@case ('Failed') {
|
||||
<app-alert type="error">Indienen mislukt: {{ failedError() }}</app-alert>
|
||||
<div style="margin-top:1rem">
|
||||
<app-button variant="secondary" (click)="onRetry()">Opnieuw proberen</app-button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</app-wizard-shell>
|
||||
`,
|
||||
})
|
||||
export class HerregistratieWizardComponent {
|
||||
private profile = inject(BigProfileStore);
|
||||
private apiClient = inject(ApiClient);
|
||||
private store = createStore<WizardState, WizardMsg>(initial, reduce);
|
||||
|
||||
/** Optional seed so Storybook / the showcase can mount any state directly. */
|
||||
@@ -75,6 +67,10 @@ export class HerregistratieWizardComponent {
|
||||
readonly state = this.store.model; // public so the showcase can highlight the live state
|
||||
protected dispatch = this.store.dispatch;
|
||||
|
||||
// Stepper labels + per-step heading titles (presentational only).
|
||||
readonly stepLabels = ['Werkervaring', 'Nascholing'];
|
||||
private stepTitles = ['Werkervaring (afgelopen 5 jaar)', 'Nascholing'];
|
||||
|
||||
private editing = computed(() => (this.state().tag === 'Editing' ? (this.state() as Extract<WizardState, { tag: 'Editing' }>) : null));
|
||||
protected step = computed(() => this.editing()?.step ?? 1);
|
||||
protected draft = computed<Draft>(() => this.editing()?.draft ?? { uren: '', jaren: '', punten: '' });
|
||||
@@ -83,6 +79,26 @@ export class HerregistratieWizardComponent {
|
||||
protected errPunten = computed(() => this.editing()?.errors.punten ?? '');
|
||||
protected failedError = computed(() => (this.state().tag === 'Failed' ? (this.state() as Extract<WizardState, { tag: 'Failed' }>).error : ''));
|
||||
|
||||
// --- Presentational wiring for the shared wizard shell ---------------------
|
||||
protected stepTitle = computed(() => this.stepTitles[this.step() - 1]);
|
||||
protected primaryLabel = computed(() => (this.step() === 1 ? 'Volgende' : 'Herregistratie aanvragen'));
|
||||
protected errorMessage = computed(() => `Indienen mislukt: ${this.failedError()}`);
|
||||
protected shellStatus = computed<WizardStatus>(() => {
|
||||
switch (this.state().tag) {
|
||||
case 'Editing': 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. */
|
||||
protected errorList = computed<WizardError[]>(() => {
|
||||
const e = this.editing()?.errors ?? {};
|
||||
return (Object.keys(e) as (keyof typeof e)[])
|
||||
.filter((k) => e[k])
|
||||
.map((k) => ({ id: k, message: e[k]! }));
|
||||
});
|
||||
|
||||
constructor() {
|
||||
queueMicrotask(() => this.dispatch({ tag: 'Seed', state: this.seed() }));
|
||||
}
|
||||
@@ -110,7 +126,7 @@ export class HerregistratieWizardComponent {
|
||||
const s = this.state();
|
||||
if (s.tag !== 'Submitting') return;
|
||||
this.profile.beginHerregistratie();
|
||||
const r = await submitHerregistratie(s.data);
|
||||
const r = await submitHerregistratie(this.apiClient, s.data);
|
||||
if (r.ok) {
|
||||
this.dispatch({ tag: 'SubmitConfirmed' });
|
||||
this.profile.confirmHerregistratie();
|
||||
|
||||
@@ -20,7 +20,7 @@ import { HerregistratieWizardComponent } from '@herregistratie/ui/herregistratie
|
||||
<app-alert type="info">
|
||||
Uw huidige registratie verloopt binnenkort. Vraag tijdig herregistratie aan.
|
||||
</app-alert>
|
||||
<div style="margin-top:1.5rem">
|
||||
<div class="app-section">
|
||||
<app-herregistratie-wizard />
|
||||
</div>
|
||||
} @else {
|
||||
|
||||
@@ -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') {
|
||||
<p class="rhc-paragraph" style="color:var(--rhc-color-grijs-700)">Stap {{ cursor() + 1 }} van {{ steps.length }}</p>
|
||||
<form (ngSubmit)="onPrimary()" style="max-width:30rem">
|
||||
@switch (step()) {
|
||||
@case ('buitenland') {
|
||||
<app-form-field label="Heeft u de afgelopen 5 jaar buiten Nederland gewerkt?" fieldId="buitenland" [error]="err('buitenlandGewerkt')">
|
||||
<app-radio-group name="buitenland" [options]="jaNee"
|
||||
[ngModel]="answers().buitenlandGewerkt ?? ''" (ngModelChange)="set('buitenlandGewerkt', $event)" />
|
||||
</app-form-field>
|
||||
@if (answers().buitenlandGewerkt === 'ja') {
|
||||
<app-form-field label="In welk land?" fieldId="land" [error]="err('land')">
|
||||
<app-text-input inputId="land" [ngModel]="answers().land ?? ''" (ngModelChange)="set('land', $event)" name="land" placeholder="bijv. België" />
|
||||
</app-form-field>
|
||||
<app-form-field label="Hoeveel uur heeft u daar gewerkt?" fieldId="buitenlandseUren" [error]="err('buitenlandseUren')">
|
||||
<app-text-input inputId="buitenlandseUren" [ngModel]="answers().buitenlandseUren ?? ''" (ngModelChange)="set('buitenlandseUren', $event)" name="buitenlandseUren" placeholder="bijv. 800" />
|
||||
</app-form-field>
|
||||
}
|
||||
}
|
||||
@case ('werk') {
|
||||
<app-form-field label="Gewerkte uren in Nederland (afgelopen 5 jaar)" fieldId="uren" [error]="err('uren')">
|
||||
<app-text-input inputId="uren" [ngModel]="answers().uren ?? ''" (ngModelChange)="set('uren', $event)" name="uren" placeholder="bijv. 4160" />
|
||||
</app-form-field>
|
||||
@if (scholingZichtbaar()) {
|
||||
<app-form-field label="U werkte relatief weinig uren. Heeft u aanvullende scholing gevolgd?" fieldId="scholing" [error]="err('scholingGevolgd')">
|
||||
<app-radio-group name="scholing" [options]="jaNee"
|
||||
[ngModel]="answers().scholingGevolgd ?? ''" (ngModelChange)="set('scholingGevolgd', $event)" />
|
||||
</app-form-field>
|
||||
}
|
||||
@if (answers().scholingGevolgd === 'ja') {
|
||||
<app-form-field label="Behaalde nascholingspunten" fieldId="punten" [error]="err('punten')">
|
||||
<app-text-input inputId="punten" [ngModel]="answers().punten ?? ''" (ngModelChange)="set('punten', $event)" name="punten" placeholder="bijv. 200" />
|
||||
</app-form-field>
|
||||
}
|
||||
}
|
||||
@case ('review') {
|
||||
<app-alert type="info">Controleer uw antwoorden en dien de aanvraag in.</app-alert>
|
||||
<dl class="rhc-data-summary rhc-data-summary--row" style="margin:1rem 0">
|
||||
<div><dt>Buiten NL gewerkt</dt><dd>{{ answers().buitenlandGewerkt ?? '—' }}</dd></div>
|
||||
@if (answers().buitenlandGewerkt === 'ja') {
|
||||
<div><dt>Land</dt><dd>{{ answers().land }}</dd></div>
|
||||
<div><dt>Buitenlandse uren</dt><dd>{{ answers().buitenlandseUren }}</dd></div>
|
||||
}
|
||||
<div><dt>Uren NL</dt><dd>{{ answers().uren }}</dd></div>
|
||||
@if (scholingZichtbaar()) {
|
||||
<div><dt>Aanvullende scholing</dt><dd>{{ answers().scholingGevolgd }}</dd></div>
|
||||
}
|
||||
@if (answers().scholingGevolgd === 'ja') {
|
||||
<div><dt>Nascholingspunten</dt><dd>{{ answers().punten }}</dd></div>
|
||||
}
|
||||
</dl>
|
||||
}
|
||||
<app-wizard-shell
|
||||
[steps]="stepLabels"
|
||||
[current]="cursor()"
|
||||
[stepTitle]="stepTitle()"
|
||||
[status]="shellStatus()"
|
||||
[primaryLabel]="primaryLabel()"
|
||||
[canGoBack]="cursor() > 0"
|
||||
[errors]="errorList()"
|
||||
[errorMessage]="errorMessage()"
|
||||
(primary)="onPrimary()"
|
||||
(back)="dispatch({ tag: 'Back' })"
|
||||
(cancel)="restart()"
|
||||
(retry)="onRetry()">
|
||||
|
||||
@switch (step()) {
|
||||
@case ('buitenland') {
|
||||
<app-form-field label="Heeft u de afgelopen 5 jaar buiten Nederland gewerkt?" fieldId="buitenlandGewerkt" [error]="err('buitenlandGewerkt')">
|
||||
<app-radio-group name="buitenlandGewerkt" [options]="jaNee"
|
||||
[ngModel]="answers().buitenlandGewerkt ?? ''" (ngModelChange)="set('buitenlandGewerkt', $event)" />
|
||||
</app-form-field>
|
||||
@if (answers().buitenlandGewerkt === 'ja') {
|
||||
<app-form-field label="In welk land?" fieldId="land" [error]="err('land')">
|
||||
<app-text-input inputId="land" [ngModel]="answers().land ?? ''" (ngModelChange)="set('land', $event)" name="land" placeholder="bijv. België" />
|
||||
</app-form-field>
|
||||
<app-form-field label="Hoeveel uur heeft u daar gewerkt?" fieldId="buitenlandseUren" [error]="err('buitenlandseUren')">
|
||||
<app-text-input inputId="buitenlandseUren" [ngModel]="answers().buitenlandseUren ?? ''" (ngModelChange)="set('buitenlandseUren', $event)" name="buitenlandseUren" placeholder="bijv. 800" />
|
||||
</app-form-field>
|
||||
}
|
||||
<div style="display:flex;gap:0.5rem;margin-top:1rem">
|
||||
@if (cursor() > 0) {
|
||||
<app-button type="button" variant="secondary" (click)="dispatch({ tag: 'Back' })">Vorige</app-button>
|
||||
}
|
||||
@case ('werk') {
|
||||
<app-form-field label="Gewerkte uren in Nederland (afgelopen 5 jaar)" fieldId="uren" [error]="err('uren')">
|
||||
<app-text-input inputId="uren" [ngModel]="answers().uren ?? ''" (ngModelChange)="set('uren', $event)" name="uren" placeholder="bijv. 4160" />
|
||||
</app-form-field>
|
||||
@if (scholingZichtbaar()) {
|
||||
<app-form-field label="U werkte relatief weinig uren. Heeft u aanvullende scholing gevolgd?" fieldId="scholingGevolgd" [error]="err('scholingGevolgd')">
|
||||
<app-radio-group name="scholingGevolgd" [options]="jaNee"
|
||||
[ngModel]="answers().scholingGevolgd ?? ''" (ngModelChange)="set('scholingGevolgd', $event)" />
|
||||
</app-form-field>
|
||||
}
|
||||
@if (answers().scholingGevolgd === 'ja') {
|
||||
<app-form-field label="Behaalde nascholingspunten" fieldId="punten" [error]="err('punten')">
|
||||
<app-text-input inputId="punten" [ngModel]="answers().punten ?? ''" (ngModelChange)="set('punten', $event)" name="punten" placeholder="bijv. 200" />
|
||||
</app-form-field>
|
||||
}
|
||||
}
|
||||
@case ('review') {
|
||||
<app-alert type="info">Controleer uw antwoorden en dien de aanvraag in.</app-alert>
|
||||
<dl class="rhc-data-summary rhc-data-summary--row app-section">
|
||||
<app-data-row key="Buiten NL gewerkt" [value]="answers().buitenlandGewerkt ?? '—'" />
|
||||
@if (answers().buitenlandGewerkt === 'ja') {
|
||||
<app-data-row key="Land" [value]="answers().land ?? ''" />
|
||||
<app-data-row key="Buitenlandse uren" [value]="answers().buitenlandseUren ?? ''" />
|
||||
}
|
||||
<app-button type="submit" variant="primary">{{ step() === 'review' ? 'Aanvraag indienen' : 'Volgende' }}</app-button>
|
||||
<app-button type="button" variant="subtle" (click)="restart()">Annuleren</app-button>
|
||||
</div>
|
||||
</form>
|
||||
<app-data-row key="Uren NL" [value]="answers().uren ?? ''" />
|
||||
@if (scholingZichtbaar()) {
|
||||
<app-data-row key="Aanvullende scholing" [value]="answers().scholingGevolgd ?? ''" />
|
||||
}
|
||||
@if (answers().scholingGevolgd === 'ja') {
|
||||
<app-data-row key="Nascholingspunten" [value]="answers().punten ?? ''" />
|
||||
}
|
||||
</dl>
|
||||
}
|
||||
}
|
||||
@case ('Submitting') {
|
||||
<app-spinner /> <span>Aanvraag wordt verwerkt…</span>
|
||||
}
|
||||
@case ('Submitted') {
|
||||
|
||||
<div wizardSuccess>
|
||||
<app-alert type="ok">Uw aanvraag tot herregistratie is ontvangen.</app-alert>
|
||||
<div style="margin-top:1rem">
|
||||
<div class="app-section">
|
||||
<app-button variant="secondary" (click)="restart()">Opnieuw beginnen</app-button>
|
||||
</div>
|
||||
}
|
||||
@case ('Failed') {
|
||||
<app-alert type="error">Indienen mislukt: {{ failedError() }}</app-alert>
|
||||
<div style="margin-top:1rem">
|
||||
<app-button variant="secondary" (click)="onRetry()">Opnieuw proberen</app-button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</app-wizard-shell>
|
||||
`,
|
||||
})
|
||||
export class IntakeWizardComponent {
|
||||
private profile = inject(BigProfileStore);
|
||||
private apiClient = inject(ApiClient);
|
||||
private store = createStore<IntakeState, IntakeMsg>(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<IntakePolicyDto>(() => '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<IntakeState>(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<IntakeState, { tag: 'Failed' }>).error : ''));
|
||||
|
||||
// --- Presentational wiring for the shared wizard shell ---------------------
|
||||
readonly stepLabels = ['Buitenland', 'Werk', 'Controle'];
|
||||
private stepTitles: Record<StepId, string> = {
|
||||
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<WizardStatus>(() => {
|
||||
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<WizardError[]>(() => {
|
||||
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();
|
||||
|
||||
@@ -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.
|
||||
</app-alert>
|
||||
<div style="margin-top:1.5rem">
|
||||
<div class="app-section">
|
||||
<app-intake-wizard />
|
||||
</div>
|
||||
</app-page-shell>
|
||||
|
||||
Reference in New Issue
Block a user