feat(WP-67): merge behandelportal into this repo as a monorepo

Restructures into apps/ssp + apps/behandelportal (two Angular projects)
plus libs/shared + libs/beheer (cross-app libraries), replacing WP-61's
separate sibling repo. That split had already produced real drift: a
hand-vendored copy of the backend's OpenAPI doc, a shared/ui+layout tree
forked and silently diverging (7 files), and beheer + the styles.scss
token bridge duplicated byte-for-byte across both repos.

- git mv the SSP's src/app/* into apps/ssp/; fold shared/, beheer/,
  environments/, the Storybook docs/*.mdx, and styles.scss into
  libs/shared + libs/beheer (all confirmed identical between the two
  repos before merging). auth stays deliberately duplicated per
  ADR-0002 (actor-specific, expected to diverge) - amended there.
- One generated API client (libs/shared), no more vendored swagger.json.
- .dependency-cruiser split into a base factory + one config per app,
  and Storybook into .storybook-ssp/.storybook-behandelportal - both
  forced by the @auth/* alias resolving to different directories per app.
- SiteHeaderComponent/ShellComponent gained HEADER_NAV_ITEMS/
  HEADER_ADMIN_LINKS/DEBUG_PANEL injection tokens so each app supplies
  its own nav/admin-links/dev-panel instead of one being hardcoded.
- CLAUDE.md, ARCHITECTURE.md, dependencies.md, and ADR-0002 updated;
  WP-67 backlog entry documents the full decision trail.

npm run ci green (lint, dep:check x2, 360 tests across ssp/
behandelportal/shared/beheer, both localized builds, backend tests,
snippet + api-client drift); both dev servers, both Storybook
instances, and docker compose verified working.

The old sibling repo (/home/eho/repos/behandelportal) is left
untouched, not deleted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-08-02 21:01:57 +02:00
co-authored by Claude Sonnet 5
parent d3f3b13345
commit e7156c5132
403 changed files with 7103 additions and 60917 deletions
@@ -0,0 +1,21 @@
import { Injectable, computed, inject } from '@angular/core';
import { SCHOLING_THRESHOLD_DEFAULT } from '../domain/intake.machine';
import { IntakePolicyAdapter } from '../infrastructure/intake-policy.adapter';
/**
* Application-layer facade for the server-owned intake policy (the scholing
* threshold config value). It owns the httpResource (created here, in the required
* injection context) and exposes the threshold as a derived signal, falling back to
* the domain default until the backend answers. The UI reaches the network through
* application/, never infrastructure/ directly (CLAUDE.md §1). The backend stays the
* authority and re-validates on submit.
*/
@Injectable({ providedIn: 'root' })
export class IntakePolicyStore {
private policy = inject(IntakePolicyAdapter);
private policyRes = this.policy.policyResource();
readonly scholingThreshold = computed(
() => this.policyRes.value()?.scholingThreshold ?? SCHOLING_THRESHOLD_DEFAULT,
);
}
@@ -0,0 +1,15 @@
import { describe, it, expect } from 'vitest';
import { hasProgress, initial, WizardState } from './herregistratie.machine';
const editing = initial as Extract<WizardState, { tag: 'Editing' }>;
describe('herregistratie hasProgress', () => {
it('is false for a fresh form', () => {
expect(hasProgress(editing)).toBe(false);
});
it('is true once a field is filled or the user advances', () => {
expect(hasProgress({ ...editing, draft: { uren: '40', jaren: '', punten: '' } })).toBe(true);
expect(hasProgress({ ...editing, step: 2 })).toBe(true);
});
});
@@ -0,0 +1,146 @@
import { describe, it, expect } from 'vitest';
import { ok, err } from '@shared/kernel/fp';
import { initialUpload } from '@shared/upload/upload.machine';
import {
initial,
next,
back,
gaNaarStap,
submit,
resolve,
reduce,
WizardState,
} from './herregistratie.machine';
const editing1 = (uren: string, jaren = '5', punten = ''): WizardState => ({
tag: 'Editing',
step: 1,
draft: { uren, jaren, punten },
errors: {},
upload: initialUpload,
});
const editing2 = (uren: string, punten: string, jaren = '5'): WizardState => ({
tag: 'Editing',
step: 2,
draft: { uren, jaren, punten },
errors: {},
upload: initialUpload,
});
const editing3 = (uren: string, punten: string, jaren = '5'): WizardState => ({
tag: 'Editing',
step: 3,
draft: { uren, jaren, punten },
errors: {},
upload: initialUpload,
});
describe('wizard.machine', () => {
it('next advances only when step 1 parses', () => {
expect(next(initial).tag).toBe('Editing'); // empty uren -> stays, with error
expect((next(initial) as any).errors.uren).toBeTruthy();
expect((next(editing1('4160')) as any).step).toBe(2);
});
it('next advances step 2 → 3 only when punten parses', () => {
expect((next(editing2('4160', 'x')) as any).step).toBe(2); // invalid punten -> stays
expect((next(editing2('4160', 'x')) as any).errors.punten).toBeTruthy();
expect((next(editing2('4160', '200')) as any).step).toBe(3);
});
it('submit reaches Submitting ONLY from step 3 with fully valid data', () => {
expect(submit(editing2('4160', '200')).tag).toBe('Editing'); // not on step 3 -> no Submitting
expect(submit(editing3('4160', 'x')).tag).toBe('Editing'); // invalid punten
const good = submit(editing3('4160', '200'));
expect(good.tag).toBe('Submitting');
expect((good as any).data).toEqual({ uren: 4160, jaren: 5, punten: 200, documents: [] });
});
it('next requires BOTH step-1 fields (uren and jaren)', () => {
expect((next(editing1('4160', '')) as any).errors.jaren).toBeTruthy(); // jaren empty -> stays
expect((next(editing1('4160', '')) as any).step).toBe(1);
expect((next(editing1('4160', '5')) as any).step).toBe(2); // both valid -> advance
});
it('back steps down one (3 → 2 → 1) and is a no-op from step 1', () => {
expect(back(initial)).toBe(initial); // step 1, nothing to go back to
expect((back(editing3('1', '2')) as any).step).toBe(2);
expect((back(editing2('1', '2')) as any).step).toBe(1);
expect(resolve(initial, ok(undefined))).toBe(initial); // not Submitting
});
it('resolve maps Submitting to Submitted / Failed', () => {
const submitting = submit(editing3('4160', '200'));
expect(resolve(submitting, ok(undefined)).tag).toBe('Submitted');
expect(resolve(submitting, err('boom')).tag).toBe('Failed');
});
it('gaNaarStap jumps back to an earlier step, clearing errors', () => {
expect((gaNaarStap(editing3('4160', '200'), 1) as any).step).toBe(1);
});
it('gaNaarStap ignores a same/forward jump and jumps outside Editing', () => {
const e3 = editing3('4160', '200');
expect(gaNaarStap(e3, 3)).toBe(e3); // same step -> no-op
const submitting = submit(e3);
expect(gaNaarStap(submitting, 1)).toBe(submitting); // not Editing -> no-op
});
});
describe('reduce (message-driven)', () => {
it('drives the full happy path via messages', () => {
let s: WizardState = initial;
s = reduce(s, { tag: 'SetField', key: 'uren', value: '4160' });
s = reduce(s, { tag: 'SetField', key: 'jaren', value: '5' });
s = reduce(s, { tag: 'Next' });
expect(s.tag === 'Editing' && s.step).toBe(2);
s = reduce(s, { tag: 'SetField', key: 'punten', value: '200' });
s = reduce(s, { tag: 'Next' });
expect(s.tag === 'Editing' && s.step).toBe(3);
s = reduce(s, { tag: 'Submit' });
expect(s.tag).toBe('Submitting');
s = reduce(s, { tag: 'SubmitConfirmed' });
expect(s.tag).toBe('Submitted');
});
it('blocks submit until required documents are satisfied', () => {
const cat = {
categoryId: 'bewijs',
label: 'Bewijs',
description: '',
required: true,
acceptedTypes: [],
maxSizeMb: 10,
multiple: false,
allowPostDelivery: true,
};
let s = reduce(editing3('4160', '200'), {
tag: 'Upload',
msg: { type: 'CategoriesLoaded', categories: [cat] },
});
s = reduce(s, { tag: 'Submit' });
expect(s.tag).toBe('Editing');
expect((s as any).errors.documenten).toBeTruthy();
s = reduce(s, {
tag: 'Upload',
msg: { type: 'DeliveryChannelChanged', categoryId: 'bewijs', channel: 'post' },
});
s = reduce(s, { tag: 'Submit' });
expect(s.tag).toBe('Submitting');
expect((s as any).data.documents).toEqual([{ categoryId: 'bewijs', channel: 'post' }]);
});
it('SubmitFailed then Retry returns to Submitting with the same data', () => {
let s = reduce(reduce(editing3('4160', '200'), { tag: 'Submit' }), {
tag: 'SubmitFailed',
error: 'boom',
});
expect(s.tag).toBe('Failed');
s = reduce(s, { tag: 'Retry' });
expect(s.tag).toBe('Submitting');
expect((s as any).data).toEqual({ uren: 4160, jaren: 5, punten: 200, documents: [] });
});
it('Seed mounts an arbitrary state', () => {
expect(reduce(initial, { tag: 'Seed', state: editing2('1', '2') }).tag).toBe('Editing');
});
});
@@ -0,0 +1,186 @@
import { Result, assertNever } from '@shared/kernel/fp';
import { Uren, parseUren } from '@registratie/domain/value-objects/uren';
import {
UploadState,
UploadMsg,
initialUpload,
reduceUpload,
requiredCategoriesSatisfied,
deliveryRefs,
} from '@shared/upload/upload.machine';
/** What the user is typing (raw, possibly invalid). */
export interface Draft {
uren: string;
jaren: string;
punten: string;
}
export type StepErrors = Partial<Record<keyof Draft | 'documenten', string>>;
/** What we have AFTER parsing — branded/typed, guaranteed valid. */
export interface Valid {
uren: Uren;
jaren: number;
punten: number;
documents: Array<{ categoryId: string; channel: 'digital' | 'post'; documentId?: string }>;
}
/**
* The whole wizard as one tagged union. `step` and `errors` exist ONLY while
* Editing; Submitting/Submitted/Failed carry a `Valid` payload and nothing else.
* So "submitting while a field is invalid" or "showing the success screen with
* errors set" are unrepresentable — the bug class is gone by construction.
*/
export type WizardState =
| { tag: 'Editing'; step: 1 | 2 | 3; draft: Draft; errors: StepErrors; upload: UploadState }
| { tag: 'Submitting'; data: Valid }
| { tag: 'Submitted'; data: Valid }
| { tag: 'Failed'; data: Valid; error: string };
export const initial: WizardState = {
tag: 'Editing',
step: 1,
draft: { uren: '', jaren: '', punten: '' },
errors: {},
upload: initialUpload,
};
/** Has the user meaningfully started, so it's worth persisting as a Concept? */
export function hasProgress(s: Extract<WizardState, { tag: 'Editing' }>): boolean {
return (
s.step > 1 ||
!!s.draft.uren ||
!!s.draft.jaren ||
!!s.draft.punten ||
deliveryRefs(s.upload).some((r) => r.channel === 'digital' && !!r.documentId)
);
}
/** Parse every field; on success hand back a Valid, else the per-field errors. */
function validate(draft: Draft, upload: UploadState): Result<StepErrors, Valid> {
const uren = parseUren(draft.uren);
const jaren = parseUren(draft.jaren);
const punten = parseUren(draft.punten);
const errors: StepErrors = {};
if (!uren.ok) errors.uren = uren.error;
if (!jaren.ok) errors.jaren = jaren.error;
if (!punten.ok) errors.punten = punten.error;
if (!requiredCategoriesSatisfied(upload)) {
errors.documenten = $localize`:@@validation.documenten:Lever de verplichte documenten aan (upload of kies "per post nasturen").`;
}
if (uren.ok && jaren.ok && punten.ok && !errors.documenten) {
return {
ok: true,
value: {
uren: uren.value,
jaren: jaren.value,
punten: punten.value,
documents: deliveryRefs(upload),
},
};
}
return { ok: false, error: errors };
}
/** Advance one step, gating on that step's fields. Illegal elsewhere = no-op. */
export function next(s: WizardState): WizardState {
if (s.tag !== 'Editing') return s;
const errors: StepErrors = {};
if (s.step === 1) {
const uren = parseUren(s.draft.uren);
const jaren = parseUren(s.draft.jaren);
if (!uren.ok) errors.uren = uren.error;
if (!jaren.ok) errors.jaren = jaren.error;
return Object.keys(errors).length === 0 ? { ...s, step: 2, errors: {} } : { ...s, errors };
}
if (s.step === 2) {
const punten = parseUren(s.draft.punten);
if (!punten.ok) errors.punten = punten.error;
return punten.ok ? { ...s, step: 3, errors: {} } : { ...s, errors };
}
return s;
}
export function back(s: WizardState): WizardState {
if (s.tag !== 'Editing' || s.step === 1) return s;
return { ...s, step: (s.step - 1) as 1 | 2, errors: {} };
}
/** Jump back to an earlier step to correct data (controle → step N). Forward
jumps are not allowed (would skip validation). */
export function gaNaarStap(s: WizardState, step: 1 | 2 | 3): WizardState {
if (s.tag !== 'Editing' || step >= s.step) return s;
return { ...s, step, errors: {} };
}
/** Step 3 submit: parse everything + require documents; Submitting only with Valid. */
export function submit(s: WizardState): WizardState {
if (s.tag !== 'Editing' || s.step !== 3) return s;
const result = validate(s.draft, s.upload);
return result.ok ? { tag: 'Submitting', data: result.value } : { ...s, errors: result.error };
}
/** Route an upload sub-message through the pure upload reducer (Editing only). */
export function upload(s: WizardState, msg: UploadMsg): WizardState {
if (s.tag !== 'Editing') return s;
return { ...s, upload: reduceUpload(s.upload, msg) };
}
/** Resolve the async submit. Only meaningful while Submitting. */
export function resolve(s: WizardState, r: Result<string, void>): WizardState {
if (s.tag !== 'Submitting') return s;
return r.ok
? { tag: 'Submitted', data: s.data }
: { tag: 'Failed', data: s.data, error: r.error };
}
/** Update one draft field while editing; ignored in any other state. */
export function setField(s: WizardState, key: keyof Draft, value: string): WizardState {
if (s.tag !== 'Editing') return s;
return { ...s, draft: { ...s.draft, [key]: value } };
}
/**
* Every event that can happen to the wizard, as one message type. The component
* sends a WizardMsg; `reduce` decides the next state. This is the Elm
* Model+Msg+update pattern: ONE pure function describes all state changes.
*/
export type WizardMsg =
| { tag: 'SetField'; key: keyof Draft; value: string }
| { tag: 'Next' }
| { tag: 'Back' }
| { tag: 'GaNaarStap'; step: 1 | 2 | 3 }
| { tag: 'Submit' }
| { tag: 'Retry' }
| { tag: 'SubmitConfirmed' }
| { tag: 'SubmitFailed'; error: string }
| { tag: 'Upload'; msg: UploadMsg }
| { tag: 'Seed'; state: WizardState }; // mount a specific state (stories/showcase)
export function reduce(s: WizardState, m: WizardMsg): WizardState {
switch (m.tag) {
case 'SetField':
return setField(s, m.key, m.value);
case 'Next':
return next(s);
case 'Back':
return back(s);
case 'GaNaarStap':
return gaNaarStap(s, m.step);
case 'Submit':
return submit(s);
case 'Retry':
return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s;
case 'SubmitConfirmed':
return s.tag === 'Submitting' ? { tag: 'Submitted', data: s.data } : s;
case 'SubmitFailed':
return s.tag === 'Submitting' ? { tag: 'Failed', data: s.data, error: m.error } : s;
case 'Upload':
return upload(s, m.msg);
case 'Seed':
return m.state;
default:
return assertNever(m);
}
}
@@ -0,0 +1,15 @@
import { describe, it, expect } from 'vitest';
import { hasProgress, initial, IntakeState } from './intake.machine';
const answering = initial as Extract<IntakeState, { tag: 'Answering' }>;
describe('intake hasProgress', () => {
it('is false for a fresh questionnaire', () => {
expect(hasProgress(answering)).toBe(false);
});
it('is true once an answer is given or the user advances', () => {
expect(hasProgress({ ...answering, answers: { buitenlandGewerkt: 'ja' } })).toBe(true);
expect(hasProgress({ ...answering, cursor: 1 })).toBe(true);
});
});
@@ -0,0 +1,169 @@
import { describe, it, expect } from 'vitest';
import { ok, err } from '@shared/kernel/fp';
import {
Answers,
initial,
STEPS,
lageUren,
currentStep,
next,
back,
gaNaarStap,
submit,
resolve,
reduce,
IntakeState,
} from './intake.machine';
const answering = (answers: Answers, cursor = 0, scholingThreshold = 1000): IntakeState => ({
tag: 'Answering',
answers,
cursor,
errors: {},
scholingThreshold,
});
describe('STEPS (fixed) and inline questions', () => {
it('always has the same three steps', () => {
expect(STEPS).toEqual(['buitenland', 'werk', 'review']);
});
it('reveals the buitenland detail questions inline only when worked abroad', () => {
// No new step; instead these fields become required within the buitenland step.
expect(next(answering({ buitenlandGewerkt: 'ja' })).tag).toBe('Answering'); // land/uren missing -> blocked
expect((next(answering({ buitenlandGewerkt: 'ja' })) as any).errors.land).toBeTruthy();
expect(next(answering({ buitenlandGewerkt: 'nee' })).tag).toBe('Answering'); // valid, advances (cursor moves)
expect((next(answering({ buitenlandGewerkt: 'nee' })) as any).cursor).toBe(1);
});
it('reveals the scholing question only when NL-hours are below the threshold', () => {
expect(lageUren({ uren: '500' })).toBe(true);
expect(lageUren({ uren: '4160' })).toBe(false);
});
it('uses the (server-owned) threshold passed in, not a hardcoded constant', () => {
// Same hours, different threshold → different visibility. Proves de-hardcoding.
expect(lageUren({ uren: '1500' }, 1000)).toBe(false);
expect(lageUren({ uren: '1500' }, 2000)).toBe(true);
// And the threshold from state flows through submit:
const lowThreshold = submit(
answering({ buitenlandGewerkt: 'nee', uren: '1500', punten: '200' }, 0, 2000),
);
expect(lowThreshold.tag).toBe('Answering'); // scholing now required (1500 < 2000), unanswered → blocked
expect((lowThreshold as any).errors.scholingGevolgd).toBeTruthy();
});
});
describe('navigation', () => {
it('Next is a no-op (sets an error) when the current step is invalid', () => {
const s = next(initial); // buitenland unanswered
expect(s.tag).toBe('Answering');
expect((s as any).cursor).toBe(0);
expect((s as any).errors.buitenlandGewerkt).toBeTruthy();
});
it('Next advances once the step is valid', () => {
const s = next(answering({ buitenlandGewerkt: 'nee' }));
expect((s as any).cursor).toBe(1);
expect(currentStep(s as any)).toBe('werk');
});
it('editing an answer leaves the cursor fixed (steps never collapse)', () => {
const edited = reduce(answering({ buitenlandGewerkt: 'ja' }, 1), {
tag: 'SetAnswer',
key: 'buitenlandGewerkt',
value: 'nee',
});
expect((edited as any).cursor).toBe(1); // cursor untouched; only inline questions change
});
it('Back never goes below the first step', () => {
expect(back(initial)).toBe(initial);
});
it('gaNaarStap jumps back to an earlier step, clearing errors', () => {
const s = answering({ buitenlandGewerkt: 'nee' }, 2);
expect((gaNaarStap(s, 0) as any).cursor).toBe(0);
});
it('gaNaarStap ignores a same/forward jump and jumps outside Answering', () => {
const s = answering({ buitenlandGewerkt: 'nee' }, 1);
expect(gaNaarStap(s, 1)).toBe(s); // same step -> no-op
expect(gaNaarStap(s, 2)).toBe(s); // forward -> no-op
const submitting = submit(answering({ buitenlandGewerkt: 'nee', uren: '4160' }, 2));
expect(gaNaarStap(submitting, 0)).toBe(submitting); // not Answering -> no-op
});
});
describe('submit', () => {
// High hours: no scholing question, so no punten is asked or collected.
const complete: Answers = { buitenlandGewerkt: 'nee', uren: '4160' };
it('reaches Submitting ONLY with valid answers', () => {
// Bad punten only blocks when scholing was followed (otherwise punten is ignored).
expect(
submit(
answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja', punten: 'x' }),
).tag,
).toBe('Answering');
const good = submit(answering(complete));
expect(good.tag).toBe('Submitting');
expect((good as any).data.uren).toBe(4160);
expect((good as any).data.punten).toBeUndefined(); // not collected without scholing
});
it('punten is required only when aanvullende scholing was gevolgd', () => {
// scholing = ja but punten missing -> blocked on punten.
const missing = submit(
answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja' }),
);
expect(missing.tag).toBe('Answering');
expect((missing as any).errors.punten).toBeTruthy();
// scholing = nee -> punten not required, submits without it.
expect(
submit(answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'nee' })).tag,
).toBe('Submitting');
});
it('low hours requires the scholing answer before submit', () => {
const noScholing = submit(answering({ buitenlandGewerkt: 'nee', uren: '500' }));
expect(noScholing.tag).toBe('Answering'); // scholing question is required, unanswered
const withScholing = submit(
answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja', punten: '200' }),
);
expect(withScholing.tag).toBe('Submitting');
expect((withScholing as any).data.aanvullendeScholing).toBe(true);
expect((withScholing as any).data.punten).toBe(200);
});
it('resolve maps Submitting to Submitted on a successful submit', () => {
const submitting = submit(answering(complete));
expect(resolve(submitting, ok(undefined)).tag).toBe('Submitted');
});
it('resolve maps Submitting to Failed on a failed submit', () => {
const submitting = submit(answering(complete));
expect(resolve(submitting, err('boom')).tag).toBe('Failed');
});
});
describe('reduce (message-driven happy path)', () => {
it('drives abroad branch end to end', () => {
let s: IntakeState = initial;
// Step 1: buitenland — the country/hours questions reveal inline (same step).
s = reduce(s, { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'ja' });
s = reduce(s, { tag: 'SetAnswer', key: 'land', value: 'België' });
s = reduce(s, { tag: 'SetAnswer', key: 'buitenlandseUren', value: '800' });
s = reduce(s, { tag: 'Next' });
expect(currentStep(s as any)).toBe('werk');
// Step 2: werk — uren + punten (no inline scholing, hours are high).
s = reduce(s, { tag: 'SetAnswer', key: 'uren', value: '4160' });
s = reduce(s, { tag: 'SetAnswer', key: 'punten', value: '200' });
s = reduce(s, { tag: 'Next' });
expect(currentStep(s as any)).toBe('review');
s = reduce(s, { tag: 'Submit' });
expect(s.tag).toBe('Submitting');
s = reduce(s, { tag: 'SubmitConfirmed' });
expect(s.tag).toBe('Submitted');
});
});
@@ -0,0 +1,238 @@
import { Result, ok, err, assertNever } from '@shared/kernel/fp';
import { Uren, parseUren } from '@registratie/domain/value-objects/uren';
/**
* A FIXED 3-step wizard with progressive disclosure. The steps never change in
* number (always `STEPS`); instead, follow-up questions appear *inline within a
* step* depending on earlier answers — answer "buiten Nederland gewerkt? → ja"
* and the country/hours questions reveal in the same step; report few hours and
* the scholing-question reveals inside the 'werk' step. "Is this field required
* right now" is a pure function (`validateStep`/`lageUren`), so it's trivial to
* test and impossible to get out of sync with the data.
*/
export type JaNee = 'ja' | 'nee';
/** The three fixed steps. Each step groups one or more questions. */
export type StepId = 'buitenland' | 'werk' | 'review';
/** One record carried across every step (and persisted). All optional: the user
fills it in gradually, and branches may never ask some fields. */
export interface Answers {
buitenlandGewerkt?: JaNee; // Q1
land?: string; // Q1a — only when buitenlandGewerkt === 'ja'
buitenlandseUren?: string; // Q1b — only when buitenlandGewerkt === 'ja'
uren?: string; // Q2 — uren in NL
scholingGevolgd?: JaNee; // Q3 — only when total hours are below the threshold
punten?: string; // Q4
}
/** What we have after the review step parses — guaranteed valid/typed. */
export interface ValidIntake {
werktBuitenland: boolean;
land?: string;
buitenlandseUren?: Uren;
uren: Uren;
aanvullendeScholing?: boolean;
punten?: Uren; // only collected when aanvullende scholing is gevolgd (scholingGevolgd === 'ja')
}
/** Demo fallback only — the real threshold is a SERVER-OWNED policy value fetched
at runtime (see IntakePolicyDto / SetPolicy). ponytail: default is the offline
fallback; the server value wins. */
export const SCHOLING_THRESHOLD_DEFAULT = 1000;
/** The server-owned intake policy (domain-side, parsed from the wire at the boundary). */
export interface IntakePolicy {
readonly scholingThreshold: number;
}
/** True when NL-hours are low enough that the scholing question must be answered.
The threshold is passed in (server-owned), not hardcoded. */
export function lageUren(a: Answers, scholingThreshold = SCHOLING_THRESHOLD_DEFAULT): boolean {
const r = parseUren(a.uren ?? '');
return r.ok && r.value < scholingThreshold;
}
// #region showcase:steps
/** The fixed step list. Number of steps never changes; questions reveal inline. */
export const STEPS: StepId[] = ['buitenland', 'werk', 'review'];
// #endregion showcase:steps
/** Per-field error map: one message per question, since a step holds several. */
type Errors = Partial<Record<keyof Answers, string>>;
export type IntakeState =
| {
tag: 'Answering';
answers: Answers;
cursor: number;
errors: Errors;
scholingThreshold: number;
}
| { tag: 'Submitting'; data: ValidIntake }
| { tag: 'Submitted'; data: ValidIntake }
| { tag: 'Failed'; data: ValidIntake; error: string };
export const initial: IntakeState = {
tag: 'Answering',
answers: {},
cursor: 0,
errors: {},
scholingThreshold: SCHOLING_THRESHOLD_DEFAULT,
};
/** Which step the cursor currently points at (clamped to the fixed list). */
export function currentStep(s: Extract<IntakeState, { tag: 'Answering' }>): StepId {
return STEPS[Math.min(s.cursor, STEPS.length - 1)];
}
/** Has the user meaningfully started, so it's worth persisting as a Concept?
(No auto-prefill here — pristine means truly untouched.) */
export function hasProgress(s: Extract<IntakeState, { tag: 'Answering' }>): boolean {
return s.cursor > 0 || Object.keys(s.answers).length > 0;
}
/** Validate every question currently visible in ONE step. Errors keyed per field. */
function validateStep(step: StepId, a: Answers, scholingThreshold: number): Result<Errors, void> {
const errors: Errors = {};
switch (step) {
case 'buitenland':
if (!a.buitenlandGewerkt)
errors.buitenlandGewerkt = $localize`:@@validation.maakKeuze:Maak een keuze.`;
else if (a.buitenlandGewerkt === 'ja') {
if (!a.land || a.land.trim() === '')
errors.land = $localize`:@@validation.land:Vul een land in.`;
const u = parseUren(a.buitenlandseUren ?? '');
if (!u.ok) errors.buitenlandseUren = u.error;
}
break;
case 'werk': {
const u = parseUren(a.uren ?? '');
if (!u.ok) errors.uren = u.error;
if (lageUren(a, scholingThreshold) && !a.scholingGevolgd)
errors.scholingGevolgd = $localize`:@@validation.maakKeuze:Maak een keuze.`;
// Nascholingspunten are only asked (and required) when scholing was followed.
if (a.scholingGevolgd === 'ja') {
const p = parseUren(a.punten ?? '');
if (!p.ok) errors.punten = p.error;
}
break;
}
case 'review':
break; // review shows a summary; no own fields
default:
return assertNever(step);
}
return Object.keys(errors).length > 0 ? err(errors) : ok(undefined);
}
/** Parse the whole questionnaire into a ValidIntake (called on submit). */
function validateAll(a: Answers, scholingThreshold: number): Result<Errors, ValidIntake> {
const errors: Errors = {};
for (const step of STEPS) {
const r = validateStep(step, a, scholingThreshold);
if (!r.ok) Object.assign(errors, r.error);
}
if (Object.keys(errors).length > 0) return err(errors);
const uren = parseUren(a.uren ?? '');
// validateStep guaranteed uren parses, but keep the compiler happy.
if (!uren.ok) return err(errors);
const werktBuitenland = a.buitenlandGewerkt === 'ja';
const buitenland = parseUren(a.buitenlandseUren ?? '');
// Punten are only collected when aanvullende scholing was gevolgd.
const punten = a.scholingGevolgd === 'ja' ? parseUren(a.punten ?? '') : undefined;
return ok({
werktBuitenland,
land: werktBuitenland ? a.land : undefined,
buitenlandseUren: werktBuitenland && buitenland.ok ? buitenland.value : undefined,
uren: uren.value,
aanvullendeScholing: lageUren(a, scholingThreshold) ? a.scholingGevolgd === 'ja' : undefined,
punten: punten?.ok ? punten.value : undefined,
});
}
export function setAnswer(s: IntakeState, key: keyof Answers, value: string): IntakeState {
if (s.tag !== 'Answering') return s;
// Steps are fixed, so editing an answer never moves the cursor — it only
// reveals/hides inline questions within the current step.
return { ...s, answers: { ...s.answers, [key]: value } };
}
export function next(s: IntakeState): IntakeState {
if (s.tag !== 'Answering') return s;
const r = validateStep(currentStep(s), s.answers, s.scholingThreshold);
if (!r.ok) return { ...s, errors: r.error };
return { ...s, cursor: Math.min(s.cursor + 1, STEPS.length - 1), errors: {} };
}
/** Apply a server-owned policy value (e.g. the scholing threshold). */
export function setPolicy(s: IntakeState, scholingThreshold: number): IntakeState {
return s.tag === 'Answering' ? { ...s, scholingThreshold } : s;
}
export function back(s: IntakeState): IntakeState {
if (s.tag !== 'Answering' || s.cursor === 0) return s;
return { ...s, cursor: s.cursor - 1, errors: {} };
}
/** Jump back to an earlier step to correct answers (review → step N). Forward
jumps are not allowed (would skip validation). */
export function gaNaarStap(s: IntakeState, cursor: number): IntakeState {
if (s.tag !== 'Answering' || cursor < 0 || cursor >= s.cursor) return s;
return { ...s, cursor, errors: {} };
}
export function submit(s: IntakeState): IntakeState {
if (s.tag !== 'Answering') return s;
const r = validateAll(s.answers, s.scholingThreshold);
return r.ok ? { tag: 'Submitting', data: r.value } : { ...s, errors: r.error };
}
export function resolve(s: IntakeState, r: Result<string, void>): IntakeState {
if (s.tag !== 'Submitting') return s;
return r.ok
? { tag: 'Submitted', data: s.data }
: { tag: 'Failed', data: s.data, error: r.error };
}
export type IntakeMsg =
| { tag: 'SetAnswer'; key: keyof Answers; value: string }
| { tag: 'Next' }
| { tag: 'Back' }
| { tag: 'GaNaarStap'; cursor: number }
| { tag: 'Submit' }
| { tag: 'Retry' }
| { tag: 'SubmitConfirmed' }
| { tag: 'SubmitFailed'; error: string }
| { tag: 'SetPolicy'; scholingThreshold: number }
| { tag: 'Seed'; state: IntakeState };
export function reduce(s: IntakeState, m: IntakeMsg): IntakeState {
switch (m.tag) {
case 'SetAnswer':
return setAnswer(s, m.key, m.value);
case 'Next':
return next(s);
case 'Back':
return back(s);
case 'GaNaarStap':
return gaNaarStap(s, m.cursor);
case 'Submit':
return submit(s);
case 'Retry':
return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s;
case 'SubmitConfirmed':
return s.tag === 'Submitting' ? { tag: 'Submitted', data: s.data } : s;
case 'SubmitFailed':
return s.tag === 'Submitting' ? { tag: 'Failed', data: s.data, error: m.error } : s;
case 'SetPolicy':
return setPolicy(s, m.scholingThreshold);
case 'Seed':
return m.state;
default:
return assertNever(m);
}
}
@@ -0,0 +1,15 @@
import { describe, it, expect } from 'vitest';
import { parseIntakePolicy } from './intake-policy.adapter';
describe('intake-policy.adapter parse boundary', () => {
it('parses a well-formed policy', () => {
const r = parseIntakePolicy({ scholingThreshold: 800 });
expect(r).toEqual({ ok: true, value: { scholingThreshold: 800 } });
});
it('rejects a missing or non-numeric threshold', () => {
expect(parseIntakePolicy({}).ok).toBe(false);
expect(parseIntakePolicy({ scholingThreshold: '800' }).ok).toBe(false);
expect(parseIntakePolicy(null).ok).toBe(false);
});
});
@@ -0,0 +1,33 @@
import { Injectable, inject, resource } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp';
import { IntakePolicy } from '@herregistratie/domain/intake.machine';
import { ApiClient } from '@shared/infrastructure/api-client';
/**
* Infrastructure adapter for the intake policy (the scholing threshold config
* value). Same shape as every other adapter — a signal `resource` over the
* generated typed client — so HTTP lives in exactly one place per concern.
*/
@Injectable({ providedIn: 'root' })
export class IntakePolicyAdapter {
private client = inject(ApiClient);
policyResource() {
return resource({
loader: async () => {
const parsed = parseIntakePolicy(await this.client.policy());
if (!parsed.ok) throw new Error(parsed.error);
return parsed.value;
},
});
}
}
/** Trust-boundary parse: an unrecognized/missing threshold is an explicit Failure. */
export function parseIntakePolicy(json: unknown): Result<string, IntakePolicy> {
if (typeof json !== 'object' || json === null) return err('intake-policy: not an object');
const dto = json as { scholingThreshold?: unknown };
if (typeof dto.scholingThreshold !== 'number')
return err('intake-policy: missing scholingThreshold');
return ok({ scholingThreshold: dto.scholingThreshold });
}
@@ -0,0 +1,289 @@
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 { AlertComponent } from '@shared/ui/alert/alert.component';
import {
WizardShellComponent,
WizardError,
WizardStatus,
naarStapLabel,
} from '@shared/layout/wizard-shell/wizard-shell.component';
import { ConfirmationComponent } from '@shared/ui/confirmation/confirmation.component';
import { createStore } from '@shared/application/store';
import { whenTag } from '@shared/kernel/fp';
import { BigProfileStore } from '@registratie/application/big-profile.store';
import {
WizardState,
WizardMsg,
Draft,
initial,
reduce,
hasProgress,
} from '@herregistratie/domain/herregistratie.machine';
import { createDraftSync } from '@registratie/application/draft-sync';
import { DocumentUploadComponent } from '@shared/ui/upload/document-upload/document-upload.component';
import { createUploadController } from '@shared/upload/upload-controller';
import { UploadAdapter } from '@shared/upload/upload.adapter';
import { UploadState, initialUpload, deliveryRefs } from '@shared/upload/upload.machine';
/** Organism: multi-step herregistratie wizard. ALL state lives in one signal
driven by the pure `reduce` function (see herregistratie.machine.ts) via an
Elm-style store. The UI just sends messages and folds over the state's tag —
no booleans like `submitting`/`submitted` that could contradict each other.
Submitting also flips an optimistic flag on the shared BigProfileStore, so
the dashboard shows "in behandeling" immediately. */
@Component({
selector: 'app-herregistratie-wizard',
imports: [
FormsModule,
FormFieldComponent,
TextInputComponent,
AlertComponent,
ConfirmationComponent,
WizardShellComponent,
DocumentUploadComponent,
],
template: `
<app-wizard-shell
[steps]="stepLabels"
[current]="step() - 1"
[stepTitle]="stepTitle()"
i18n-processName="@@herregWizard.processName"
processName="Herregistratie aanvragen"
[status]="shellStatus()"
[primaryLabel]="primaryLabel()"
[canGoBack]="step() > 1"
[errors]="errorList()"
[errorMessage]="errorMessage()"
(primary)="onPrimary()"
(back)="dispatch({ tag: 'Back' })"
(cancel)="restart()"
(retry)="onRetry()"
(goToStep)="goToStep($event)"
>
@switch (step()) {
@case (1) {
<fieldset>
<app-form-field
i18n-label="@@herregWizard.urenLabel"
label="Gewerkte uren (afgelopen 5 jaar)"
fieldId="uren"
required
[error]="errUren()"
>
<app-text-input
inputId="uren"
[ngModel]="draft().uren"
(ngModelChange)="dispatch({ tag: 'SetField', key: 'uren', value: $event })"
name="uren"
[invalid]="!!errUren()"
i18n-placeholder="@@herregWizard.urenPlaceholder"
placeholder="bijv. 4160"
/>
</app-form-field>
<app-form-field
i18n-label="@@herregWizard.jarenLabel"
label="Aantal jaren werkzaam"
fieldId="jaren"
required
[error]="errJaren()"
>
<app-text-input
inputId="jaren"
[ngModel]="draft().jaren"
(ngModelChange)="dispatch({ tag: 'SetField', key: 'jaren', value: $event })"
name="jaren"
[invalid]="!!errJaren()"
i18n-placeholder="@@herregWizard.jarenPlaceholder"
placeholder="bijv. 5"
/>
</app-form-field>
</fieldset>
}
@case (2) {
<fieldset>
<app-form-field
i18n-label="@@herregWizard.puntenLabel"
label="Behaalde nascholingspunten"
fieldId="punten"
required
[error]="errPunten()"
>
<app-text-input
inputId="punten"
[ngModel]="draft().punten"
(ngModelChange)="dispatch({ tag: 'SetField', key: 'punten', value: $event })"
name="punten"
[invalid]="!!errPunten()"
i18n-placeholder="@@herregWizard.puntenPlaceholder"
placeholder="bijv. 200"
/>
</app-form-field>
</fieldset>
}
@case (3) {
<app-document-upload
[state]="upload()"
[previewUrlFor]="previewUrlFor"
(fileSelected)="uploadCtl.onFileSelected($event.categoryId, $event.files)"
(removeUpload)="uploadCtl.onRemove($event)"
(retryUpload)="uploadCtl.onRetry($event)"
(deleteUpload)="uploadCtl.onDelete($event)"
(channelChange)="uploadCtl.onChannelChange($event.categoryId, $event.channel)"
/>
@if (errDocumenten()) {
<app-alert type="warning">{{ errDocumenten() }}</app-alert>
}
}
}
<div wizardSuccess>
<app-confirmation
i18n-title="@@herregWizard.success.title"
title="Uw aanvraag tot herregistratie is ontvangen"
/>
</div>
</app-wizard-shell>
`,
})
export class HerregistratieWizardComponent {
private profile = inject(BigProfileStore);
private uploadAdapter = inject(UploadAdapter);
private store = createStore<WizardState, WizardMsg>(initial, reduce);
/** Preview/download link for a completed upload; dev-simulation `demo-*` ids have
no stored bytes, so they get no link. */
protected previewUrlFor = (documentId: string): string | undefined =>
documentId.startsWith('demo-') ? undefined : this.uploadAdapter.contentUrl(documentId);
/** Optional seed so Storybook / the showcase can mount any state directly. */
seed = input<WizardState>(initial);
readonly state = this.store.model; // public so the showcase can highlight the live state
protected dispatch = this.store.dispatch;
// Backend draft-sync (new persistence for this wizard): create a Concept on first
// progress, debounced-sync the snapshot, resume by `?aanvraag=<id>`.
private draftSync = createDraftSync({
type: 'herregistratie',
snapshot: () => {
const s = this.state();
if (s.tag !== 'Editing' || !hasProgress(s)) return null;
const documentIds = deliveryRefs(s.upload)
.filter((r) => r.channel === 'digital' && r.documentId)
.map((r) => r.documentId!);
return { draft: s, stepIndex: s.step - 1, stepCount: this.stepLabels.length, documentIds };
},
onResume: (draft) => this.dispatch({ tag: 'Seed', state: draft as WizardState }),
enabled: () => this.seed() === initial,
});
// Stepper labels + per-step heading titles (presentational only).
readonly stepLabels = [
$localize`:@@herregWizard.step.werkervaring:Werkervaring`,
$localize`:@@herregWizard.step.nascholing:Nascholing`,
$localize`:@@herregWizard.step.documenten:Documenten`,
];
private stepTitles = [
$localize`:@@herregWizard.title.werkervaring:Werkervaring (afgelopen 5 jaar)`,
$localize`:@@herregWizard.title.nascholing:Nascholing`,
$localize`:@@herregWizard.title.documenten:Documenten aanleveren`,
];
private editing = computed(() => whenTag(this.state(), 'Editing'));
protected step = computed(() => this.editing()?.step ?? 1);
protected draft = computed<Draft>(
() => this.editing()?.draft ?? { uren: '', jaren: '', punten: '' },
);
protected upload = computed<UploadState>(() => this.editing()?.upload ?? initialUpload);
protected errUren = computed(() => this.editing()?.errors.uren ?? '');
protected errJaren = computed(() => this.editing()?.errors.jaren ?? '');
protected errPunten = computed(() => this.editing()?.errors.punten ?? '');
protected errDocumenten = computed(() => this.editing()?.errors.documenten ?? '');
protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? '');
protected uploadCtl = createUploadController({
wizardId: 'herregistratie',
getUpload: () => this.upload(),
dispatch: (msg) => this.dispatch({ tag: 'Upload', msg }),
});
// --- Presentational wiring for the shared wizard shell ---------------------
protected stepTitle = computed(() => this.stepTitles[this.step() - 1]);
protected primaryLabel = computed(() => {
const step = this.step();
return step < 3
? naarStapLabel(step + 1, this.stepLabels[step])
: $localize`:@@herregWizard.indienen:Herregistratie aanvragen`;
});
/** Stepper emits a 0-based index for an earlier (visited) step. */
protected goToStep(index: number) {
this.dispatch({ tag: 'GaNaarStap', step: (index + 1) as 1 | 2 | 3 });
}
protected errorMessage = computed(
() => $localize`:@@wizard.indienenMislukt: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() {
// An explicit seed (stories/tests) wins; otherwise resume the backend draft
// (`?aanvraag=<id>`) or start fresh. Persistence is the draftSync controller's job.
const seeded = this.seed();
queueMicrotask(() =>
seeded !== initial ? this.dispatch({ tag: 'Seed', state: seeded }) : this.draftSync.resume(),
);
}
onPrimary() {
const s = this.state();
if (s.tag !== 'Editing') return;
this.dispatch(s.step < 3 ? { tag: 'Next' } : { tag: 'Submit' });
this.runIfSubmitting();
}
onRetry() {
this.dispatch({ tag: 'Retry' });
this.runIfSubmitting();
}
/** Reset the wizard to a fresh, empty start. */
restart() {
this.draftSync.reset();
this.dispatch({ tag: 'Seed', state: initial });
}
/** The effect: when we entered Submitting, submit through the aanvraag lifecycle,
flip the optimistic cross-page flag, then dispatch the result (commit/rollback). */
private async runIfSubmitting() {
const s = this.state();
if (s.tag !== 'Submitting') return;
this.profile.beginHerregistratie();
const r = await this.draftSync.submit({ uren: s.data.uren, documents: s.data.documents });
if (r.ok) {
this.dispatch({ tag: 'SubmitConfirmed' });
this.profile.confirmHerregistratie();
} else {
this.dispatch({ tag: 'SubmitFailed', error: r.error });
this.profile.rollbackHerregistratie();
}
}
}
@@ -0,0 +1,74 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { applicationConfig } from '@storybook/angular';
import { provideHttpClient } from '@angular/common/http';
import { provideApiClient } from '@shared/infrastructure/api-client.provider';
import { HerregistratieWizardComponent } from './herregistratie-wizard.component';
import { WizardState } from '@herregistratie/domain/herregistratie.machine';
import { initialUpload } from '@shared/upload/upload.machine';
import { Uren } from '@registratie/domain/value-objects/uren';
const validData = { uren: 4160 as Uren, jaren: 5, punten: 200, documents: [] };
const meta: Meta<HerregistratieWizardComponent> = {
title: 'Domein/Herregistratie/Wizard',
component: HerregistratieWizardComponent,
// The wizard injects BigProfileStore (for the optimistic cross-page flag),
// which creates httpResources — so the story needs an HttpClient.
decorators: [applicationConfig({ providers: [provideHttpClient(), provideApiClient()] })],
};
export default meta;
type Story = StoryObj<HerregistratieWizardComponent>;
// Each story seeds one state of the machine — one render per union variant.
export const Step1: Story = {
args: {
seed: {
tag: 'Editing',
step: 1,
draft: { uren: '', jaren: '', punten: '' },
errors: {},
upload: initialUpload,
},
},
};
export const Step1Error: Story = {
args: {
seed: {
tag: 'Editing',
step: 1,
draft: { uren: 'abc', jaren: '', punten: '' },
errors: {
uren: 'Vul een geheel aantal in (0 of meer).',
jaren: 'Vul een geheel aantal in (0 of meer).',
},
upload: initialUpload,
} satisfies WizardState,
},
};
export const Step2: Story = {
args: {
seed: {
tag: 'Editing',
step: 2,
draft: { uren: '4160', jaren: '5', punten: '' },
errors: {},
upload: initialUpload,
},
},
};
export const Step3: Story = {
args: {
seed: {
tag: 'Editing',
step: 3,
draft: { uren: '4160', jaren: '5', punten: '200' },
errors: {},
upload: initialUpload,
},
},
};
export const Submitting: Story = { args: { seed: { tag: 'Submitting', data: validData } } };
export const Submitted: Story = { args: { seed: { tag: 'Submitted', data: validData } } };
export const Failed: Story = {
args: { seed: { tag: 'Failed', data: validData, error: 'Netwerkfout' } },
};
@@ -0,0 +1,47 @@
import { Component, computed, inject } from '@angular/core';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { ASYNC } from '@shared/ui/async/async.component';
import { map } from '@shared/application/remote-data';
import { BigProfileStore } from '@registratie/application/big-profile.store';
import { HerregistratieWizardComponent } from '@herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component';
/** A whole new page built from existing building blocks. Eligibility is a
SERVER-computed decision read from the aggregated view — the frontend renders
it, it does not recompute the rule. */
@Component({
selector: 'app-herregistratie-page',
imports: [PageShellComponent, AlertComponent, ...ASYNC, HerregistratieWizardComponent],
template: `
<app-page-shell
i18n-heading="@@herregistratie.heading"
heading="Herregistratie aanvragen"
backLink="/dashboard"
>
<app-async [data]="eligibility()">
<ng-template appAsyncLoaded let-eligible>
@if (eligible) {
<app-alert type="info" i18n="@@herregistratie.eligible">
Uw huidige registratie verloopt binnenkort. Vraag tijdig herregistratie aan.
</app-alert>
<div class="app-section">
<app-herregistratie-wizard />
</div>
} @else {
<app-alert type="warning" i18n="@@herregistratie.notEligible">
Voor uw huidige registratiestatus is herregistratie niet mogelijk.
</app-alert>
}
</ng-template>
</app-async>
</app-page-shell>
`,
})
export class HerregistratiePage {
private store = inject(BigProfileStore);
// The eligibility decision comes from the server (decisions block), not a
// client-side rule. The UI just reads the boolean.
protected eligibility = computed(() =>
map(this.store.decisions(), (d) => d.eligibleForHerregistratie),
);
}
@@ -0,0 +1,36 @@
import { provideHttpClient } from '@angular/common/http';
import { TestBed } from '@angular/core/testing';
import { describe, expect, it } from 'vitest';
import { provideApiClient } from '@shared/infrastructure/api-client.provider';
import { IntakeWizardComponent } from './intake-wizard.component';
import { IntakeState } from '@herregistratie/domain/intake.machine';
// Regression: wizard steps must render their logical field groups as separate CIBG grey
// <fieldset> blocks (`.form-horizontal fieldset` ⇒ #f1f5f9, 1.25em gap). If the fieldset
// wrapping is dropped, the inputs revert to bare white. The buitenland step with
// buitenlandGewerkt='ja' has two groups (the question + the land/uren follow-up), so it
// must render ≥2 fieldsets, each holding a form-group.
const buitenlandJa: IntakeState = {
tag: 'Answering',
answers: { buitenlandGewerkt: 'ja' },
cursor: 0,
errors: {},
scholingThreshold: 1000,
};
describe('IntakeWizardComponent', () => {
it('renders each field group as its own grey <fieldset>', () => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideApiClient()],
});
const fixture = TestBed.createComponent(IntakeWizardComponent);
fixture.componentInstance.dispatch({ tag: 'Seed', state: buitenlandJa });
fixture.detectChanges();
const fieldsets: HTMLElement[] = Array.from(
fixture.nativeElement.querySelectorAll('form.form-horizontal fieldset'),
);
expect(fieldsets.length).toBeGreaterThanOrEqual(2);
fieldsets.forEach((fs) => expect(fs.querySelector('.form-group')).toBeTruthy());
});
});
@@ -0,0 +1,399 @@
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, JA_NEE } 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 { DataRowComponent } from '@shared/ui/data-row/data-row.component';
import { ReviewSectionComponent } from '@shared/ui/review-section/review-section.component';
import { ConfirmationComponent } from '@shared/ui/confirmation/confirmation.component';
import {
WizardShellComponent,
WizardError,
WizardStatus,
naarStapLabel,
} from '@shared/layout/wizard-shell/wizard-shell.component';
import { createStore } from '@shared/application/store';
import { whenTag } from '@shared/kernel/fp';
import { BigProfileStore } from '@registratie/application/big-profile.store';
import {
IntakeState,
IntakeMsg,
Answers,
StepId,
initial,
reduce,
STEPS,
lageUren,
hasProgress,
SCHOLING_THRESHOLD_DEFAULT,
} from '@herregistratie/domain/intake.machine';
import { createDraftSync } from '@registratie/application/draft-sync';
import { IntakePolicyStore } from '@herregistratie/application/intake-policy.store';
/** Organism: a BRANCHING intake questionnaire. All state lives in one signal
driven by the pure `reduce` (intake.machine.ts). Which step renders is derived
from the answers via `visibleSteps`, never stored — so editing an earlier
answer immediately changes the remaining steps. Answers are persisted to
sessionStorage so a page reload keeps the user's progress (cleared on tab close). */
@Component({
selector: 'app-intake-wizard',
imports: [
FormsModule,
FormFieldComponent,
TextInputComponent,
RadioGroupComponent,
ButtonComponent,
AlertComponent,
DataRowComponent,
ReviewSectionComponent,
ConfirmationComponent,
WizardShellComponent,
],
template: `
<app-wizard-shell
[steps]="stepLabels"
[current]="cursor()"
[stepTitle]="stepTitle()"
i18n-processName="@@intake.processName"
processName="Herregistratie-intake"
[status]="shellStatus()"
[primaryLabel]="primaryLabel()"
[canGoBack]="cursor() > 0"
[errors]="errorList()"
[errorMessage]="errorMessage()"
(primary)="onPrimary()"
(back)="dispatch({ tag: 'Back' })"
(cancel)="restart()"
(retry)="onRetry()"
(goToStep)="dispatch({ tag: 'GaNaarStap', cursor: $event })"
>
@switch (step()) {
@case ('buitenland') {
<fieldset>
<app-form-field
i18n-label="@@intake.q.buitenland"
label="Heeft u de afgelopen 5 jaar buiten Nederland gewerkt?"
fieldId="buitenlandGewerkt"
required
[error]="err('buitenlandGewerkt')"
>
<app-radio-group
name="buitenlandGewerkt"
[options]="jaNee"
[ngModel]="answers().buitenlandGewerkt ?? ''"
(ngModelChange)="set('buitenlandGewerkt', $event)"
/>
</app-form-field>
</fieldset>
@if (answers().buitenlandGewerkt === 'ja') {
<fieldset>
<app-form-field
i18n-label="@@intake.q.land"
label="In welk land?"
fieldId="land"
required
[error]="err('land')"
>
<app-text-input
inputId="land"
[ngModel]="answers().land ?? ''"
(ngModelChange)="set('land', $event)"
name="land"
i18n-placeholder="@@intake.q.landPlaceholder"
placeholder="bijv. België"
/>
</app-form-field>
<app-form-field
i18n-label="@@intake.q.buitenlandseUren"
label="Hoeveel uur heeft u daar gewerkt?"
fieldId="buitenlandseUren"
required
[error]="err('buitenlandseUren')"
>
<app-text-input
inputId="buitenlandseUren"
[ngModel]="answers().buitenlandseUren ?? ''"
(ngModelChange)="set('buitenlandseUren', $event)"
name="buitenlandseUren"
i18n-placeholder="@@intake.q.buitenlandseUrenPlaceholder"
placeholder="bijv. 800"
/>
</app-form-field>
</fieldset>
}
}
@case ('werk') {
<fieldset>
<app-form-field
i18n-label="@@intake.q.urenNl"
label="Gewerkte uren in Nederland (afgelopen 5 jaar)"
fieldId="uren"
required
[error]="err('uren')"
>
<app-text-input
inputId="uren"
[ngModel]="answers().uren ?? ''"
(ngModelChange)="set('uren', $event)"
name="uren"
i18n-placeholder="@@intake.q.urenNlPlaceholder"
placeholder="bijv. 4160"
/>
</app-form-field>
</fieldset>
@if (scholingZichtbaar()) {
<fieldset>
<app-form-field
i18n-label="@@intake.q.scholing"
label="U werkte relatief weinig uren. Heeft u aanvullende scholing gevolgd?"
fieldId="scholingGevolgd"
required
[error]="err('scholingGevolgd')"
>
<app-radio-group
name="scholingGevolgd"
[options]="jaNee"
[ngModel]="answers().scholingGevolgd ?? ''"
(ngModelChange)="set('scholingGevolgd', $event)"
/>
</app-form-field>
</fieldset>
}
@if (answers().scholingGevolgd === 'ja') {
<fieldset>
<app-form-field
i18n-label="@@intake.q.punten"
label="Behaalde nascholingspunten"
fieldId="punten"
required
[error]="err('punten')"
>
<app-text-input
inputId="punten"
[ngModel]="answers().punten ?? ''"
(ngModelChange)="set('punten', $event)"
name="punten"
i18n-placeholder="@@intake.q.puntenPlaceholder"
placeholder="bijv. 200"
/>
</app-form-field>
</fieldset>
}
}
@case ('review') {
<app-alert type="info" i18n="@@intake.review.controleer"
>Controleer uw antwoorden en dien de aanvraag in.</app-alert
>
<app-review-section
i18n-heading="@@intake.sectie.buitenland"
heading="Buitenland"
i18n-editAriaLabel="@@intake.buitenlandWijzigenAria"
editAriaLabel="Wijzigen buitenland"
(edit)="dispatch({ tag: 'GaNaarStap', cursor: 0 })"
>
<div
app-data-row
i18n-key="@@intake.review.buitenNl"
key="Buiten NL gewerkt"
[value]="answers().buitenlandGewerkt ?? '—'"
></div>
@if (answers().buitenlandGewerkt === 'ja') {
<div
app-data-row
i18n-key="@@intake.review.land"
key="Land"
[value]="answers().land ?? ''"
></div>
<div
app-data-row
i18n-key="@@intake.review.buitenlandseUren"
key="Buitenlandse uren"
[value]="answers().buitenlandseUren ?? ''"
></div>
}
</app-review-section>
<app-review-section
class="app-section"
i18n-heading="@@intake.sectie.werk"
heading="Werk in Nederland"
i18n-editAriaLabel="@@intake.werkWijzigenAria"
editAriaLabel="Wijzigen werk in Nederland"
(edit)="dispatch({ tag: 'GaNaarStap', cursor: 1 })"
>
<div
app-data-row
i18n-key="@@intake.review.urenNl"
key="Uren NL"
[value]="answers().uren ?? ''"
></div>
@if (scholingZichtbaar()) {
<div
app-data-row
i18n-key="@@intake.review.scholing"
key="Aanvullende scholing"
[value]="answers().scholingGevolgd ?? ''"
></div>
}
@if (answers().scholingGevolgd === 'ja') {
<div
app-data-row
i18n-key="@@intake.review.punten"
key="Nascholingspunten"
[value]="answers().punten ?? ''"
></div>
}
</app-review-section>
}
}
<div wizardSuccess>
<app-confirmation
i18n-title="@@intake.success.title"
title="Uw aanvraag tot herregistratie is ontvangen"
>
<div class="app-section">
<app-button variant="secondary" (click)="restart()" i18n="@@intake.opnieuw"
>Opnieuw beginnen</app-button
>
</div>
</app-confirmation>
</div>
</app-wizard-shell>
`,
})
export class IntakeWizardComponent {
private profile = inject(BigProfileStore);
// Server-owned policy (scholing threshold): fetched from the backend via the
// application facade, not hardcoded. The backend stays the authority on submit.
private policyStore = inject(IntakePolicyStore);
private store = createStore<IntakeState, IntakeMsg>(initial, reduce);
/** Optional seed so Storybook / the showcase can mount any state directly. */
seed = input<IntakeState>(initial);
readonly jaNee = JA_NEE;
readonly state = this.store.model;
readonly dispatch = this.store.dispatch;
// Backend draft-sync (replaces sessionStorage); the intake has no uploads.
private draftSync = createDraftSync({
type: 'intake',
snapshot: () => {
const s = this.state();
if (s.tag !== 'Answering' || !hasProgress(s)) return null;
return { draft: s, stepIndex: s.cursor, stepCount: STEPS.length, documentIds: [] };
},
onResume: (draft) => this.dispatch({ tag: 'Seed', state: draft as IntakeState }),
enabled: () => this.seed() === initial,
});
private answering = computed(() => whenTag(this.state(), 'Answering'));
/** Public so the showcase can render the (fixed) step list next to the wizard. */
readonly steps = STEPS;
protected cursor = computed(() => this.answering()?.cursor ?? 0);
protected answers = computed<Answers>(() => this.answering()?.answers ?? {});
protected step = computed<StepId>(() => STEPS[Math.min(this.cursor(), STEPS.length - 1)]);
/** Server-owned threshold from the policy endpoint (mirrored into machine state). */
protected scholingThreshold = computed(
() => this.answering()?.scholingThreshold ?? SCHOLING_THRESHOLD_DEFAULT,
);
/** Whether the inline scholing question is shown (and required) in the 'werk' step. */
protected scholingZichtbaar = computed(() => lageUren(this.answers(), this.scholingThreshold()));
protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? '');
// --- Presentational wiring for the shared wizard shell ---------------------
readonly stepLabels = [
$localize`:@@intake.step.buitenland:Buitenland`,
$localize`:@@intake.step.werk:Werk`,
$localize`:@@intake.step.controle:Controle`,
];
private stepTitles: Record<StepId, string> = {
buitenland: $localize`:@@intake.title.buitenland:Werken in het buitenland`,
werk: $localize`:@@intake.title.werk:Werkervaring in Nederland`,
review: $localize`:@@intake.title.review:Controleren en indienen`,
};
protected stepTitle = computed(() => this.stepTitles[this.step()]);
protected primaryLabel = computed(() => {
if (this.step() === 'review') return $localize`:@@intake.indienen:Aanvraag indienen`;
const next = this.cursor() + 1;
return naarStapLabel(next + 1, this.stepLabels[next]);
});
protected errorMessage = computed(
() => $localize`:@@wizard.indienenMislukt: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 });
constructor() {
// An explicit seed (stories/tests) wins; otherwise resume the backend draft
// (`?aanvraag=<id>`) or start fresh. Persistence is the draftSync controller's job.
const seeded = this.seed();
queueMicrotask(() =>
seeded !== initial ? this.dispatch({ tag: 'Seed', state: seeded }) : this.draftSync.resume(),
);
// Apply the server-owned threshold into machine state as it arrives. Track
// 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.policyStore.scholingThreshold();
untracked(() => this.dispatch({ tag: 'SetPolicy', scholingThreshold }));
});
}
onPrimary() {
const s = this.state();
if (s.tag !== 'Answering') return;
this.dispatch(this.step() === 'review' ? { tag: 'Submit' } : { tag: 'Next' });
this.runIfSubmitting();
}
onRetry() {
this.dispatch({ tag: 'Retry' });
this.runIfSubmitting();
}
restart() {
this.draftSync.reset();
this.dispatch({ tag: 'Seed', state: initial });
}
/** The effect: when we enter Submitting, submit through the aanvraag lifecycle,
flip the optimistic cross-page flag, then dispatch the outcome (commit/rollback). */
private async runIfSubmitting() {
const s = this.state();
if (s.tag !== 'Submitting') return;
this.profile.beginHerregistratie();
const r = await this.draftSync.submit({ uren: s.data.uren });
if (r.ok) {
this.dispatch({ tag: 'SubmitConfirmed' });
this.profile.confirmHerregistratie();
} else {
this.dispatch({ tag: 'SubmitFailed', error: r.error });
this.profile.rollbackHerregistratie();
}
}
}
@@ -0,0 +1,42 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { applicationConfig } from '@storybook/angular';
import { provideHttpClient } from '@angular/common/http';
import { provideApiClient } from '@shared/infrastructure/api-client.provider';
import { IntakeWizardComponent } from './intake-wizard.component';
import { IntakeState, Answers } from '@herregistratie/domain/intake.machine';
import { Uren } from '@registratie/domain/value-objects/uren';
const validData = { werktBuitenland: false, uren: 4160 as Uren, punten: 200 as Uren };
const meta: Meta<IntakeWizardComponent> = {
title: 'Domein/Herregistratie/IntakeWizard',
component: IntakeWizardComponent,
// Injects BigProfileStore (optimistic flag) which creates httpResources.
decorators: [applicationConfig({ providers: [provideHttpClient(), provideApiClient()] })],
};
export default meta;
type Story = StoryObj<IntakeWizardComponent>;
const answering = (answers: Answers, cursor = 0): IntakeState => ({
tag: 'Answering',
answers,
cursor,
errors: {},
scholingThreshold: 1000,
});
export const Start: Story = { args: { seed: answering({}) } };
// Inline reveal: country/hours appear within the buitenland step (cursor 0).
export const AbroadBranch: Story = { args: { seed: answering({ buitenlandGewerkt: 'ja' }, 0) } };
// Inline reveal: the scholing question appears within the werk step (cursor 1).
export const LowHoursScholing: Story = {
args: { seed: answering({ buitenlandGewerkt: 'nee', uren: '500' }, 1) },
};
export const Review: Story = {
args: { seed: answering({ buitenlandGewerkt: 'nee', uren: '4160', punten: '200' }, 2) },
};
export const Submitting: Story = { args: { seed: { tag: 'Submitting', data: validData } } };
export const Submitted: Story = { args: { seed: { tag: 'Submitted', data: validData } } };
export const Failed: Story = {
args: { seed: { tag: 'Failed', data: validData, error: 'Netwerkfout' } },
};
@@ -0,0 +1,27 @@
import { Component } from '@angular/core';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { IntakeWizardComponent } from '@herregistratie/ui/intake-wizard/intake-wizard.component';
/** Page: the branching intake questionnaire. Built entirely from existing
building blocks (page shell + alert + the intake-wizard organism). */
@Component({
selector: 'app-intake-page',
imports: [PageShellComponent, AlertComponent, IntakeWizardComponent],
template: `
<app-page-shell
i18n-heading="@@intake.heading"
heading="Herregistratie — intake"
backLink="/dashboard"
>
<app-alert type="info" i18n="@@intake.intro">
Een paar vragen bepalen welke gegevens we nodig hebben. Afhankelijk van uw antwoorden
verschijnen er extra vragen. Uw antwoorden blijven bewaard als u de pagina herlaadt.
</app-alert>
<div class="app-section">
<app-intake-wizard />
</div>
</app-page-shell>
`,
})
export class IntakePage {}