Add registratie wizard, BFF dashboard-view, contracts/value-objects, and architecture docs
Checkpoint of in-progress work: the registration wizard (address prefill, DUO diploma lookup, policy questions), decision-DTO contracts, parse-don't- validate value objects, infrastructure adapters, plus CLAUDE.md and the architecture/ADR docs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ export const routes: Routes = [
|
||||
{ path: 'login', loadComponent: () => import('@auth/ui/login.page').then(m => m.LoginPage) },
|
||||
{ path: 'dashboard', canActivate: [authGuard], loadComponent: () => import('@registratie/ui/dashboard.page').then(m => m.DashboardPage) },
|
||||
{ path: 'registratie', canActivate: [authGuard], loadComponent: () => import('@registratie/ui/registration-detail.page').then(m => m.RegistrationDetailPage) },
|
||||
{ path: 'registreren', canActivate: [authGuard], loadComponent: () => import('@registratie/ui/registratie.page').then(m => m.RegistratiePage) },
|
||||
{ path: 'herregistratie', canActivate: [authGuard], loadComponent: () => import('@herregistratie/ui/herregistratie.page').then(m => m.HerregistratiePage) },
|
||||
{ path: 'intake', canActivate: [authGuard], loadComponent: () => import('@herregistratie/ui/intake.page').then(m => m.IntakePage) },
|
||||
{ path: 'concepts', loadComponent: () => import('./showcase/concepts.page').then(m => m.ConceptsPage) },
|
||||
|
||||
@@ -1,22 +1,45 @@
|
||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
import { Injectable, computed, effect, inject, signal } from '@angular/core';
|
||||
import { Result } from '@shared/kernel/fp';
|
||||
import { Session } from '../domain/session';
|
||||
import { DigidAdapter } from '../infrastructure/digid.adapter';
|
||||
|
||||
const STORAGE_KEY = 'session-v1';
|
||||
|
||||
/** Restore a persisted session (best-effort; corrupt entry → logged out). */
|
||||
function restore(): Session | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(STORAGE_KEY);
|
||||
return raw ? (JSON.parse(raw) as Session) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds the current session for the whole app. Because it is providedIn:'root'
|
||||
* there is exactly one instance — every component that injects it sees the same
|
||||
* session signal, so logging in is instantly visible everywhere (the guard, the
|
||||
* header, etc.). ponytail: in-memory only; a refresh logs you out.
|
||||
* header, etc.). The session is mirrored to sessionStorage so a refresh or a
|
||||
* deep-link to a protected route keeps you logged in; it clears when the tab
|
||||
* closes. ponytail: sessionStorage, not localStorage — no cross-tab sync, which
|
||||
* matches a single-session portal.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SessionStore {
|
||||
private digid = inject(DigidAdapter);
|
||||
private _session = signal<Session | null>(null);
|
||||
private _session = signal<Session | null>(restore());
|
||||
|
||||
readonly session = this._session.asReadonly();
|
||||
readonly isAuthenticated = computed(() => this._session() !== null);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const s = this._session();
|
||||
if (s) sessionStorage.setItem(STORAGE_KEY, JSON.stringify(s));
|
||||
else sessionStorage.removeItem(STORAGE_KEY);
|
||||
});
|
||||
}
|
||||
|
||||
/** Effectful command: authenticate, then store the session on success. */
|
||||
async login(bsn: string): Promise<Result<string, Session>> {
|
||||
const r = await this.digid.authenticate(bsn);
|
||||
|
||||
12
src/app/herregistratie/contracts/intake-policy.dto.ts
Normal file
12
src/app/herregistratie/contracts/intake-policy.dto.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -3,7 +3,8 @@ import { ok, err } from '@shared/kernel/fp';
|
||||
import {
|
||||
Answers,
|
||||
initial,
|
||||
visibleSteps,
|
||||
STEPS,
|
||||
lageUren,
|
||||
currentStep,
|
||||
next,
|
||||
back,
|
||||
@@ -13,21 +14,34 @@ import {
|
||||
IntakeState,
|
||||
} from './intake.machine';
|
||||
|
||||
const answering = (answers: Answers, cursor = 0): IntakeState => ({ tag: 'Answering', answers, cursor, errors: {} });
|
||||
const answering = (answers: Answers, cursor = 0, scholingThreshold = 1000): IntakeState => ({ tag: 'Answering', answers, cursor, errors: {}, scholingThreshold });
|
||||
|
||||
describe('visibleSteps (the branching)', () => {
|
||||
it('asks only buitenland/uren/punten/review by default', () => {
|
||||
expect(visibleSteps({})).toEqual(['buitenland', 'uren', 'punten', 'review']);
|
||||
describe('STEPS (fixed) and inline questions', () => {
|
||||
it('always has the same three steps', () => {
|
||||
expect(STEPS).toEqual(['buitenland', 'werk', 'review']);
|
||||
});
|
||||
|
||||
it('adds the buitenlandDetails step when worked abroad', () => {
|
||||
expect(visibleSteps({ buitenlandGewerkt: 'ja' })).toContain('buitenlandDetails');
|
||||
expect(visibleSteps({ buitenlandGewerkt: 'nee' })).not.toContain('buitenlandDetails');
|
||||
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('adds the scholing step only when NL-hours are below the threshold', () => {
|
||||
expect(visibleSteps({ uren: '500' })).toContain('scholing');
|
||||
expect(visibleSteps({ uren: '4160' })).not.toContain('scholing');
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,20 +50,18 @@ describe('navigation', () => {
|
||||
const s = next(initial); // buitenland unanswered
|
||||
expect(s.tag).toBe('Answering');
|
||||
expect((s as any).cursor).toBe(0);
|
||||
expect((s as any).errors.buitenland).toBeTruthy();
|
||||
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('uren');
|
||||
expect(currentStep(s as any)).toBe('werk');
|
||||
});
|
||||
|
||||
it('keeps the cursor in range when an answer collapses a branch', () => {
|
||||
// Worked abroad, cursor sitting on the extra detail step (index 1)...
|
||||
const collapsed = reduce(answering({ buitenlandGewerkt: 'ja' }, 1), { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' });
|
||||
// ...the detail step is gone; cursor must not point past the new shorter list.
|
||||
expect((collapsed as any).cursor).toBeLessThan(visibleSteps((collapsed as any).answers).length);
|
||||
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', () => {
|
||||
@@ -58,21 +70,34 @@ describe('navigation', () => {
|
||||
});
|
||||
|
||||
describe('submit', () => {
|
||||
const complete: Answers = { buitenlandGewerkt: 'nee', uren: '4160', punten: '200' };
|
||||
// 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', () => {
|
||||
expect(submit(answering({ buitenlandGewerkt: 'nee', uren: '4160', punten: 'x' })).tag).toBe('Answering');
|
||||
// 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', punten: '200' }));
|
||||
expect(noScholing.tag).toBe('Answering'); // scholing step is required, unanswered
|
||||
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 / Failed', () => {
|
||||
@@ -85,16 +110,14 @@ describe('submit', () => {
|
||||
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: 'Next' });
|
||||
expect(currentStep(s as any)).toBe('buitenlandDetails');
|
||||
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('uren');
|
||||
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: 'Next' });
|
||||
expect(currentStep(s as any)).toBe('punten');
|
||||
s = reduce(s, { tag: 'SetAnswer', key: 'punten', value: '200' });
|
||||
s = reduce(s, { tag: 'Next' });
|
||||
expect(currentStep(s as any)).toBe('review');
|
||||
|
||||
@@ -2,18 +2,19 @@ import { Result, ok, err, assertNever } from '@shared/kernel/fp';
|
||||
import { Uren, parseUren } from '@registratie/domain/value-objects/uren';
|
||||
|
||||
/**
|
||||
* A BRANCHING wizard. Unlike herregistratie.machine (fixed 2 steps), the set of
|
||||
* steps here is NOT stored — it's *derived* from the answers by `visibleSteps`.
|
||||
* Answer "buiten Nederland gewerkt? → ja" and two extra steps appear; report few
|
||||
* hours and a scholing-question appears. The progress bar's denominator changes
|
||||
* as you type. "Which question comes next" is a pure function, so it's trivial
|
||||
* to test and impossible to get out of sync with the data.
|
||||
* 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';
|
||||
|
||||
/** Every possible question, as a union — never a bare string. */
|
||||
export type StepId = 'buitenland' | 'buitenlandDetails' | 'uren' | 'scholing' | 'punten' | 'review';
|
||||
/** 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. */
|
||||
@@ -33,111 +34,115 @@ export interface ValidIntake {
|
||||
buitenlandseUren?: Uren;
|
||||
uren: Uren;
|
||||
aanvullendeScholing?: boolean;
|
||||
punten: Uren;
|
||||
punten?: Uren; // only collected when aanvullende scholing is gevolgd (scholingGevolgd === 'ja')
|
||||
}
|
||||
|
||||
/** Below this many NL-hours we ask whether extra scholing was followed. */
|
||||
const LAGE_UREN_DREMPEL = 1000;
|
||||
/** 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;
|
||||
|
||||
function lageUren(a: Answers): boolean {
|
||||
/** 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 < LAGE_UREN_DREMPEL;
|
||||
return r.ok && r.value < scholingThreshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* THE branching, as one pure function. The step list is recomputed on every
|
||||
* transition, so changing an earlier answer immediately adds/removes later steps.
|
||||
*/
|
||||
export function visibleSteps(a: Answers): StepId[] {
|
||||
const steps: StepId[] = ['buitenland'];
|
||||
if (a.buitenlandGewerkt === 'ja') steps.push('buitenlandDetails');
|
||||
steps.push('uren');
|
||||
if (lageUren(a)) steps.push('scholing');
|
||||
steps.push('punten', 'review');
|
||||
return steps;
|
||||
}
|
||||
/** The fixed step list. Number of steps never changes; questions reveal inline. */
|
||||
export const STEPS: StepId[] = ['buitenland', 'werk', 'review'];
|
||||
|
||||
/** 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: Partial<Record<StepId, string>> }
|
||||
| { 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: {} };
|
||||
export const initial: IntakeState = { tag: 'Answering', answers: {}, cursor: 0, errors: {}, scholingThreshold: SCHOLING_THRESHOLD_DEFAULT };
|
||||
|
||||
/** Which step the cursor currently points at (clamped to the live step list). */
|
||||
/** Which step the cursor currently points at (clamped to the fixed list). */
|
||||
export function currentStep(s: Extract<IntakeState, { tag: 'Answering' }>): StepId {
|
||||
const steps = visibleSteps(s.answers);
|
||||
return steps[Math.min(s.cursor, steps.length - 1)];
|
||||
return STEPS[Math.min(s.cursor, STEPS.length - 1)];
|
||||
}
|
||||
|
||||
/** Validate ONE step's fields. Returns the per-field error keyed by StepId. */
|
||||
function validateStep(step: StepId, a: Answers): Result<Partial<Record<StepId, string>>, void> {
|
||||
/** 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':
|
||||
return a.buitenlandGewerkt ? ok(undefined) : err({ buitenland: 'Maak een keuze.' });
|
||||
case 'buitenlandDetails': {
|
||||
if (!a.land || a.land.trim() === '') return err({ buitenlandDetails: 'Vul een land in.' });
|
||||
const u = parseUren(a.buitenlandseUren ?? '');
|
||||
return u.ok ? ok(undefined) : err({ buitenlandDetails: u.error });
|
||||
}
|
||||
case 'uren': {
|
||||
if (!a.buitenlandGewerkt) errors.buitenlandGewerkt = 'Maak een keuze.';
|
||||
else if (a.buitenlandGewerkt === 'ja') {
|
||||
if (!a.land || a.land.trim() === '') errors.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 ?? '');
|
||||
return u.ok ? ok(undefined) : err({ uren: u.error });
|
||||
}
|
||||
case 'scholing':
|
||||
return a.scholingGevolgd ? ok(undefined) : err({ scholing: 'Maak een keuze.' });
|
||||
case 'punten': {
|
||||
const p = parseUren(a.punten ?? '');
|
||||
return p.ok ? ok(undefined) : err({ punten: p.error });
|
||||
if (!u.ok) errors.uren = u.error;
|
||||
if (lageUren(a, scholingThreshold) && !a.scholingGevolgd) errors.scholingGevolgd = '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':
|
||||
return ok(undefined); // review shows a summary; no own fields
|
||||
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): Result<Partial<Record<StepId, string>>, ValidIntake> {
|
||||
const errors: Partial<Record<StepId, string>> = {};
|
||||
for (const step of visibleSteps(a)) {
|
||||
const r = validateStep(step, a);
|
||||
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 ?? '');
|
||||
const punten = parseUren(a.punten ?? '');
|
||||
// visibleSteps guaranteed these parse, but keep the compiler happy.
|
||||
if (!uren.ok || !punten.ok) return err(errors);
|
||||
// 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) ? a.scholingGevolgd === 'ja' : undefined,
|
||||
punten: punten.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;
|
||||
const answers = { ...s.answers, [key]: value };
|
||||
// Editing an earlier answer can shrink the step list; clamp the cursor.
|
||||
const cursor = Math.min(s.cursor, visibleSteps(answers).length - 1);
|
||||
return { ...s, answers, cursor };
|
||||
// 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);
|
||||
const r = validateStep(currentStep(s), s.answers, s.scholingThreshold);
|
||||
if (!r.ok) return { ...s, errors: r.error };
|
||||
const last = visibleSteps(s.answers).length - 1;
|
||||
return { ...s, cursor: Math.min(s.cursor + 1, last), errors: {} };
|
||||
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 {
|
||||
@@ -147,7 +152,7 @@ export function back(s: IntakeState): IntakeState {
|
||||
|
||||
export function submit(s: IntakeState): IntakeState {
|
||||
if (s.tag !== 'Answering') return s;
|
||||
const r = validateAll(s.answers);
|
||||
const r = validateAll(s.answers, s.scholingThreshold);
|
||||
return r.ok ? { tag: 'Submitting', data: r.value } : { ...s, errors: r.error };
|
||||
}
|
||||
|
||||
@@ -164,6 +169,7 @@ export type IntakeMsg =
|
||||
| { tag: 'Retry' }
|
||||
| { tag: 'SubmitConfirmed' }
|
||||
| { tag: 'SubmitFailed'; error: string }
|
||||
| { tag: 'SetPolicy'; scholingThreshold: number }
|
||||
| { tag: 'Seed'; state: IntakeState };
|
||||
|
||||
export function reduce(s: IntakeState, m: IntakeMsg): IntakeState {
|
||||
@@ -182,6 +188,8 @@ export function reduce(s: IntakeState, m: IntakeMsg): IntakeState {
|
||||
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:
|
||||
|
||||
@@ -33,7 +33,10 @@ import { submitHerregistratie } from '@herregistratie/application/submit-herregi
|
||||
<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>
|
||||
<app-button type="submit" variant="primary">Volgende</app-button>
|
||||
<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 })"
|
||||
@@ -42,6 +45,7 @@ import { submitHerregistratie } from '@herregistratie/application/submit-herregi
|
||||
<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>
|
||||
@@ -95,6 +99,11 @@ export class HerregistratieWizardComponent {
|
||||
this.runIfSubmitting();
|
||||
}
|
||||
|
||||
/** Reset the wizard to a fresh, empty start. */
|
||||
restart() {
|
||||
this.dispatch({ tag: 'Seed', state: initial });
|
||||
}
|
||||
|
||||
/** The effect: when we entered Submitting, call the backend command, flip the
|
||||
optimistic cross-page flag, then dispatch the result (and commit/rollback). */
|
||||
private async runIfSubmitting() {
|
||||
|
||||
@@ -4,11 +4,11 @@ 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 { isHerregistratieEligible } from '@registratie/domain/registration.policy';
|
||||
import { HerregistratieWizardComponent } from '@herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component';
|
||||
|
||||
/** A whole new page built from existing building blocks. Eligibility is a pure
|
||||
domain rule (registration.policy) read from the shared profile state. */
|
||||
/** 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],
|
||||
@@ -35,8 +35,9 @@ import { HerregistratieWizardComponent } from '@herregistratie/ui/herregistratie
|
||||
})
|
||||
export class HerregistratiePage {
|
||||
private store = inject(BigProfileStore);
|
||||
// Derive a boolean RemoteData from the combined profile via map (pure).
|
||||
// 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.profile(), (p) => isHerregistratieEligible(p.registration, new Date())),
|
||||
map(this.store.decisions(), (d) => d.eligibleForHerregistratie),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, computed, effect, inject, input } from '@angular/core';
|
||||
import { Component, computed, effect, inject, input, untracked } from '@angular/core';
|
||||
import { httpResource } from '@angular/common/http';
|
||||
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';
|
||||
@@ -15,11 +16,14 @@ import {
|
||||
StepId,
|
||||
initial,
|
||||
reduce,
|
||||
visibleSteps,
|
||||
STEPS,
|
||||
lageUren,
|
||||
SCHOLING_THRESHOLD_DEFAULT,
|
||||
} from '@herregistratie/domain/intake.machine';
|
||||
import { IntakePolicyDto } from '@herregistratie/contracts/intake-policy.dto';
|
||||
import { submitIntake } from '@herregistratie/application/submit-intake';
|
||||
|
||||
const STORAGE_KEY = 'intake-v1'; // ponytail: bump the suffix if the Answers shape changes; no migration.
|
||||
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' }];
|
||||
|
||||
/** Organism: a BRANCHING intake questionnaire. All state lives in one signal
|
||||
@@ -33,38 +37,38 @@ const JA_NEE = [{ value: 'ja', label: 'Ja' }, { value: 'nee', label: 'Nee' }];
|
||||
template: `
|
||||
@switch (state().tag) {
|
||||
@case ('Answering') {
|
||||
<p class="rhc-paragraph" style="color:var(--rhc-color-grijs-700)">Stap {{ cursor() + 1 }} van {{ steps().length }}</p>
|
||||
<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('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 ('buitenlandDetails') {
|
||||
<app-form-field label="In welk land?" fieldId="land" [error]="err('buitenlandDetails')">
|
||||
<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">
|
||||
<app-text-input inputId="buitenlandseUren" [ngModel]="answers().buitenlandseUren ?? ''" (ngModelChange)="set('buitenlandseUren', $event)" name="buitenlandseUren" placeholder="bijv. 800" />
|
||||
</app-form-field>
|
||||
}
|
||||
@case ('uren') {
|
||||
@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>
|
||||
}
|
||||
@case ('scholing') {
|
||||
<app-form-field label="U werkte relatief weinig uren. Heeft u aanvullende scholing gevolgd?" fieldId="scholing" [error]="err('scholing')">
|
||||
<app-radio-group name="scholing" [options]="jaNee"
|
||||
[ngModel]="answers().scholingGevolgd ?? ''" (ngModelChange)="set('scholingGevolgd', $event)" />
|
||||
</app-form-field>
|
||||
}
|
||||
@case ('punten') {
|
||||
<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>
|
||||
@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>
|
||||
@@ -75,10 +79,12 @@ const JA_NEE = [{ value: 'ja', label: 'Ja' }, { value: 'nee', label: 'Nee' }];
|
||||
<div><dt>Buitenlandse uren</dt><dd>{{ answers().buitenlandseUren }}</dd></div>
|
||||
}
|
||||
<div><dt>Uren NL</dt><dd>{{ answers().uren }}</dd></div>
|
||||
@if (steps().includes('scholing')) {
|
||||
@if (scholingZichtbaar()) {
|
||||
<div><dt>Aanvullende scholing</dt><dd>{{ answers().scholingGevolgd }}</dd></div>
|
||||
}
|
||||
<div><dt>Nascholingspunten</dt><dd>{{ answers().punten }}</dd></div>
|
||||
@if (answers().scholingGevolgd === 'ja') {
|
||||
<div><dt>Nascholingspunten</dt><dd>{{ answers().punten }}</dd></div>
|
||||
}
|
||||
</dl>
|
||||
}
|
||||
}
|
||||
@@ -87,6 +93,7 @@ const JA_NEE = [{ value: 'ja', label: 'Ja' }, { value: 'nee', label: 'Nee' }];
|
||||
<app-button type="button" variant="secondary" (click)="dispatch({ tag: 'Back' })">Vorige</app-button>
|
||||
}
|
||||
<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>
|
||||
}
|
||||
@@ -112,6 +119,12 @@ export class IntakeWizardComponent {
|
||||
private profile = inject(BigProfileStore);
|
||||
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 },
|
||||
});
|
||||
|
||||
/** Optional seed so Storybook / the showcase can mount any state directly. */
|
||||
seed = input<IntakeState>(initial);
|
||||
|
||||
@@ -120,14 +133,18 @@ export class IntakeWizardComponent {
|
||||
readonly dispatch = this.store.dispatch;
|
||||
|
||||
private answering = computed(() => (this.state().tag === 'Answering' ? (this.state() as Extract<IntakeState, { tag: 'Answering' }>) : null));
|
||||
/** Public so the showcase can render the live step list next to the wizard. */
|
||||
readonly steps = computed<StepId[]>(() => visibleSteps(this.answering()?.answers ?? {}));
|
||||
/** 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>(() => this.steps()[Math.min(this.cursor(), this.steps().length - 1)]);
|
||||
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(() => (this.state().tag === 'Failed' ? (this.state() as Extract<IntakeState, { tag: 'Failed' }>).error : ''));
|
||||
|
||||
protected err = (k: StepId) => this.answering()?.errors[k] ?? '';
|
||||
protected err = (k: keyof Answers) => this.answering()?.errors[k] ?? '';
|
||||
protected set = (key: keyof Answers, value: string) => this.dispatch({ tag: 'SetAnswer', key, value });
|
||||
|
||||
constructor() {
|
||||
@@ -140,6 +157,13 @@ export class IntakeWizardComponent {
|
||||
if (s.tag === 'Answering') localStorage.setItem(STORAGE_KEY, JSON.stringify(s));
|
||||
else localStorage.removeItem(STORAGE_KEY);
|
||||
});
|
||||
// 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.policyRes.value().scholingThreshold;
|
||||
untracked(() => this.dispatch({ tag: 'SetPolicy', scholingThreshold }));
|
||||
});
|
||||
}
|
||||
|
||||
private restore(): IntakeState | null {
|
||||
|
||||
@@ -16,12 +16,14 @@ const meta: Meta<IntakeWizardComponent> = {
|
||||
export default meta;
|
||||
type Story = StoryObj<IntakeWizardComponent>;
|
||||
|
||||
const answering = (answers: Answers, cursor = 0): IntakeState => ({ tag: 'Answering', answers, cursor, errors: {} });
|
||||
const answering = (answers: Answers, cursor = 0): IntakeState => ({ tag: 'Answering', answers, cursor, errors: {}, scholingThreshold: 1000 });
|
||||
|
||||
export const Start: Story = { args: { seed: answering({}) } };
|
||||
export const AbroadBranch: Story = { args: { seed: answering({ buitenlandGewerkt: 'ja' }, 1) } };
|
||||
export const LowHoursScholing: Story = { args: { seed: answering({ buitenlandGewerkt: 'nee', uren: '500' }, 2) } };
|
||||
export const Review: Story = { args: { seed: answering({ buitenlandGewerkt: 'nee', uren: '4160', punten: '200' }, 3) } };
|
||||
// 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' } } };
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
import { RemoteData, fromResource, map2 } from '@shared/application/remote-data';
|
||||
import { RemoteData, fromResource, map } from '@shared/application/remote-data';
|
||||
import { Aantekening } from '../domain/registration';
|
||||
import { BigProfile } from '../domain/big-profile';
|
||||
import { HerregistratieDecisions, DashboardView } from '../contracts/dashboard-view.dto';
|
||||
import { BigRegisterAdapter } from '../infrastructure/big-register.adapter';
|
||||
import { BrpAdapter } from '../infrastructure/brp.adapter';
|
||||
import { DashboardViewAdapter, parseDashboardView } from '../infrastructure/dashboard-view.adapter';
|
||||
|
||||
type Err = Error | undefined;
|
||||
|
||||
@@ -13,29 +14,32 @@ type Err = Error | undefined;
|
||||
* (created here, in the required injection context) and exposes them as
|
||||
* RemoteData signals.
|
||||
*
|
||||
* The headline trick: `profile` combines TWO independent services — the
|
||||
* BIG-register and the BRP — into ONE RemoteData via map2. A page renders a
|
||||
* single state (loading / error / loaded), never juggling three.
|
||||
* The dashboard data now comes from ONE screen-shaped ("BFF-lite") call that
|
||||
* returns registration + person + server-computed `decisions`. One request → one
|
||||
* consistent snapshot, instead of stitching three independently loading/erroring
|
||||
* resources together client-side. See docs/architecture/0001-bff-lite-decision-dtos.md.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class BigProfileStore {
|
||||
private big = inject(BigRegisterAdapter);
|
||||
private brp = inject(BrpAdapter);
|
||||
private viewAdapter = inject(DashboardViewAdapter);
|
||||
|
||||
private registrationRes = this.big.registrationResource();
|
||||
private viewRes = this.viewAdapter.dashboardViewResource();
|
||||
private aantekeningenRes = this.big.aantekeningenResource();
|
||||
private personRes = this.brp.personResource();
|
||||
|
||||
/** BIG-register + BRP folded into one state. */
|
||||
readonly profile = computed<RemoteData<Err, BigProfile>>(() =>
|
||||
map2(
|
||||
fromResource(this.registrationRes),
|
||||
fromResource(this.personRes),
|
||||
// httpResource types value as T | undefined; in the Success branch it is
|
||||
// always present, so narrowing here is safe.
|
||||
(registration, person) => ({ registration: registration!, person: person! }),
|
||||
),
|
||||
);
|
||||
/** The aggregated view, validated at the trust boundary (DTO → domain). */
|
||||
private view = computed<RemoteData<Err, DashboardView>>(() => {
|
||||
const rd = fromResource(this.viewRes);
|
||||
if (rd.tag !== 'Success') return rd;
|
||||
const parsed = parseDashboardView(rd.value);
|
||||
return parsed.ok ? { tag: 'Success', value: parsed.value } : { tag: 'Failure', error: new Error(parsed.error) };
|
||||
});
|
||||
|
||||
/** Registration + person, from the single aggregated call. */
|
||||
readonly profile = computed<RemoteData<Err, BigProfile>>(() => map(this.view(), (v) => v.profile));
|
||||
|
||||
/** Server-computed decisions (e.g. herregistratie eligibility) — rendered, not recomputed. */
|
||||
readonly decisions = computed<RemoteData<Err, HerregistratieDecisions>>(() => map(this.view(), (v) => v.decisions));
|
||||
|
||||
/** Specialisms/notes stay a separate stream (they have their own empty state). */
|
||||
readonly aantekeningen = computed<RemoteData<Err, Aantekening[]>>(() =>
|
||||
@@ -52,7 +56,7 @@ export class BigProfileStore {
|
||||
}
|
||||
confirmHerregistratie() {
|
||||
this.pending.set(false);
|
||||
this.registrationRes.reload(); // invalidate: re-fetch the now-updated registration
|
||||
this.viewRes.reload(); // invalidate: re-fetch the now-updated view (registration + decisions)
|
||||
}
|
||||
rollbackHerregistratie() {
|
||||
this.pending.set(false); // submission failed — undo the optimistic flag
|
||||
|
||||
19
src/app/registratie/application/submit-registratie.ts
Normal file
19
src/app/registratie/application/submit-registratie.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Result, ok, err } from '@shared/kernel/fp';
|
||||
import { ValidRegistratie } from '@registratie/domain/registratie-wizard.machine';
|
||||
|
||||
/**
|
||||
* Command: send the completed registration to the backend, which invokes the
|
||||
* domain command to create/finalize the registration aggregate. Returns a Result
|
||||
* so the caller branches on success/failure without try/catch; on success it
|
||||
* yields the confirmation reference (PRD §9). ponytail: faked with a timer; swap
|
||||
* for a real POST when there's an API. The "manually entered diploma" rule lets
|
||||
* the demo show the failure path.
|
||||
*/
|
||||
export async function submitRegistratie(data: ValidRegistratie): Promise<Result<string, string>> {
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
if (data.diplomaHerkomst === 'handmatig') {
|
||||
return err('Een handmatig ingevoerd diploma kan niet automatisch worden geverifieerd. Uw aanvraag is doorgestuurd voor handmatige beoordeling.');
|
||||
}
|
||||
const referentie = 'BIG-2026-' + Math.floor(100000 + Math.random() * 900000);
|
||||
return ok(referentie);
|
||||
}
|
||||
15
src/app/registratie/contracts/brp-address.dto.ts
Normal file
15
src/app/registratie/contracts/brp-address.dto.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* WIRE CONTRACT for the BRP address lookup ("BFF-lite" — one screen-shaped call).
|
||||
*
|
||||
* In production this is GENERATED from the OpenAPI/TypeSpec spec and served by our
|
||||
* own backend, which talks to the BRP behind an adapter. The frontend never sees
|
||||
* the BRP's own wire format. See docs/architecture/0001-bff-lite-decision-dtos.md.
|
||||
*
|
||||
* "Geen adres bekend" is a first-class outcome (`gevonden: false`), not an error —
|
||||
* the wizard falls back to manual entry (PRD §7). Slice 1 ships only the happy
|
||||
* path (gevonden: true).
|
||||
*/
|
||||
export interface BrpAddressDto {
|
||||
gevonden: boolean;
|
||||
adres?: { straat: string; postcode: string; woonplaats: string };
|
||||
}
|
||||
36
src/app/registratie/contracts/dashboard-view.dto.ts
Normal file
36
src/app/registratie/contracts/dashboard-view.dto.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Registration } from '@registratie/domain/registration';
|
||||
import { Person } from '@registratie/domain/person';
|
||||
import { BigProfile } from '@registratie/domain/big-profile';
|
||||
|
||||
/**
|
||||
* WIRE CONTRACT for the dashboard screen — the "BFF-lite" response.
|
||||
*
|
||||
* In production this type is GENERATED from the OpenAPI/TypeSpec spec (one source
|
||||
* of truth for both sides), and the `decisions` block is computed BY THE BACKEND
|
||||
* — never recomputed on the client. The frontend renders decisions; it does not
|
||||
* own the rules. See docs/architecture/0001-bff-lite-decision-dtos.md.
|
||||
*
|
||||
* One screen-shaped call replaces the previous three (BIG-register + BRP + …),
|
||||
* so the page always sees one consistent snapshot instead of three independently
|
||||
* loading/erroring resources.
|
||||
*/
|
||||
export interface DashboardViewDto {
|
||||
registration: Registration;
|
||||
person: Person;
|
||||
decisions: HerregistratieDecisions;
|
||||
}
|
||||
|
||||
/** Server-computed decisions. The eligibility rule lives on the backend; the
|
||||
optional reason lets the UI explain itself without knowing the rule. */
|
||||
export interface HerregistratieDecisions {
|
||||
eligibleForHerregistratie: boolean;
|
||||
herregistratieReason?: string;
|
||||
}
|
||||
|
||||
/** The parsed, frontend-side view (DTO mapped onto our own domain model). Keeping
|
||||
this distinct from DashboardViewDto is the decoupling seam: the wire shape can
|
||||
change without the FE domain following, and vice-versa. */
|
||||
export interface DashboardView {
|
||||
profile: BigProfile;
|
||||
decisions: HerregistratieDecisions;
|
||||
}
|
||||
38
src/app/registratie/contracts/duo-diplomas.dto.ts
Normal file
38
src/app/registratie/contracts/duo-diplomas.dto.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* WIRE CONTRACT for the DUO diploma lookup ("BFF-lite" — one screen-shaped call
|
||||
* returning everything the beroep step needs).
|
||||
*
|
||||
* Each diploma carries its server-computed `beroep` (the profession it maps to)
|
||||
* and the `policyQuestions` (geldigheidsvragen) that apply to it. These are
|
||||
* DECISIONS computed by the backend from the diploma's attributes — the frontend
|
||||
* renders them, it does not derive them (decision-DTO pattern, ADR-0001). E.g. an
|
||||
* English-language diploma carries the Dutch-proficiency question.
|
||||
*
|
||||
* `handmatig` is the fallback when the diploma is not in the DUO list: the
|
||||
* professions the user may declare and the MAXIMAL policy-question set that then
|
||||
* applies (a manual diploma is unverified, so the strictest set is used).
|
||||
*/
|
||||
export interface DuoLookupDto {
|
||||
diplomas: DuoDiplomaDto[];
|
||||
handmatig: ManualDiplomaPolicyDto;
|
||||
}
|
||||
|
||||
export interface DuoDiplomaDto {
|
||||
id: string;
|
||||
naam: string;
|
||||
instelling: string;
|
||||
jaar: number;
|
||||
beroep: string; // server-derived profession
|
||||
policyQuestions: PolicyQuestionDto[]; // server-decided geldigheidsvragen
|
||||
}
|
||||
|
||||
export interface ManualDiplomaPolicyDto {
|
||||
beroepen: string[]; // professions the user may declare for a manual diploma
|
||||
policyQuestions: PolicyQuestionDto[]; // maximal set applied to a manual diploma
|
||||
}
|
||||
|
||||
export interface PolicyQuestionDto {
|
||||
id: string;
|
||||
vraag: string;
|
||||
type: 'ja-nee' | 'tekst';
|
||||
}
|
||||
206
src/app/registratie/domain/registratie-wizard.machine.spec.ts
Normal file
206
src/app/registratie/domain/registratie-wizard.machine.spec.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ok, err } from '@shared/kernel/fp';
|
||||
import {
|
||||
Draft,
|
||||
RegistratieState,
|
||||
STEPS,
|
||||
initial,
|
||||
currentStep,
|
||||
next,
|
||||
back,
|
||||
gaNaarStap,
|
||||
kiesDiploma,
|
||||
kiesHandmatig,
|
||||
declareerBeroep,
|
||||
setAntwoord,
|
||||
setField,
|
||||
prefillAdres,
|
||||
submit,
|
||||
resolve,
|
||||
reduce,
|
||||
} from './registratie-wizard.machine';
|
||||
|
||||
const invullen = (draft: Partial<Draft>, cursor = 0): RegistratieState => ({
|
||||
tag: 'Invullen',
|
||||
draft: { antwoorden: {}, ...draft },
|
||||
cursor,
|
||||
errors: {},
|
||||
});
|
||||
|
||||
const validAdres = { straat: 'Lange Voorhout 9', postcode: '2514 EA', woonplaats: 'Den Haag', correspondentie: 'post' as const, adresHerkomst: 'brp' as const };
|
||||
const validDraft: Partial<Draft> = { ...validAdres, diplomaId: 'd1', beroep: 'Arts', diplomaHerkomst: 'duo' };
|
||||
|
||||
describe('STEPS (fixed)', () => {
|
||||
it('always has the same three steps', () => {
|
||||
expect(STEPS).toEqual(['adres', 'beroep', 'controle']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('navigation', () => {
|
||||
it('Next is a no-op (sets errors) when the adres step is invalid', () => {
|
||||
const s = next(initial);
|
||||
expect(s.tag).toBe('Invullen');
|
||||
expect((s as any).cursor).toBe(0);
|
||||
expect((s as any).errors.straat).toBeTruthy();
|
||||
expect((s as any).errors.correspondentie).toBeTruthy();
|
||||
});
|
||||
|
||||
it('Next advances once the adres step is valid', () => {
|
||||
const s = next(invullen(validAdres));
|
||||
expect((s as any).cursor).toBe(1);
|
||||
expect(currentStep(s as any)).toBe('beroep');
|
||||
});
|
||||
|
||||
it('requires a valid e-mail only when the channel is email', () => {
|
||||
const bad = next(invullen({ ...validAdres, correspondentie: 'email' }));
|
||||
expect((bad as any).errors.email).toBeTruthy();
|
||||
const good = next(invullen({ ...validAdres, correspondentie: 'email', email: 'a@b.nl' }));
|
||||
expect((good as any).cursor).toBe(1);
|
||||
});
|
||||
|
||||
it('beroep step requires a chosen diploma', () => {
|
||||
const noDiploma = next(invullen(validAdres, 1));
|
||||
expect((noDiploma as any).cursor).toBe(1);
|
||||
expect((noDiploma as any).errors.diploma).toBeTruthy();
|
||||
const withDiploma = next(invullen(validDraft, 1));
|
||||
expect((withDiploma as any).cursor).toBe(2);
|
||||
});
|
||||
|
||||
it('Back never goes below the first step and preserves the draft', () => {
|
||||
expect(back(initial)).toBe(initial);
|
||||
const s = back(invullen(validDraft, 2));
|
||||
expect((s as any).cursor).toBe(1);
|
||||
expect((s as any).draft.beroep).toBe('Arts');
|
||||
});
|
||||
|
||||
it('GaNaarStap only jumps backwards', () => {
|
||||
expect((gaNaarStap(invullen(validDraft, 2), 0) as any).cursor).toBe(0);
|
||||
expect((gaNaarStap(invullen(validDraft, 1), 2) as any).cursor).toBe(1); // forward jump rejected
|
||||
});
|
||||
});
|
||||
|
||||
describe('adres origin (BRP vs handmatig)', () => {
|
||||
it('prefillAdres flags origin brp', () => {
|
||||
const s = prefillAdres(invullen({}), 'Lange Voorhout 9', '2514 EA', 'Den Haag');
|
||||
expect((s as any).draft.adresHerkomst).toBe('brp');
|
||||
expect((s as any).draft.straat).toBe('Lange Voorhout 9');
|
||||
});
|
||||
|
||||
it('editing a prefilled address field flips origin to handmatig', () => {
|
||||
const prefilled = prefillAdres(invullen({}), 'Lange Voorhout 9', '2514 EA', 'Den Haag');
|
||||
const edited = setField(prefilled, 'woonplaats', 'Rotterdam');
|
||||
expect((edited as any).draft.adresHerkomst).toBe('handmatig');
|
||||
});
|
||||
|
||||
it('typing an address with no BRP prefill yields handmatig', () => {
|
||||
const s = setField(invullen({}), 'straat', 'Kerkstraat 1');
|
||||
expect((s as any).draft.adresHerkomst).toBe('handmatig');
|
||||
});
|
||||
|
||||
it('editing the e-mail field does not change the address origin', () => {
|
||||
const prefilled = prefillAdres(invullen({}), 'Lange Voorhout 9', '2514 EA', 'Den Haag');
|
||||
const edited = setField(prefilled, 'email', 'a@b.nl');
|
||||
expect((edited as any).draft.adresHerkomst).toBe('brp');
|
||||
});
|
||||
|
||||
it('a manually entered address still submits (only manual diploma is gated)', () => {
|
||||
const s = submit(invullen({ straat: 'Kerkstraat 1', postcode: '1234 AB', woonplaats: 'Utrecht', correspondentie: 'post', adresHerkomst: 'handmatig', diplomaId: 'd1', beroep: 'Arts', diplomaHerkomst: 'duo' }));
|
||||
expect(s.tag).toBe('Indienen');
|
||||
expect((s as any).data.adresHerkomst).toBe('handmatig');
|
||||
});
|
||||
});
|
||||
|
||||
describe('kiesDiploma', () => {
|
||||
it('derives the beroep from the chosen diploma and flags origin duo', () => {
|
||||
const s = kiesDiploma(invullen({}), 'd9', 'Verpleegkundige', []);
|
||||
expect((s as any).draft.diplomaId).toBe('d9');
|
||||
expect((s as any).draft.beroep).toBe('Verpleegkundige');
|
||||
expect((s as any).draft.diplomaHerkomst).toBe('duo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('policy questions (geldigheidsvragen)', () => {
|
||||
it('a diploma with questions blocks Next until they are answered', () => {
|
||||
let s = kiesDiploma(invullen(validAdres, 1), 'd2', 'Arts', ['nl-taalvaardigheid']);
|
||||
const blocked = next(s);
|
||||
expect((blocked as any).cursor).toBe(1);
|
||||
expect((blocked as any).errors.antwoorden['nl-taalvaardigheid']).toBeTruthy();
|
||||
s = setAntwoord(s, 'nl-taalvaardigheid', 'ja');
|
||||
expect((next(s) as any).cursor).toBe(2);
|
||||
});
|
||||
|
||||
it('validateAll keeps only the answers to the questions that applied', () => {
|
||||
let s = kiesDiploma(invullen(validAdres, 2), 'd2', 'Arts', ['nl-taalvaardigheid']);
|
||||
s = setAntwoord(s, 'nl-taalvaardigheid', 'ja');
|
||||
s = setAntwoord(s, 'stale', 'x'); // not in vraagIds
|
||||
const done = submit(s);
|
||||
expect(done.tag).toBe('Indienen');
|
||||
expect((done as any).data.antwoorden).toEqual({ 'nl-taalvaardigheid': 'ja' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('manual diploma fallback', () => {
|
||||
const maxIds = ['nl-taalvaardigheid', 'diploma-erkend', 'toelichting'];
|
||||
|
||||
it('KiesHandmatig flags handmatig with the maximal question set and no beroep yet', () => {
|
||||
const s = kiesHandmatig(invullen(validAdres, 1), maxIds);
|
||||
expect((s as any).draft.diplomaHerkomst).toBe('handmatig');
|
||||
expect((s as any).draft.beroep).toBeUndefined();
|
||||
expect((s as any).draft.vraagIds).toEqual(maxIds);
|
||||
});
|
||||
|
||||
it('requires a declared beroep + all maximal questions before submit', () => {
|
||||
let s = kiesHandmatig(invullen(validAdres, 2), maxIds);
|
||||
expect(submit(s).tag).toBe('Invullen'); // no beroep declared
|
||||
s = declareerBeroep(s, 'Fysiotherapeut');
|
||||
expect(submit(s).tag).toBe('Invullen'); // questions unanswered
|
||||
for (const id of maxIds) s = setAntwoord(s, id, 'ja');
|
||||
const done = submit(s);
|
||||
expect(done.tag).toBe('Indienen');
|
||||
expect((done as any).data.diplomaHerkomst).toBe('handmatig');
|
||||
expect((done as any).data.beroep).toBe('Fysiotherapeut');
|
||||
});
|
||||
});
|
||||
|
||||
describe('submit', () => {
|
||||
it('reaches Indienen ONLY with a complete, valid draft', () => {
|
||||
expect(submit(invullen(validAdres)).tag).toBe('Invullen'); // no diploma
|
||||
const good = submit(invullen(validDraft));
|
||||
expect(good.tag).toBe('Indienen');
|
||||
expect((good as any).data.beroep).toBe('Arts');
|
||||
expect((good as any).data.adres.postcode).toBe('2514 EA');
|
||||
expect((good as any).data.adresHerkomst).toBe('brp');
|
||||
});
|
||||
|
||||
it('resolve maps Indienen to Ingediend (with referentie) / Mislukt', () => {
|
||||
const indienen = submit(invullen(validDraft));
|
||||
expect(resolve(indienen, ok('BIG-2026-001')).tag).toBe('Ingediend');
|
||||
expect((resolve(indienen, ok('BIG-2026-001')) as any).referentie).toBe('BIG-2026-001');
|
||||
expect(resolve(indienen, err('boom')).tag).toBe('Mislukt');
|
||||
});
|
||||
});
|
||||
|
||||
describe('reduce (message-driven happy path)', () => {
|
||||
it('drives the full flow via messages', () => {
|
||||
let s: RegistratieState = initial;
|
||||
s = reduce(s, { tag: 'PrefillAdres', straat: 'Lange Voorhout 9', postcode: '2514 EA', woonplaats: 'Den Haag' });
|
||||
s = reduce(s, { tag: 'SetCorrespondentie', value: 'post' });
|
||||
s = reduce(s, { tag: 'Next' });
|
||||
expect(currentStep(s as any)).toBe('beroep');
|
||||
s = reduce(s, { tag: 'KiesDiploma', diplomaId: 'd1', beroep: 'Arts', vraagIds: [] });
|
||||
s = reduce(s, { tag: 'Next' });
|
||||
expect(currentStep(s as any)).toBe('controle');
|
||||
s = reduce(s, { tag: 'Submit' });
|
||||
expect(s.tag).toBe('Indienen');
|
||||
s = reduce(s, { tag: 'SubmitConfirmed', referentie: 'BIG-2026-001' });
|
||||
expect(s.tag).toBe('Ingediend');
|
||||
});
|
||||
|
||||
it('SubmitFailed then Retry returns to Indienen with the same data', () => {
|
||||
let s = reduce(reduce(invullen(validDraft), { tag: 'Submit' }), { tag: 'SubmitFailed', error: 'boom' });
|
||||
expect(s.tag).toBe('Mislukt');
|
||||
s = reduce(s, { tag: 'Retry' });
|
||||
expect(s.tag).toBe('Indienen');
|
||||
expect((s as any).data.beroep).toBe('Arts');
|
||||
});
|
||||
});
|
||||
280
src/app/registratie/domain/registratie-wizard.machine.ts
Normal file
280
src/app/registratie/domain/registratie-wizard.machine.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
import { Result, ok, err, assertNever } from '@shared/kernel/fp';
|
||||
import { Postcode, parsePostcode } from '@registratie/domain/value-objects/postcode';
|
||||
import { Email, parseEmail } from '@registratie/domain/value-objects/email';
|
||||
|
||||
/**
|
||||
* A FIXED 3-step registration wizard. The steps never change in number (always
|
||||
* `STEPS`): (1) adres + correspondentievoorkeur, (2) beroep o.b.v. diploma,
|
||||
* (3) controle & indienen. Follow-up questions appear *inline within a step*
|
||||
* (e.g. choosing 'email' reveals the e-mail field). "Is this field required
|
||||
* right now" is a pure function (`validateStep`), so it is trivial to test and
|
||||
* impossible to get out of sync with the data. Invariants live here, not in the
|
||||
* UI: the wizard reaches `Indienen` only when a complete `ValidRegistratie` parses.
|
||||
*/
|
||||
|
||||
export type StepId = 'adres' | 'beroep' | 'controle';
|
||||
|
||||
/** The fixed step list. Number of steps never changes; questions reveal inline. */
|
||||
export const STEPS: StepId[] = ['adres', 'beroep', 'controle'];
|
||||
|
||||
/** Where a piece of data came from — recorded on the aggregate (PRD §5). */
|
||||
export type AdresHerkomst = 'brp' | 'handmatig';
|
||||
export type DiplomaHerkomst = 'duo' | 'handmatig';
|
||||
export type Correspondentie = 'email' | 'post';
|
||||
|
||||
/** One record carried across every step (and persisted). All optional: the user
|
||||
fills it in gradually. Adres fields are kept flat so one `SetField` message
|
||||
serves them all (mirrors the intake machine). */
|
||||
export interface Draft {
|
||||
straat?: string;
|
||||
postcode?: string;
|
||||
woonplaats?: string;
|
||||
adresHerkomst?: AdresHerkomst;
|
||||
correspondentie?: Correspondentie;
|
||||
email?: string;
|
||||
diplomaId?: string;
|
||||
diplomaHerkomst?: DiplomaHerkomst;
|
||||
beroep?: string; // DERIVED from the chosen DUO diploma (or declared for a manual one)
|
||||
vraagIds?: string[]; // ids of the policy questions that apply to the chosen diploma
|
||||
antwoorden: Record<string, string>; // geldigheidsantwoorden, keyed by question id
|
||||
}
|
||||
|
||||
/** What we have after the controle step parses — guaranteed valid/typed. */
|
||||
export interface ValidRegistratie {
|
||||
adres: { straat: string; postcode: Postcode; woonplaats: string };
|
||||
adresHerkomst: AdresHerkomst;
|
||||
correspondentie: Correspondentie;
|
||||
email?: Email; // only when correspondentie === 'email'
|
||||
diplomaId: string;
|
||||
diplomaHerkomst: DiplomaHerkomst;
|
||||
beroep: string;
|
||||
antwoorden: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Text fields settable via SetField. */
|
||||
export type DraftField = 'straat' | 'postcode' | 'woonplaats' | 'email';
|
||||
|
||||
/** Per-field error map. `antwoorden` holds per-policy-question errors, keyed by
|
||||
question id (a step can show several questions). */
|
||||
export interface Errors {
|
||||
straat?: string;
|
||||
postcode?: string;
|
||||
woonplaats?: string;
|
||||
email?: string;
|
||||
correspondentie?: string;
|
||||
diploma?: string;
|
||||
antwoorden?: Record<string, string>;
|
||||
}
|
||||
|
||||
export type RegistratieState =
|
||||
| { tag: 'Invullen'; draft: Draft; cursor: number; errors: Errors }
|
||||
| { tag: 'Indienen'; data: ValidRegistratie }
|
||||
| { tag: 'Ingediend'; data: ValidRegistratie; referentie: string }
|
||||
| { tag: 'Mislukt'; data: ValidRegistratie; error: string };
|
||||
|
||||
const emptyDraft: Draft = { antwoorden: {} };
|
||||
export const initial: RegistratieState = { tag: 'Invullen', draft: emptyDraft, cursor: 0, errors: {} };
|
||||
|
||||
/** Which step the cursor currently points at (clamped to the fixed list). */
|
||||
export function currentStep(s: Extract<RegistratieState, { tag: 'Invullen' }>): StepId {
|
||||
return STEPS[Math.min(s.cursor, STEPS.length - 1)];
|
||||
}
|
||||
|
||||
/** Validate every question currently visible in ONE step. Errors keyed per field. */
|
||||
function validateStep(step: StepId, d: Draft): Result<Errors, void> {
|
||||
const errors: Errors = {};
|
||||
switch (step) {
|
||||
case 'adres': {
|
||||
if (!d.straat || d.straat.trim() === '') errors.straat = 'Vul een straat en huisnummer in.';
|
||||
const pc = parsePostcode(d.postcode ?? '');
|
||||
if (!pc.ok) errors.postcode = pc.error;
|
||||
if (!d.woonplaats || d.woonplaats.trim() === '') errors.woonplaats = 'Vul een woonplaats in.';
|
||||
if (!d.correspondentie) errors.correspondentie = 'Maak een keuze.';
|
||||
// E-mail is only required when 'email' is the chosen channel.
|
||||
if (d.correspondentie === 'email') {
|
||||
const e = parseEmail(d.email ?? '');
|
||||
if (!e.ok) errors.email = e.error;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'beroep': {
|
||||
// A diploma must be chosen (or declared manually); its beroep is then known.
|
||||
if (!d.diplomaId || !d.beroep) {
|
||||
errors.diploma = 'Kies het diploma waarmee u zich wilt registreren, of voer het handmatig in.';
|
||||
break;
|
||||
}
|
||||
// Every policy question the chosen diploma raised must be answered. Which
|
||||
// questions apply is server-decided (carried in `vraagIds`); we only check
|
||||
// they're answered.
|
||||
const open: Record<string, string> = {};
|
||||
for (const id of d.vraagIds ?? []) {
|
||||
if (!(d.antwoorden[id] ?? '').trim()) open[id] = 'Beantwoord deze vraag.';
|
||||
}
|
||||
if (Object.keys(open).length > 0) errors.antwoorden = open;
|
||||
break;
|
||||
}
|
||||
case 'controle':
|
||||
break; // controle shows a summary; no own fields
|
||||
default:
|
||||
return assertNever(step);
|
||||
}
|
||||
return Object.keys(errors).length > 0 ? err(errors) : ok(undefined);
|
||||
}
|
||||
|
||||
/** Parse the whole wizard into a ValidRegistratie (called on submit). */
|
||||
function validateAll(d: Draft): Result<Errors, ValidRegistratie> {
|
||||
const errors: Errors = {};
|
||||
for (const step of STEPS) {
|
||||
const r = validateStep(step, d);
|
||||
if (!r.ok) Object.assign(errors, r.error);
|
||||
}
|
||||
if (Object.keys(errors).length > 0) return err(errors);
|
||||
|
||||
const pc = parsePostcode(d.postcode ?? '');
|
||||
// validateStep guaranteed these parse, but keep the compiler happy.
|
||||
if (!pc.ok || !d.diplomaId || !d.beroep || !d.correspondentie) return err(errors);
|
||||
const email = d.correspondentie === 'email' ? parseEmail(d.email ?? '') : undefined;
|
||||
// Keep only the answers to the questions that actually applied.
|
||||
const vraagIds = d.vraagIds ?? [];
|
||||
const antwoorden = Object.fromEntries(vraagIds.map((id) => [id, d.antwoorden[id] ?? '']));
|
||||
|
||||
return ok({
|
||||
adres: { straat: d.straat!, postcode: pc.value, woonplaats: d.woonplaats! },
|
||||
adresHerkomst: d.adresHerkomst ?? 'handmatig',
|
||||
correspondentie: d.correspondentie,
|
||||
email: email?.ok ? email.value : undefined,
|
||||
diplomaId: d.diplomaId,
|
||||
diplomaHerkomst: d.diplomaHerkomst ?? 'handmatig',
|
||||
beroep: d.beroep,
|
||||
antwoorden,
|
||||
});
|
||||
}
|
||||
|
||||
export function setField(s: RegistratieState, key: DraftField, value: string): RegistratieState {
|
||||
if (s.tag !== 'Invullen') return s;
|
||||
const draft: Draft = { ...s.draft, [key]: value };
|
||||
// Editing an address field means the user owns it now — not the BRP copy.
|
||||
if (key === 'straat' || key === 'postcode' || key === 'woonplaats') draft.adresHerkomst = 'handmatig';
|
||||
return { ...s, draft };
|
||||
}
|
||||
|
||||
export function setCorrespondentie(s: RegistratieState, value: Correspondentie): RegistratieState {
|
||||
if (s.tag !== 'Invullen') return s;
|
||||
return { ...s, draft: { ...s.draft, correspondentie: value } };
|
||||
}
|
||||
|
||||
/** Prefill the address from a BRP lookup and flag its origin (PRD §7). */
|
||||
export function prefillAdres(s: RegistratieState, straat: string, postcode: string, woonplaats: string): RegistratieState {
|
||||
if (s.tag !== 'Invullen') return s;
|
||||
return { ...s, draft: { ...s.draft, straat, postcode, woonplaats, adresHerkomst: 'brp' } };
|
||||
}
|
||||
|
||||
/** Pick a DUO diploma; the beroep is derived from it and the applicable policy
|
||||
questions (`vraagIds`) come with it (both server-computed, passed in). */
|
||||
export function kiesDiploma(s: RegistratieState, diplomaId: string, beroep: string, vraagIds: string[]): RegistratieState {
|
||||
if (s.tag !== 'Invullen') return s;
|
||||
return { ...s, draft: { ...s.draft, diplomaId, beroep, vraagIds, diplomaHerkomst: 'duo' }, errors: {} };
|
||||
}
|
||||
|
||||
/** Switch to manual diploma entry: the diploma isn't in DUO, so the MAXIMAL
|
||||
policy-question set applies and the entry is flagged handmatig/unverified. The
|
||||
beroep is declared separately (declareerBeroep). */
|
||||
export function kiesHandmatig(s: RegistratieState, vraagIds: string[]): RegistratieState {
|
||||
if (s.tag !== 'Invullen') return s;
|
||||
return { ...s, draft: { ...s.draft, diplomaId: 'handmatig', beroep: undefined, vraagIds, diplomaHerkomst: 'handmatig' }, errors: {} };
|
||||
}
|
||||
|
||||
/** Declare the beroep for a manually-entered diploma (chosen from a fixed list). */
|
||||
export function declareerBeroep(s: RegistratieState, beroep: string): RegistratieState {
|
||||
if (s.tag !== 'Invullen') return s;
|
||||
return { ...s, draft: { ...s.draft, beroep } };
|
||||
}
|
||||
|
||||
export function setAntwoord(s: RegistratieState, vraagId: string, value: string): RegistratieState {
|
||||
if (s.tag !== 'Invullen') return s;
|
||||
return { ...s, draft: { ...s.draft, antwoorden: { ...s.draft.antwoorden, [vraagId]: value } } };
|
||||
}
|
||||
|
||||
export function next(s: RegistratieState): RegistratieState {
|
||||
if (s.tag !== 'Invullen') return s;
|
||||
const r = validateStep(currentStep(s), s.draft);
|
||||
if (!r.ok) return { ...s, errors: r.error };
|
||||
return { ...s, cursor: Math.min(s.cursor + 1, STEPS.length - 1), errors: {} };
|
||||
}
|
||||
|
||||
export function back(s: RegistratieState): RegistratieState {
|
||||
if (s.tag !== 'Invullen' || s.cursor === 0) return s;
|
||||
return { ...s, cursor: s.cursor - 1, errors: {} };
|
||||
}
|
||||
|
||||
/** Jump back to an earlier step to correct data (controle → step N). Forward
|
||||
jumps are not allowed (would skip validation). Preserves the draft. */
|
||||
export function gaNaarStap(s: RegistratieState, cursor: number): RegistratieState {
|
||||
if (s.tag !== 'Invullen' || cursor < 0 || cursor >= s.cursor) return s;
|
||||
return { ...s, cursor, errors: {} };
|
||||
}
|
||||
|
||||
export function submit(s: RegistratieState): RegistratieState {
|
||||
if (s.tag !== 'Invullen') return s;
|
||||
const r = validateAll(s.draft);
|
||||
return r.ok ? { tag: 'Indienen', data: r.value } : { ...s, errors: r.error };
|
||||
}
|
||||
|
||||
export function resolve(s: RegistratieState, r: Result<string, string>): RegistratieState {
|
||||
if (s.tag !== 'Indienen') return s;
|
||||
return r.ok ? { tag: 'Ingediend', data: s.data, referentie: r.value } : { tag: 'Mislukt', data: s.data, error: r.error };
|
||||
}
|
||||
|
||||
export type RegistratieMsg =
|
||||
| { tag: 'SetField'; key: DraftField; value: string }
|
||||
| { tag: 'SetCorrespondentie'; value: Correspondentie }
|
||||
| { tag: 'PrefillAdres'; straat: string; postcode: string; woonplaats: string }
|
||||
| { tag: 'KiesDiploma'; diplomaId: string; beroep: string; vraagIds: string[] }
|
||||
| { tag: 'KiesHandmatig'; vraagIds: string[] }
|
||||
| { tag: 'DeclareerBeroep'; beroep: string }
|
||||
| { tag: 'SetAntwoord'; vraagId: string; value: string }
|
||||
| { tag: 'Next' }
|
||||
| { tag: 'Back' }
|
||||
| { tag: 'GaNaarStap'; cursor: number }
|
||||
| { tag: 'Submit' }
|
||||
| { tag: 'Retry' }
|
||||
| { tag: 'SubmitConfirmed'; referentie: string }
|
||||
| { tag: 'SubmitFailed'; error: string }
|
||||
| { tag: 'Seed'; state: RegistratieState };
|
||||
|
||||
export function reduce(s: RegistratieState, m: RegistratieMsg): RegistratieState {
|
||||
switch (m.tag) {
|
||||
case 'SetField':
|
||||
return setField(s, m.key, m.value);
|
||||
case 'SetCorrespondentie':
|
||||
return setCorrespondentie(s, m.value);
|
||||
case 'PrefillAdres':
|
||||
return prefillAdres(s, m.straat, m.postcode, m.woonplaats);
|
||||
case 'KiesDiploma':
|
||||
return kiesDiploma(s, m.diplomaId, m.beroep, m.vraagIds);
|
||||
case 'KiesHandmatig':
|
||||
return kiesHandmatig(s, m.vraagIds);
|
||||
case 'DeclareerBeroep':
|
||||
return declareerBeroep(s, m.beroep);
|
||||
case 'SetAntwoord':
|
||||
return setAntwoord(s, m.vraagId, 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 === 'Mislukt' ? { tag: 'Indienen', data: s.data } : s;
|
||||
case 'SubmitConfirmed':
|
||||
return s.tag === 'Indienen' ? { tag: 'Ingediend', data: s.data, referentie: m.referentie } : s;
|
||||
case 'SubmitFailed':
|
||||
return s.tag === 'Indienen' ? { tag: 'Mislukt', data: s.data, error: m.error } : s;
|
||||
case 'Seed':
|
||||
return m.state;
|
||||
default:
|
||||
return assertNever(m);
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,10 @@ export function herregistratieDeadline(reg: Registration): Date | null {
|
||||
}
|
||||
|
||||
/** A registration may apply for herregistratie only while active and within the
|
||||
window before its deadline. A struck-off or suspended registration may not. */
|
||||
window before its deadline. A struck-off or suspended registration may not.
|
||||
SERVER-OWNED RULE: this now runs on the backend (BFF), which ships the result
|
||||
as `decisions.eligibleForHerregistratie` in the dashboard view. Kept here as
|
||||
the reference implementation + unit test; the frontend no longer calls it. */
|
||||
export function isHerregistratieEligible(reg: Registration, today: Date, windowMonths = 12): boolean {
|
||||
const deadline = herregistratieDeadline(reg);
|
||||
if (!deadline) return false;
|
||||
|
||||
17
src/app/registratie/domain/value-objects/email.spec.ts
Normal file
17
src/app/registratie/domain/value-objects/email.spec.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseEmail } from './email';
|
||||
|
||||
describe('parseEmail', () => {
|
||||
it('accepts a well-formed address and trims it', () => {
|
||||
const r = parseEmail(' naam@voorbeeld.nl ');
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) expect(r.value).toBe('naam@voorbeeld.nl');
|
||||
});
|
||||
|
||||
it('rejects malformed addresses', () => {
|
||||
expect(parseEmail('').ok).toBe(false);
|
||||
expect(parseEmail('naam').ok).toBe(false);
|
||||
expect(parseEmail('naam@voorbeeld').ok).toBe(false);
|
||||
expect(parseEmail('naam @voorbeeld.nl').ok).toBe(false);
|
||||
});
|
||||
});
|
||||
19
src/app/registratie/domain/value-objects/email.ts
Normal file
19
src/app/registratie/domain/value-objects/email.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Brand, Result, ok, err } from '@shared/kernel/fp';
|
||||
|
||||
/**
|
||||
* Value object: an e-mail address. "Parse, don't validate" — an Email is a
|
||||
* distinct type from a raw string, mintable only via parseEmail, so holding one
|
||||
* is proof it is well-formed. Format-only check (the FE keeps format validation
|
||||
* for instant feedback; the backend stays the authority — see ADR-0001).
|
||||
*/
|
||||
export type Email = Brand<string, 'Email'>;
|
||||
|
||||
export function parseEmail(raw: string): Result<string, Email> {
|
||||
const t = raw.trim();
|
||||
// Deliberately lax: a single @ with non-empty, dot-bearing parts. Good enough
|
||||
// for instant feedback; the server re-validates.
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(t)) {
|
||||
return err('Voer een geldig e-mailadres in, bijv. naam@voorbeeld.nl.');
|
||||
}
|
||||
return ok(t as Email);
|
||||
}
|
||||
@@ -1,19 +1,18 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { httpResource } from '@angular/common/http';
|
||||
import { Registration, Aantekening } from '../domain/registration';
|
||||
import { Aantekening } from '../domain/registration';
|
||||
|
||||
/**
|
||||
* Infrastructure adapter for the BIG-register source. Exposes signal-based
|
||||
* resources (Angular's httpResource); each returns a Resource with
|
||||
* status()/value()/error()/reload(). Call from an injection context
|
||||
* (a field initializer in the store).
|
||||
*
|
||||
* Note: registration + person are now served via the aggregated dashboard-view
|
||||
* endpoint (see DashboardViewAdapter). Only the notes stream remains separate.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class BigRegisterAdapter {
|
||||
registrationResource() {
|
||||
return httpResource<Registration>(() => 'mock/registration.json');
|
||||
}
|
||||
|
||||
aantekeningenResource() {
|
||||
return httpResource<Aantekening[]>(() => 'mock/notes.json', { defaultValue: [] });
|
||||
}
|
||||
|
||||
22
src/app/registratie/infrastructure/brp.adapter.spec.ts
Normal file
22
src/app/registratie/infrastructure/brp.adapter.spec.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseBrpAddress } from './brp.adapter';
|
||||
|
||||
describe('parseBrpAddress (trust boundary)', () => {
|
||||
it('accepts a found address', () => {
|
||||
const r = parseBrpAddress({ gevonden: true, adres: { straat: 'Lange Voorhout 9', postcode: '2514 EA', woonplaats: 'Den Haag' } });
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) expect(r.value.adres?.postcode).toBe('2514 EA');
|
||||
});
|
||||
|
||||
it('accepts "geen adres" (gevonden: false) as a valid outcome', () => {
|
||||
const r = parseBrpAddress({ gevonden: false });
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects malformed responses', () => {
|
||||
expect(parseBrpAddress(null).ok).toBe(false);
|
||||
expect(parseBrpAddress({}).ok).toBe(false); // missing gevonden
|
||||
expect(parseBrpAddress({ gevonden: true }).ok).toBe(false); // found but no adres
|
||||
expect(parseBrpAddress({ gevonden: true, adres: { straat: 'x' } }).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,34 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { httpResource } from '@angular/common/http';
|
||||
import { Person } from '../domain/person';
|
||||
import { Result, ok, err } from '@shared/kernel/fp';
|
||||
import { BrpAddressDto } from '@registratie/contracts/brp-address.dto';
|
||||
|
||||
/** Infrastructure adapter for the BRP (Basisregistratie Personen) source. */
|
||||
/**
|
||||
* Infrastructure adapter for the BRP address lookup, reached only through our own
|
||||
* ("BFF-lite") endpoint — the anti-corruption boundary. In this POC the endpoint
|
||||
* is a static mock; pointing at a real backend touches only this file + the DTO.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class BrpAdapter {
|
||||
personResource() {
|
||||
return httpResource<Person>(() => 'mock/brp.json');
|
||||
// Typed as the DTO for ergonomics, but the value is untrusted JSON until
|
||||
// parseBrpAddress validates it.
|
||||
adresResource() {
|
||||
return httpResource<BrpAddressDto>(() => 'mock/brp-address.json');
|
||||
}
|
||||
}
|
||||
|
||||
/** Trust-boundary parse: validate the untrusted response shape. "Geen adres" is a
|
||||
valid outcome (gevonden: false), not a malformed response. ponytail: hand-written;
|
||||
reach for a schema lib once the contract count grows. */
|
||||
export function parseBrpAddress(json: unknown): Result<string, BrpAddressDto> {
|
||||
if (typeof json !== 'object' || json === null) return err('brp-address: not an object');
|
||||
const dto = json as Partial<BrpAddressDto>;
|
||||
if (typeof dto.gevonden !== 'boolean') return err('brp-address: missing/invalid gevonden');
|
||||
if (dto.gevonden) {
|
||||
const a = dto.adres;
|
||||
if (!a || typeof a.straat !== 'string' || typeof a.postcode !== 'string' || typeof a.woonplaats !== 'string') {
|
||||
return err('brp-address: missing/invalid adres');
|
||||
}
|
||||
}
|
||||
return ok({ gevonden: dto.gevonden, adres: dto.adres });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseDashboardView } from './dashboard-view.adapter';
|
||||
|
||||
const valid = {
|
||||
registration: {
|
||||
bigNummer: '19012345601',
|
||||
naam: 'Dr. A. de Vries',
|
||||
beroep: 'Arts',
|
||||
registratiedatum: '2012-09-01',
|
||||
geboortedatum: '1985-03-14',
|
||||
status: { tag: 'Geregistreerd', herregistratieDatum: '2027-03-01' },
|
||||
},
|
||||
person: { naam: 'Dr. A. de Vries', geboortedatum: '1985-03-14', adres: { straat: 'X 1', postcode: '2514 EA', woonplaats: 'Den Haag' } },
|
||||
decisions: { eligibleForHerregistratie: true, herregistratieReason: 'within window' },
|
||||
};
|
||||
|
||||
describe('parseDashboardView (trust boundary)', () => {
|
||||
it('maps a valid response into a DashboardView', () => {
|
||||
const r = parseDashboardView(valid);
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
expect(r.value.profile.registration.bigNummer).toBe('19012345601');
|
||||
expect(r.value.decisions.eligibleForHerregistratie).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects malformed responses instead of trusting them', () => {
|
||||
expect(parseDashboardView(null).ok).toBe(false);
|
||||
expect(parseDashboardView({ ...valid, registration: undefined }).ok).toBe(false);
|
||||
expect(parseDashboardView({ ...valid, decisions: { eligibleForHerregistratie: 'yes' } }).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
47
src/app/registratie/infrastructure/dashboard-view.adapter.ts
Normal file
47
src/app/registratie/infrastructure/dashboard-view.adapter.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { httpResource } from '@angular/common/http';
|
||||
import { Result, ok, err } from '@shared/kernel/fp';
|
||||
import { DashboardViewDto, DashboardView } from '@registratie/contracts/dashboard-view.dto';
|
||||
|
||||
/**
|
||||
* Infrastructure adapter for the screen-shaped ("BFF-lite") dashboard endpoint.
|
||||
* ONE call returns registration + person + server-computed decisions.
|
||||
* (In this POC the endpoint is a static mock; the decisions are precomputed to
|
||||
* stand in for what the backend would compute.)
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DashboardViewAdapter {
|
||||
// Typed as the DTO for ergonomics, but the value is still untrusted JSON —
|
||||
// parseDashboardView validates it at the boundary before the app uses it.
|
||||
dashboardViewResource() {
|
||||
return httpResource<DashboardViewDto>(() => 'mock/dashboard-view.json');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trust-boundary parse: validate the untrusted response shape and map the DTO
|
||||
* onto our own domain model. Hand-written on purpose — no Zod for a single
|
||||
* contract. ponytail: reach for a schema lib once the contract count grows.
|
||||
*/
|
||||
export function parseDashboardView(json: unknown): Result<string, DashboardView> {
|
||||
if (typeof json !== 'object' || json === null) return err('dashboard-view: not an object');
|
||||
const dto = json as Partial<DashboardViewDto>;
|
||||
|
||||
const reg = dto.registration;
|
||||
if (!reg || typeof reg.bigNummer !== 'string' || !reg.status || typeof reg.status.tag !== 'string') {
|
||||
return err('dashboard-view: missing/invalid registration');
|
||||
}
|
||||
const person = dto.person;
|
||||
if (!person || !person.adres || typeof person.adres.postcode !== 'string') {
|
||||
return err('dashboard-view: missing/invalid person');
|
||||
}
|
||||
const d = dto.decisions;
|
||||
if (!d || typeof d.eligibleForHerregistratie !== 'boolean') {
|
||||
return err('dashboard-view: missing/invalid decisions');
|
||||
}
|
||||
|
||||
return ok({
|
||||
profile: { registration: reg, person },
|
||||
decisions: { eligibleForHerregistratie: d.eligibleForHerregistratie, herregistratieReason: d.herregistratieReason },
|
||||
});
|
||||
}
|
||||
39
src/app/registratie/infrastructure/duo.adapter.spec.ts
Normal file
39
src/app/registratie/infrastructure/duo.adapter.spec.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseDuoLookup } from './duo.adapter';
|
||||
|
||||
const valid = {
|
||||
diplomas: [
|
||||
{ id: 'd1', naam: 'Geneeskunde', instelling: 'Universiteit Leiden', jaar: 2011, beroep: 'Arts', policyQuestions: [] },
|
||||
{ id: 'd2', naam: 'Medicine', instelling: 'University of Edinburgh', jaar: 2013, beroep: 'Arts', policyQuestions: [{ id: 'nl-taal', vraag: 'Toon taalvaardigheid', type: 'ja-nee' }] },
|
||||
],
|
||||
handmatig: {
|
||||
beroepen: ['Arts', 'Verpleegkundige'],
|
||||
policyQuestions: [{ id: 'toelichting', vraag: 'Toelichting', type: 'tekst' }],
|
||||
},
|
||||
};
|
||||
|
||||
describe('parseDuoLookup (trust boundary)', () => {
|
||||
it('maps a valid lookup (diplomas + manual fallback)', () => {
|
||||
const r = parseDuoLookup(valid);
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
expect(r.value.diplomas).toHaveLength(2);
|
||||
expect(r.value.diplomas[1].policyQuestions[0].id).toBe('nl-taal');
|
||||
expect(r.value.handmatig.beroepen).toContain('Verpleegkundige');
|
||||
expect(r.value.handmatig.policyQuestions[0].type).toBe('tekst');
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts an empty diploma list (forces manual entry)', () => {
|
||||
const r = parseDuoLookup({ diplomas: [], handmatig: valid.handmatig });
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects malformed responses', () => {
|
||||
expect(parseDuoLookup(null).ok).toBe(false);
|
||||
expect(parseDuoLookup({}).ok).toBe(false); // no diplomas
|
||||
expect(parseDuoLookup({ diplomas: [] }).ok).toBe(false); // no handmatig
|
||||
expect(parseDuoLookup({ diplomas: [{ id: 'd1' }], handmatig: valid.handmatig }).ok).toBe(false); // bad diploma
|
||||
expect(parseDuoLookup({ diplomas: [], handmatig: { beroepen: ['Arts'], policyQuestions: [{ id: 'x', vraag: 'y', type: 'bogus' }] } }).ok).toBe(false); // bad question type
|
||||
});
|
||||
});
|
||||
60
src/app/registratie/infrastructure/duo.adapter.ts
Normal file
60
src/app/registratie/infrastructure/duo.adapter.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { httpResource } from '@angular/common/http';
|
||||
import { Result, ok, err } from '@shared/kernel/fp';
|
||||
import { DuoLookupDto, DuoDiplomaDto, PolicyQuestionDto, ManualDiplomaPolicyDto } from '@registratie/contracts/duo-diplomas.dto';
|
||||
|
||||
const EMPTY: DuoLookupDto = { diplomas: [], handmatig: { beroepen: [], policyQuestions: [] } };
|
||||
|
||||
/**
|
||||
* Infrastructure adapter for the DUO diploma lookup, reached only through our own
|
||||
* ("BFF-lite") endpoint — the anti-corruption boundary. The response carries the
|
||||
* user's diplomas (each with its server-computed beroep + policy questions) and
|
||||
* the manual-entry fallback policy. The frontend renders; it does not derive. In
|
||||
* this POC the endpoint is a static mock.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DuoAdapter {
|
||||
diplomasResource() {
|
||||
return httpResource<DuoLookupDto>(() => 'mock/duo-diplomas.json', { defaultValue: EMPTY });
|
||||
}
|
||||
}
|
||||
|
||||
function parseQuestions(json: unknown): PolicyQuestionDto[] | null {
|
||||
if (!Array.isArray(json)) return null;
|
||||
const out: PolicyQuestionDto[] = [];
|
||||
for (const q of json) {
|
||||
if (typeof q !== 'object' || q === null) return null;
|
||||
const p = q as Partial<PolicyQuestionDto>;
|
||||
if (typeof p.id !== 'string' || typeof p.vraag !== 'string' || (p.type !== 'ja-nee' && p.type !== 'tekst')) return null;
|
||||
out.push({ id: p.id, vraag: p.vraag, type: p.type });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Trust-boundary parse: validate the untrusted response shape (diplomas, each
|
||||
with derived beroep + policy questions, plus the manual-entry fallback). */
|
||||
export function parseDuoLookup(json: unknown): Result<string, DuoLookupDto> {
|
||||
if (typeof json !== 'object' || json === null) return err('duo-lookup: not an object');
|
||||
const dto = json as Partial<DuoLookupDto>;
|
||||
if (!Array.isArray(dto.diplomas)) return err('duo-lookup: missing diplomas');
|
||||
|
||||
const diplomas: DuoDiplomaDto[] = [];
|
||||
for (const item of dto.diplomas) {
|
||||
if (typeof item !== 'object' || item === null) return err('duo-lookup: invalid diploma');
|
||||
const d = item as Partial<DuoDiplomaDto>;
|
||||
const vragen = parseQuestions(d.policyQuestions);
|
||||
if (typeof d.id !== 'string' || typeof d.naam !== 'string' || typeof d.beroep !== 'string' || vragen === null) {
|
||||
return err('duo-lookup: missing/invalid diploma fields');
|
||||
}
|
||||
diplomas.push({ id: d.id, naam: d.naam, instelling: d.instelling ?? '', jaar: typeof d.jaar === 'number' ? d.jaar : 0, beroep: d.beroep, policyQuestions: vragen });
|
||||
}
|
||||
|
||||
const hm = dto.handmatig;
|
||||
const hmVragen = hm ? parseQuestions(hm.policyQuestions) : null;
|
||||
if (!hm || !Array.isArray(hm.beroepen) || hmVragen === null) {
|
||||
return err('duo-lookup: missing/invalid handmatig fallback');
|
||||
}
|
||||
const handmatig: ManualDiplomaPolicyDto = { beroepen: hm.beroepen, policyQuestions: hmVragen };
|
||||
|
||||
return ok({ diplomas, handmatig });
|
||||
}
|
||||
@@ -56,6 +56,9 @@ import { BigProfileStore } from '@registratie/application/big-profile.store';
|
||||
</div>
|
||||
|
||||
<p style="margin-top:2rem">
|
||||
<app-link to="/registreren">Inschrijven in het BIG-register (registratiewizard) →</app-link>
|
||||
</p>
|
||||
<p>
|
||||
<app-link to="/registratie">Gegevens bekijken of een wijziging doorgeven →</app-link>
|
||||
</p>
|
||||
<p>
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
import { Component, ElementRef, computed, effect, inject, input, untracked, viewChild } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
|
||||
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
|
||||
import { RadioGroupComponent } from '@shared/ui/radio-group/radio-group.component';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||
import { SpinnerComponent } from '@shared/ui/spinner/spinner.component';
|
||||
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
|
||||
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
|
||||
import { StepperComponent } from '@shared/ui/stepper/stepper.component';
|
||||
import { ASYNC } from '@shared/ui/async/async.component';
|
||||
import { createStore } from '@shared/application/store';
|
||||
import { RemoteData, fromResource } from '@shared/application/remote-data';
|
||||
import { BrpAdapter, parseBrpAddress } from '@registratie/infrastructure/brp.adapter';
|
||||
import { DuoAdapter, parseDuoLookup } from '@registratie/infrastructure/duo.adapter';
|
||||
import { DuoLookupDto, PolicyQuestionDto } from '@registratie/contracts/duo-diplomas.dto';
|
||||
import {
|
||||
RegistratieState,
|
||||
RegistratieMsg,
|
||||
Draft,
|
||||
DraftField,
|
||||
Correspondentie,
|
||||
StepId,
|
||||
initial,
|
||||
reduce,
|
||||
STEPS,
|
||||
} from '@registratie/domain/registratie-wizard.machine';
|
||||
import { submitRegistratie } from '@registratie/application/submit-registratie';
|
||||
|
||||
const STORAGE_KEY = 'registratie-v1'; // ponytail: bump the suffix if the persisted shape changes; no migration.
|
||||
const KANALEN = [{ value: 'email', label: 'E-mail' }, { value: 'post', label: 'Post' }];
|
||||
const JA_NEE = [{ value: 'ja', label: 'Ja' }, { value: 'nee', label: 'Nee' }];
|
||||
const HANDMATIG = '__handmatig__'; // sentinel option: "my diploma isn't listed"
|
||||
|
||||
/** Organism: the BIG-registration wizard. All state lives in one signal driven by
|
||||
the pure `reduce` (registratie-wizard.machine.ts). The BRP address prefills the
|
||||
draft via an effect; the DUO diploma list renders through <app-async>; choosing
|
||||
a diploma reveals its server-derived beroep. The draft is persisted to
|
||||
localStorage so a reload keeps the user's progress. Built entirely from existing
|
||||
atoms/molecules. */
|
||||
@Component({
|
||||
selector: 'app-registratie-wizard',
|
||||
imports: [
|
||||
FormsModule, FormFieldComponent, TextInputComponent, RadioGroupComponent, ButtonComponent,
|
||||
AlertComponent, SpinnerComponent, SkeletonComponent, DataRowComponent, StepperComponent, ...ASYNC,
|
||||
],
|
||||
template: `
|
||||
@switch (state().tag) {
|
||||
@case ('Invullen') {
|
||||
<app-stepper class="app-section" [steps]="stepLabels" [current]="cursor()" />
|
||||
<h2 #stepHeading tabindex="-1" class="rhc-heading nl-heading--level-2 app-section">{{ stepTitle() }}</h2>
|
||||
<form (ngSubmit)="onPrimary()" class="app-form">
|
||||
@switch (step()) {
|
||||
@case ('adres') {
|
||||
@if (adresStatus() === 'laden') {
|
||||
<app-skeleton height="2.5rem" [count]="4" />
|
||||
} @else {
|
||||
@switch (adresStatus()) {
|
||||
@case ('gevonden') {
|
||||
<app-alert type="info">Vooraf ingevuld op basis van de BRP. Controleer en pas zo nodig aan.</app-alert>
|
||||
}
|
||||
@case ('geen') {
|
||||
<app-alert type="warning">We vonden geen adres in de BRP. Vul uw adres hieronder handmatig in.</app-alert>
|
||||
}
|
||||
@case ('fout') {
|
||||
<app-alert type="warning">We konden de BRP nu niet bereiken. Vul uw adres hieronder handmatig in.</app-alert>
|
||||
}
|
||||
}
|
||||
<app-form-field label="Straat en huisnummer" fieldId="straat" [error]="err('straat')">
|
||||
<app-text-input inputId="straat" [invalid]="!!err('straat')" [ngModel]="draft().straat ?? ''" (ngModelChange)="set('straat', $event)" name="straat" />
|
||||
</app-form-field>
|
||||
<app-form-field label="Postcode" fieldId="postcode" [error]="err('postcode')">
|
||||
<app-text-input inputId="postcode" [invalid]="!!err('postcode')" [ngModel]="draft().postcode ?? ''" (ngModelChange)="set('postcode', $event)" name="postcode" placeholder="1234 AB" />
|
||||
</app-form-field>
|
||||
<app-form-field label="Woonplaats" fieldId="woonplaats" [error]="err('woonplaats')">
|
||||
<app-text-input inputId="woonplaats" [invalid]="!!err('woonplaats')" [ngModel]="draft().woonplaats ?? ''" (ngModelChange)="set('woonplaats', $event)" name="woonplaats" />
|
||||
</app-form-field>
|
||||
<app-form-field label="Hoe wilt u correspondentie ontvangen?" fieldId="correspondentie" [error]="err('correspondentie')">
|
||||
<app-radio-group name="correspondentie" [options]="kanalen" [invalid]="!!err('correspondentie')"
|
||||
[ngModel]="draft().correspondentie ?? ''" (ngModelChange)="setKanaal($event)" />
|
||||
</app-form-field>
|
||||
@if (draft().correspondentie === 'email') {
|
||||
<app-form-field label="E-mailadres" fieldId="email" [error]="err('email')">
|
||||
<app-text-input inputId="email" type="email" [invalid]="!!err('email')" [ngModel]="draft().email ?? ''" (ngModelChange)="set('email', $event)" name="email" placeholder="naam@voorbeeld.nl" />
|
||||
</app-form-field>
|
||||
}
|
||||
}
|
||||
}
|
||||
@case ('beroep') {
|
||||
<app-async [data]="lookupRd()">
|
||||
<ng-template appAsyncLoaded let-data>
|
||||
<app-form-field label="Kies het diploma waarmee u zich wilt registreren" fieldId="diploma" [error]="err('diploma')">
|
||||
<app-radio-group name="diploma" [options]="diplomaOptions($any(data))" [invalid]="!!err('diploma')"
|
||||
[ngModel]="diplomaKeuze()" (ngModelChange)="onDiplomaKeuze($any(data), $event)" />
|
||||
</app-form-field>
|
||||
|
||||
@if (handmatigActief()) {
|
||||
<app-alert type="warning">Een handmatig ingevoerd diploma kan niet automatisch worden geverifieerd. Kies uw beroep en beantwoord de aanvullende vragen; uw aanvraag wordt daarna handmatig beoordeeld.</app-alert>
|
||||
<app-form-field label="Voor welk beroep wilt u zich registreren?" fieldId="hm-beroep" [error]="err('diploma')">
|
||||
<app-radio-group name="hm-beroep" [options]="beroepOptions($any(data))" [invalid]="!!err('diploma')"
|
||||
[ngModel]="draft().beroep ?? ''" (ngModelChange)="dispatch({ tag: 'DeclareerBeroep', beroep: $event })" />
|
||||
</app-form-field>
|
||||
} @else if (draft().beroep) {
|
||||
<dl class="rhc-data-summary rhc-data-summary--row app-section">
|
||||
<app-data-row key="Beroep (afgeleid uit diploma)" [value]="draft().beroep ?? ''" />
|
||||
</dl>
|
||||
}
|
||||
|
||||
@for (q of actieveVragen($any(data)); track q.id) {
|
||||
<app-form-field [label]="q.vraag" [fieldId]="'vraag-' + q.id" [error]="vraagErr(q.id)">
|
||||
@if (q.type === 'ja-nee') {
|
||||
<app-radio-group [name]="'vraag-' + q.id" [options]="jaNee" [invalid]="!!vraagErr(q.id)"
|
||||
[ngModel]="draft().antwoorden[q.id] ?? ''" (ngModelChange)="dispatch({ tag: 'SetAntwoord', vraagId: q.id, value: $event })" [ngModelOptions]="{ standalone: true }" />
|
||||
} @else {
|
||||
<app-text-input [inputId]="'vraag-' + q.id" [invalid]="!!vraagErr(q.id)" [ngModel]="draft().antwoorden[q.id] ?? ''"
|
||||
(ngModelChange)="dispatch({ tag: 'SetAntwoord', vraagId: q.id, value: $event })" [ngModelOptions]="{ standalone: true }" />
|
||||
}
|
||||
</app-form-field>
|
||||
}
|
||||
</ng-template>
|
||||
<ng-template appAsyncLoading>
|
||||
<app-skeleton height="2.5rem" [count]="3" />
|
||||
</ng-template>
|
||||
</app-async>
|
||||
}
|
||||
@case ('controle') {
|
||||
<app-alert type="info">Controleer uw gegevens en dien de registratie in.</app-alert>
|
||||
<dl class="rhc-data-summary rhc-data-summary--row app-section">
|
||||
<app-data-row key="Adres" [value]="adresSamenvatting()" />
|
||||
<app-data-row key="Herkomst adres" [value]="adresHerkomstLabel()" />
|
||||
<app-data-row key="Correspondentie" [value]="correspondentieLabel()" />
|
||||
@if (draft().correspondentie === 'email') {
|
||||
<app-data-row key="E-mailadres" [value]="draft().email ?? ''" />
|
||||
}
|
||||
<app-data-row key="Beroep" [value]="draft().beroep ?? ''" />
|
||||
<app-data-row key="Herkomst diploma" [value]="diplomaHerkomstLabel()" />
|
||||
@for (item of samenvattingVragen(); track item.vraag) {
|
||||
<app-data-row [key]="item.vraag" [value]="item.antwoord" />
|
||||
}
|
||||
</dl>
|
||||
<div class="app-button-row app-section">
|
||||
<app-button type="button" variant="subtle" (click)="dispatch({ tag: 'GaNaarStap', cursor: 0 })">Adres wijzigen</app-button>
|
||||
<app-button type="button" variant="subtle" (click)="dispatch({ tag: 'GaNaarStap', cursor: 1 })">Diploma wijzigen</app-button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
<div class="app-button-row app-button-row--spaced">
|
||||
@if (cursor() > 0) {
|
||||
<app-button type="button" variant="secondary" (click)="dispatch({ tag: 'Back' })">Vorige</app-button>
|
||||
}
|
||||
<app-button type="submit" variant="primary">{{ step() === 'controle' ? 'Registratie indienen' : 'Volgende' }}</app-button>
|
||||
<app-button type="button" variant="subtle" (click)="restart()">Annuleren</app-button>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
@case ('Indienen') {
|
||||
<app-spinner /> <span>Uw registratie wordt verwerkt…</span>
|
||||
}
|
||||
@case ('Ingediend') {
|
||||
<app-alert type="ok">Uw registratie is ontvangen. Uw referentienummer is {{ referentie() }}. Bewaar dit nummer voor uw administratie.</app-alert>
|
||||
<div class="app-section">
|
||||
<app-button variant="secondary" (click)="restart()">Nieuwe registratie starten</app-button>
|
||||
</div>
|
||||
}
|
||||
@case ('Mislukt') {
|
||||
<app-alert type="error">Het indienen is niet gelukt: {{ failedError() }}</app-alert>
|
||||
<div class="app-section">
|
||||
<app-button variant="secondary" (click)="onRetry()">Opnieuw proberen</app-button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class RegistratieWizardComponent {
|
||||
private brp = inject(BrpAdapter);
|
||||
private duo = inject(DuoAdapter);
|
||||
private store = createStore<RegistratieState, RegistratieMsg>(initial, reduce);
|
||||
|
||||
protected adresRes = this.brp.adresResource();
|
||||
private diplomasRes = this.duo.diplomasResource();
|
||||
|
||||
/** Optional seed so Storybook / tests can mount any state directly. */
|
||||
seed = input<RegistratieState>(initial);
|
||||
|
||||
readonly kanalen = KANALEN;
|
||||
readonly stepLabels = ['Adres', 'Beroep', 'Controle']; // short labels for the stepper
|
||||
private stepTitles = ['Adres en correspondentievoorkeur', 'Beroep op basis van uw diploma', 'Controleren en indienen'];
|
||||
readonly state = this.store.model;
|
||||
readonly dispatch = this.store.dispatch;
|
||||
|
||||
/** Focus target: the step heading receives focus on step change (a11y). */
|
||||
private stepHeading = viewChild<ElementRef<HTMLElement>>('stepHeading');
|
||||
|
||||
private invullen = computed(() => (this.state().tag === 'Invullen' ? (this.state() as Extract<RegistratieState, { tag: 'Invullen' }>) : null));
|
||||
protected cursor = computed(() => this.invullen()?.cursor ?? 0);
|
||||
protected draft = computed<Draft>(() => this.invullen()?.draft ?? { antwoorden: {} });
|
||||
protected step = computed<StepId>(() => STEPS[Math.min(this.cursor(), STEPS.length - 1)]);
|
||||
protected stepTitle = computed(() => this.stepTitles[Math.min(this.cursor(), this.stepTitles.length - 1)]);
|
||||
protected referentie = computed(() => (this.state().tag === 'Ingediend' ? (this.state() as Extract<RegistratieState, { tag: 'Ingediend' }>).referentie : ''));
|
||||
protected failedError = computed(() => (this.state().tag === 'Mislukt' ? (this.state() as Extract<RegistratieState, { tag: 'Mislukt' }>).error : ''));
|
||||
protected adresSamenvatting = computed(() => {
|
||||
const d = this.draft();
|
||||
return [d.straat, [d.postcode, d.woonplaats].filter(Boolean).join(' ')].filter(Boolean).join(', ');
|
||||
});
|
||||
// Readable labels for the controle summary (instead of raw enum values).
|
||||
protected adresHerkomstLabel = computed(() => ({ brp: 'Automatisch uit de BRP', handmatig: 'Handmatig ingevoerd' }[this.draft().adresHerkomst ?? 'handmatig']));
|
||||
protected correspondentieLabel = computed(() => ({ email: 'Per e-mail', post: 'Per post' }[this.draft().correspondentie ?? 'post']));
|
||||
protected diplomaHerkomstLabel = computed(() => ({ duo: 'Geverifieerd via DUO', handmatig: 'Handmatig ingevoerd (wordt beoordeeld)' }[this.draft().diplomaHerkomst ?? 'handmatig']));
|
||||
|
||||
/** BRP lookup outcome. A failure or "geen adres" never blocks the wizard — both
|
||||
fall back to manual entry (PRD §7). 'geen' covers both no-address-found and a
|
||||
malformed response; 'fout' is an unreachable BRP. */
|
||||
protected adresStatus = computed<'laden' | 'gevonden' | 'geen' | 'fout'>(() => {
|
||||
const st = this.adresRes.status();
|
||||
if (st === 'loading' || st === 'reloading') return 'laden';
|
||||
if (st === 'error') return 'fout';
|
||||
const json = this.adresRes.value();
|
||||
const parsed = json !== undefined ? parseBrpAddress(json) : null;
|
||||
return parsed && parsed.ok && parsed.value.gevonden ? 'gevonden' : 'geen';
|
||||
});
|
||||
|
||||
/** The DUO lookup (diplomas + manual fallback), validated at the trust boundary. */
|
||||
protected lookupRd = computed<RemoteData<Error | undefined, DuoLookupDto>>(() => {
|
||||
const rd = fromResource(this.diplomasRes);
|
||||
if (rd.tag !== 'Success') return rd;
|
||||
const parsed = parseDuoLookup(rd.value);
|
||||
return parsed.ok ? { tag: 'Success', value: parsed.value } : { tag: 'Failure', error: new Error(parsed.error) };
|
||||
});
|
||||
|
||||
/** Parsed lookup as a plain value (or null) — used outside the beroep step (the
|
||||
controle summary) where the <app-async> template variable isn't in scope. */
|
||||
private duoData = computed<DuoLookupDto | null>(() => {
|
||||
const rd = this.lookupRd();
|
||||
return rd.tag === 'Success' ? rd.value : null;
|
||||
});
|
||||
|
||||
readonly jaNee = JA_NEE;
|
||||
|
||||
protected err = (k: DraftField | 'correspondentie' | 'diploma') => this.invullen()?.errors[k] ?? '';
|
||||
protected vraagErr = (id: string) => this.invullen()?.errors.antwoorden?.[id] ?? '';
|
||||
protected set = (key: DraftField, value: string) => this.dispatch({ tag: 'SetField', key, value });
|
||||
protected setKanaal = (value: string) => this.dispatch({ tag: 'SetCorrespondentie', value: value as Correspondentie });
|
||||
|
||||
/** True while the user is entering a diploma manually (not in the DUO list). */
|
||||
protected handmatigActief = computed(() => this.draft().diplomaHerkomst === 'handmatig');
|
||||
/** The radio selection: a diploma id, or the "not listed" sentinel in manual mode. */
|
||||
protected diplomaKeuze = computed(() => (this.handmatigActief() ? HANDMATIG : this.draft().diplomaId ?? ''));
|
||||
|
||||
protected diplomaOptions = (data: DuoLookupDto) => [
|
||||
...data.diplomas.map((d) => ({ value: d.id, label: `${d.naam} — ${d.instelling} (${d.jaar})` })),
|
||||
{ value: HANDMATIG, label: 'Mijn diploma staat er niet bij' },
|
||||
];
|
||||
|
||||
protected beroepOptions = (data: DuoLookupDto) => data.handmatig.beroepen.map((b) => ({ value: b, label: b }));
|
||||
|
||||
/** The policy questions that apply to the current choice (server-decided). */
|
||||
protected actieveVragen = (data: DuoLookupDto): PolicyQuestionDto[] => {
|
||||
if (this.handmatigActief()) return data.handmatig.policyQuestions;
|
||||
return data.diplomas.find((d) => d.id === this.draft().diplomaId)?.policyQuestions ?? [];
|
||||
};
|
||||
|
||||
/** Answered policy questions for the controle summary (question text + answer). */
|
||||
protected samenvattingVragen = computed(() => {
|
||||
const data = this.duoData();
|
||||
const d = this.draft();
|
||||
if (!data) return [] as { vraag: string; antwoord: string }[];
|
||||
const alle = [...data.diplomas.flatMap((x) => x.policyQuestions), ...data.handmatig.policyQuestions];
|
||||
return (d.vraagIds ?? []).map((id) => ({ vraag: alle.find((q) => q.id === id)?.vraag ?? id, antwoord: d.antwoorden[id] ?? '' }));
|
||||
});
|
||||
|
||||
protected onDiplomaKeuze(data: DuoLookupDto, id: string) {
|
||||
if (id === HANDMATIG) {
|
||||
this.dispatch({ tag: 'KiesHandmatig', vraagIds: data.handmatig.policyQuestions.map((q) => q.id) });
|
||||
return;
|
||||
}
|
||||
const d = data.diplomas.find((x) => x.id === id);
|
||||
if (d) this.dispatch({ tag: 'KiesDiploma', diplomaId: d.id, beroep: d.beroep, vraagIds: d.policyQuestions.map((q) => q.id) });
|
||||
}
|
||||
|
||||
constructor() {
|
||||
// An explicit seed (stories) wins; otherwise resume from localStorage.
|
||||
const seeded = this.seed();
|
||||
queueMicrotask(() => this.dispatch({ tag: 'Seed', state: seeded !== initial ? seeded : (this.restore() ?? initial) }));
|
||||
// Persist only while filling in; clear once the flow is done.
|
||||
effect(() => {
|
||||
const s = this.state();
|
||||
if (s.tag === 'Invullen') localStorage.setItem(STORAGE_KEY, JSON.stringify(s));
|
||||
else localStorage.removeItem(STORAGE_KEY);
|
||||
});
|
||||
// Prefill the address from the BRP lookup as it arrives. Track only the resource
|
||||
// value; untrack the dispatch (it reads the state signal, which would otherwise
|
||||
// make this effect loop on its own write). Don't clobber edits/restored data.
|
||||
effect(() => {
|
||||
const json = this.adresRes.value();
|
||||
if (!json) return;
|
||||
const parsed = parseBrpAddress(json);
|
||||
untracked(() => {
|
||||
const s = this.state();
|
||||
if (s.tag !== 'Invullen' || s.draft.straat) return;
|
||||
// ponytail: slice 2 handles gevonden === false -> manual entry stays handmatig.
|
||||
if (parsed.ok && parsed.value.gevonden && parsed.value.adres) {
|
||||
const a = parsed.value.adres;
|
||||
this.dispatch({ tag: 'PrefillAdres', straat: a.straat, postcode: a.postcode, woonplaats: a.woonplaats });
|
||||
}
|
||||
});
|
||||
});
|
||||
// A11y: move focus to the step heading when the STEP changes (depends only on
|
||||
// cursor(), which is value-stable across keystrokes — so typing never steals
|
||||
// focus). Skip the first run so we don't grab focus on initial load.
|
||||
let firstStep = true;
|
||||
effect(() => {
|
||||
this.cursor();
|
||||
if (firstStep) { firstStep = false; return; }
|
||||
untracked(() => {
|
||||
if (this.state().tag !== 'Invullen') return;
|
||||
queueMicrotask(() => this.stepHeading()?.nativeElement.focus());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private restore(): RegistratieState | null {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as RegistratieState;
|
||||
} catch {
|
||||
return null; // ponytail: corrupt entry -> start fresh, no migration.
|
||||
}
|
||||
}
|
||||
|
||||
onPrimary() {
|
||||
const s = this.state();
|
||||
if (s.tag !== 'Invullen') return;
|
||||
this.dispatch(this.step() === 'controle' ? { tag: 'Submit' } : { tag: 'Next' });
|
||||
this.runIfIndienen();
|
||||
}
|
||||
|
||||
onRetry() {
|
||||
this.dispatch({ tag: 'Retry' });
|
||||
this.runIfIndienen();
|
||||
}
|
||||
|
||||
/** Reset the wizard to a fresh start. Reload the BRP lookup so the address
|
||||
re-prefills, keeping the form and the "vooraf ingevuld" note consistent. */
|
||||
restart() {
|
||||
this.dispatch({ tag: 'Seed', state: initial });
|
||||
this.adresRes.reload();
|
||||
}
|
||||
|
||||
/** The effect: when we enter Indienen, call the backend, then dispatch the
|
||||
outcome (success carries the confirmation reference). */
|
||||
private async runIfIndienen() {
|
||||
const s = this.state();
|
||||
if (s.tag !== 'Indienen') return;
|
||||
const r = await submitRegistratie(s.data);
|
||||
if (r.ok) this.dispatch({ tag: 'SubmitConfirmed', referentie: r.value });
|
||||
else this.dispatch({ tag: 'SubmitFailed', error: r.error });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { applicationConfig } from '@storybook/angular';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { RegistratieWizardComponent } from './registratie-wizard.component';
|
||||
import { Draft, RegistratieState, ValidRegistratie } from '@registratie/domain/registratie-wizard.machine';
|
||||
import { Postcode } from '@registratie/domain/value-objects/postcode';
|
||||
|
||||
const adres: Partial<Draft> = { straat: 'Lange Voorhout 9', postcode: '2514 EA', woonplaats: 'Den Haag', adresHerkomst: 'brp', correspondentie: 'post' };
|
||||
const filled: Partial<Draft> = { ...adres, diplomaId: 'd1', beroep: 'Arts', diplomaHerkomst: 'duo', vraagIds: [] };
|
||||
|
||||
const invullen = (draft: Partial<Draft>, cursor = 0): RegistratieState => ({ tag: 'Invullen', draft: { antwoorden: {}, ...draft }, cursor, errors: {} });
|
||||
|
||||
const validData: ValidRegistratie = {
|
||||
adres: { straat: 'Lange Voorhout 9', postcode: '2514 EA' as Postcode, woonplaats: 'Den Haag' },
|
||||
adresHerkomst: 'brp',
|
||||
correspondentie: 'post',
|
||||
diplomaId: 'd1',
|
||||
diplomaHerkomst: 'duo',
|
||||
beroep: 'Arts',
|
||||
antwoorden: {},
|
||||
};
|
||||
|
||||
const meta: Meta<RegistratieWizardComponent> = {
|
||||
title: 'Registratie/RegistratieWizard',
|
||||
component: RegistratieWizardComponent,
|
||||
decorators: [applicationConfig({ providers: [provideHttpClient()] })],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<RegistratieWizardComponent>;
|
||||
|
||||
export const Adres: Story = { args: { seed: invullen(adres, 0) } };
|
||||
export const Beroep: Story = { args: { seed: invullen(filled, 1) } };
|
||||
/** English-language diploma → the Dutch-proficiency policy question appears. */
|
||||
export const BeroepEngelstalig: Story = { args: { seed: invullen({ ...adres, diplomaId: 'd2', beroep: 'Arts', diplomaHerkomst: 'duo', vraagIds: ['nl-taalvaardigheid'] }, 1) } };
|
||||
/** Diploma not in DUO → declare beroep + the maximal policy-question set. */
|
||||
export const BeroepHandmatig: Story = { args: { seed: invullen({ ...adres, diplomaId: 'handmatig', diplomaHerkomst: 'handmatig', vraagIds: ['nl-taalvaardigheid', 'diploma-erkend', 'toelichting'] }, 1) } };
|
||||
export const Controle: Story = { args: { seed: invullen(filled, 2) } };
|
||||
export const Indienen: Story = { args: { seed: { tag: 'Indienen', data: validData } } };
|
||||
export const Ingediend: Story = { args: { seed: { tag: 'Ingediend', data: validData, referentie: 'BIG-2026-123456' } } };
|
||||
export const Mislukt: Story = { args: { seed: { tag: 'Mislukt', data: validData, error: 'Netwerkfout' } } };
|
||||
27
src/app/registratie/ui/registratie.page.ts
Normal file
27
src/app/registratie/ui/registratie.page.ts
Normal file
@@ -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 { RegistratieWizardComponent } from '@registratie/ui/registratie-wizard/registratie-wizard.component';
|
||||
|
||||
/** Page: register in the BIG-register. Built entirely from existing building
|
||||
blocks (page shell + alert + the registratie-wizard organism). */
|
||||
@Component({
|
||||
selector: 'app-registratie-page',
|
||||
imports: [PageShellComponent, AlertComponent, RegistratieWizardComponent],
|
||||
template: `
|
||||
<app-page-shell
|
||||
heading="Inschrijven in het BIG-register"
|
||||
backLink="/dashboard"
|
||||
[breadcrumb]="[{ label: 'Mijn omgeving', link: '/dashboard' }, { label: 'Inschrijven in het BIG-register' }]">
|
||||
<app-alert type="info">
|
||||
In drie stappen schrijft u zich in: uw adres en correspondentievoorkeur, het
|
||||
diploma waarmee u zich registreert, en een controle. Uw gegevens blijven bewaard
|
||||
als u de pagina herlaadt.
|
||||
</app-alert>
|
||||
<div class="app-section">
|
||||
<app-registratie-wizard />
|
||||
</div>
|
||||
</app-page-shell>
|
||||
`,
|
||||
})
|
||||
export class RegistratiePage {}
|
||||
39
src/app/shared/layout/breadcrumb/breadcrumb.component.ts
Normal file
39
src/app/shared/layout/breadcrumb/breadcrumb.component.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
export interface BreadcrumbItem {
|
||||
label: string;
|
||||
link?: string; // omit on the current (last) page
|
||||
}
|
||||
|
||||
/** Chrome: breadcrumb navigation. Renders the RHC/Utrecht breadcrumb pattern; the
|
||||
last item is the current page (no link, aria-current). Domain-free — the caller
|
||||
supplies the trail. */
|
||||
@Component({
|
||||
selector: 'app-breadcrumb',
|
||||
imports: [RouterLink],
|
||||
styles: [`
|
||||
:host{display:block}
|
||||
.list{display:flex;flex-wrap:wrap;gap:var(--rhc-space-max-md);align-items:center;list-style:none;margin:0;padding:0}
|
||||
.sep{color:var(--rhc-color-foreground-subtle)}
|
||||
`],
|
||||
template: `
|
||||
<nav class="rhc-breadcrumb-nav utrecht-breadcrumb-nav" aria-label="Kruimelpad">
|
||||
<ol class="utrecht-breadcrumb-nav__list list">
|
||||
@for (item of items(); track item.label; let last = $last) {
|
||||
<li class="utrecht-breadcrumb-nav__item">
|
||||
@if (item.link && !last) {
|
||||
<a class="utrecht-breadcrumb-nav__link rhc-link nl-link" [routerLink]="item.link">{{ item.label }}</a>
|
||||
<span class="sep" aria-hidden="true">/</span>
|
||||
} @else {
|
||||
<span class="utrecht-breadcrumb-nav__link rhc-breadcrumb-nav__link--current" aria-current="page">{{ item.label }}</span>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
</nav>
|
||||
`,
|
||||
})
|
||||
export class BreadcrumbComponent {
|
||||
items = input.required<BreadcrumbItem[]>();
|
||||
}
|
||||
23
src/app/shared/layout/breadcrumb/breadcrumb.stories.ts
Normal file
23
src/app/shared/layout/breadcrumb/breadcrumb.stories.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { applicationConfig } from '@storybook/angular';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { BreadcrumbComponent } from './breadcrumb.component';
|
||||
|
||||
const meta: Meta<BreadcrumbComponent> = {
|
||||
title: 'Layout/Breadcrumb',
|
||||
component: BreadcrumbComponent,
|
||||
decorators: [applicationConfig({ providers: [provideRouter([])] })],
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<app-breadcrumb [items]="items" />`,
|
||||
}),
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<BreadcrumbComponent>;
|
||||
|
||||
export const TweeNiveaus: Story = {
|
||||
args: { items: [{ label: 'Mijn omgeving', link: '/dashboard' }, { label: 'Inschrijven in het BIG-register' }] },
|
||||
};
|
||||
export const DrieNiveaus: Story = {
|
||||
args: { items: [{ label: 'Mijn omgeving', link: '/dashboard' }, { label: 'Registratie', link: '/registratie' }, { label: 'Inschrijven' }] },
|
||||
};
|
||||
@@ -1,22 +1,31 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||
import { LinkComponent } from '@shared/ui/link/link.component';
|
||||
import { BreadcrumbComponent, BreadcrumbItem } from '@shared/layout/breadcrumb/breadcrumb.component';
|
||||
|
||||
/** Template: standard page body — optional back-link, a heading, optional intro,
|
||||
and projected content. Rendered inside the persistent ShellComponent via the
|
||||
router outlet, so it owns only the content (not the header/footer chrome). */
|
||||
/** Template: standard page body — optional breadcrumb, optional back-link, a
|
||||
heading, optional intro, and projected content. Rendered inside the persistent
|
||||
ShellComponent via the router outlet, so it owns only the content (not chrome). */
|
||||
@Component({
|
||||
selector: 'app-page-shell',
|
||||
imports: [HeadingComponent, LinkComponent],
|
||||
styles: [':host{display:block}'],
|
||||
imports: [HeadingComponent, LinkComponent, BreadcrumbComponent],
|
||||
styles: [`
|
||||
:host{display:block}
|
||||
.body--narrow{max-inline-size:var(--app-form-narrow)}
|
||||
.crumb{margin-block-end:var(--rhc-space-max-xl)}
|
||||
.intro{margin-block-end:var(--rhc-space-max-2xl)}
|
||||
`],
|
||||
template: `
|
||||
<div [style.max-width]="width() === 'narrow' ? '32rem' : null">
|
||||
<div [class.body--narrow]="width() === 'narrow'">
|
||||
@if (breadcrumb()) {
|
||||
<app-breadcrumb class="crumb" [items]="breadcrumb()!" />
|
||||
}
|
||||
@if (backLink()) {
|
||||
<p><app-link [to]="backLink()!">← {{ backLabel() }}</app-link></p>
|
||||
}
|
||||
<app-heading [level]="1">{{ heading() }}</app-heading>
|
||||
@if (intro()) {
|
||||
<p class="rhc-paragraph" style="margin-bottom:1.5rem">{{ intro() }}</p>
|
||||
<p class="rhc-paragraph intro">{{ intro() }}</p>
|
||||
}
|
||||
<ng-content />
|
||||
</div>
|
||||
@@ -28,4 +37,5 @@ export class PageShellComponent {
|
||||
backLink = input<string>();
|
||||
backLabel = input('Terug naar overzicht');
|
||||
width = input<'default' | 'narrow'>('default');
|
||||
breadcrumb = input<BreadcrumbItem[]>();
|
||||
}
|
||||
|
||||
@@ -8,13 +8,19 @@ import { SiteFooterComponent } from '@shared/layout/site-footer/site-footer.comp
|
||||
@Component({
|
||||
selector: 'app-shell',
|
||||
imports: [RouterOutlet, SiteHeaderComponent, SiteFooterComponent],
|
||||
styles: [':host{display:block}'],
|
||||
styles: [`
|
||||
:host{display:block}
|
||||
.skip{position:absolute;left:var(--app-skip-link-offset)}
|
||||
.layout{min-block-size:100vh;align-items:stretch}
|
||||
.main{flex:1;inline-size:100%;box-sizing:border-box}
|
||||
.content{max-inline-size:var(--app-content-max);margin-inline:auto;padding:var(--rhc-space-max-3xl) var(--rhc-space-max-2xl);box-sizing:border-box}
|
||||
`],
|
||||
template: `
|
||||
<a href="#main" class="rhc-skip-link" style="position:absolute;left:-999px">Naar de inhoud</a>
|
||||
<div class="utrecht-page-layout utrecht-page-layout--stretch" style="--app-content-max:64rem;min-height:100vh;align-items:stretch">
|
||||
<a href="#main" class="rhc-skip-link skip">Naar de inhoud</a>
|
||||
<div class="utrecht-page-layout utrecht-page-layout--stretch layout">
|
||||
<app-site-header />
|
||||
<main id="main" class="utrecht-page-content" style="flex:1;width:100%;box-sizing:border-box">
|
||||
<div style="max-width:var(--app-content-max);margin:0 auto;padding:2rem 1.5rem;box-sizing:border-box">
|
||||
<main id="main" class="utrecht-page-content main">
|
||||
<div class="content">
|
||||
<router-outlet />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -3,13 +3,18 @@ import { Component } from '@angular/core';
|
||||
/** Organism: site footer. */
|
||||
@Component({
|
||||
selector: 'app-site-footer',
|
||||
styles: [':host{display:block}'],
|
||||
styles: [`
|
||||
:host{display:block}
|
||||
.bar{background:var(--rhc-color-donkerblauw-700);color:var(--rhc-color-wit);margin-block-start:var(--rhc-space-max-5xl);inline-size:100%}
|
||||
.inner{max-inline-size:var(--app-content-max);margin-inline:auto;padding:var(--rhc-space-max-2xl);display:flex;gap:var(--rhc-space-max-3xl);flex-wrap:wrap;box-sizing:border-box}
|
||||
.end{margin-inline-start:auto}
|
||||
`],
|
||||
template: `
|
||||
<footer class="utrecht-page-footer" style="background:var(--rhc-color-lintblauw-900,#01689b);color:#fff;margin-top:3rem;width:100%">
|
||||
<div style="max-width:var(--app-content-max);margin:0 auto;padding:1.5rem;display:flex;gap:2rem;flex-wrap:wrap;box-sizing:border-box">
|
||||
<footer class="utrecht-page-footer bar">
|
||||
<div class="inner">
|
||||
<span>BIG-register</span>
|
||||
<span>CIBG — Ministerie van Volksgezondheid, Welzijn en Sport</span>
|
||||
<span style="margin-left:auto;opacity:0.85">Demo / POC — geen echte gegevens</span>
|
||||
<span class="end">Demo / POC — geen echte gegevens</span>
|
||||
</div>
|
||||
</footer>
|
||||
`,
|
||||
|
||||
@@ -8,17 +8,26 @@ import { RouterLink } from '@angular/router';
|
||||
imports: [RouterLink],
|
||||
// :host display:block so the full-bleed bar fills the flex column (custom
|
||||
// elements default to display:inline, which collapsed the bar to its content).
|
||||
styles: [':host{display:block}'],
|
||||
styles: [`
|
||||
:host{display:block}
|
||||
.bar{background:var(--rhc-color-lintblauw-700);color:var(--rhc-color-wit);inline-size:100%}
|
||||
.inner{display:flex;align-items:center;gap:var(--rhc-space-max-xl);max-inline-size:var(--app-content-max);margin-inline:auto;padding:var(--rhc-space-max-xl) var(--rhc-space-max-2xl);box-sizing:border-box}
|
||||
.brand{display:flex;align-items:center;gap:var(--rhc-space-max-lg);color:inherit;text-decoration:none}
|
||||
.mark{display:inline-block;inline-size:var(--rhc-space-max-md);block-size:var(--rhc-space-max-4xl);background:var(--rhc-color-wit)}
|
||||
.name{font-weight:var(--rhc-text-font-weight-bold);line-height:var(--rhc-text-line-height-sm)}
|
||||
.sub{font-weight:var(--rhc-text-font-weight-regular);font-size:var(--rhc-text-font-size-sm)}
|
||||
.portal{margin-inline-start:auto;font-weight:var(--rhc-text-font-weight-semi-bold)}
|
||||
`],
|
||||
template: `
|
||||
<header class="utrecht-page-header" style="background:var(--rhc-color-lintblauw-700,#154273);color:#fff;width:100%">
|
||||
<div style="display:flex;align-items:center;gap:1rem;max-width:var(--app-content-max);margin:0 auto;padding:1rem 1.5rem;box-sizing:border-box">
|
||||
<a routerLink="/dashboard" style="display:flex;align-items:center;gap:0.75rem;color:inherit;text-decoration:none">
|
||||
<span aria-hidden="true" style="display:inline-block;width:0.5rem;height:2.25rem;background:#fff"></span>
|
||||
<span style="font-weight:700;line-height:1.1">
|
||||
Rijksoverheid<br><span style="font-weight:400;font-size:0.9rem">BIG-register</span>
|
||||
<header class="utrecht-page-header bar">
|
||||
<div class="inner">
|
||||
<a routerLink="/dashboard" class="brand">
|
||||
<span aria-hidden="true" class="mark"></span>
|
||||
<span class="name">
|
||||
Rijksoverheid<br><span class="sub">BIG-register</span>
|
||||
</span>
|
||||
</a>
|
||||
<span style="margin-left:auto;font-weight:500">{{ subtitle() }}</span>
|
||||
<span class="portal">{{ subtitle() }}</span>
|
||||
</div>
|
||||
</header>
|
||||
`,
|
||||
|
||||
@@ -35,6 +35,7 @@ export class AsyncErrorDirective {
|
||||
selector: 'app-async',
|
||||
imports: [NgTemplateOutlet, SpinnerComponent, AlertComponent, ButtonComponent],
|
||||
template: `
|
||||
<div aria-live="polite" [attr.aria-busy]="rd().tag === 'Loading' ? 'true' : null">
|
||||
@switch (rd().tag) {
|
||||
@case ('Loading') {
|
||||
@if (loadingTpl()) { <ng-container [ngTemplateOutlet]="loadingTpl()!.tpl" /> }
|
||||
@@ -60,6 +61,7 @@ export class AsyncErrorDirective {
|
||||
[ngTemplateOutletContext]="{ $implicit: value() }" />
|
||||
}
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class AsyncComponent<T> {
|
||||
|
||||
@@ -6,13 +6,13 @@ import { Component, input } from '@angular/core';
|
||||
selector: 'app-form-field',
|
||||
template: `
|
||||
<div class="rhc-form-field utrecht-form-field" [class.utrecht-form-field--invalid]="!!error()">
|
||||
<label class="utrecht-form-label" [for]="fieldId()">{{ label() }}</label>
|
||||
<label class="utrecht-form-label" [id]="fieldId() + '-label'" [for]="fieldId()">{{ label() }}</label>
|
||||
@if (description()) {
|
||||
<div class="utrecht-form-field-description">{{ description() }}</div>
|
||||
<div class="utrecht-form-field-description" [id]="fieldId() + '-desc'">{{ description() }}</div>
|
||||
}
|
||||
<ng-content />
|
||||
@if (error()) {
|
||||
<div class="utrecht-form-field-error-message" role="alert">{{ error() }}</div>
|
||||
<div class="utrecht-form-field-error-message" [id]="fieldId() + '-error'" role="alert">{{ error() }}</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
|
||||
@@ -10,10 +10,23 @@ export interface RadioOption {
|
||||
form control so it works with ngModel just like the text-input atom. */
|
||||
@Component({
|
||||
selector: 'app-radio-group',
|
||||
styles: [`
|
||||
.rhc-radio-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--rhc-space-max-md);
|
||||
padding-block: var(--rhc-space-max-sm);
|
||||
}
|
||||
`],
|
||||
template: `
|
||||
<div class="utrecht-form-field-radio-group" role="radiogroup">
|
||||
<div
|
||||
class="utrecht-form-field-radio-group"
|
||||
role="radiogroup"
|
||||
[attr.aria-labelledby]="name() + '-label'"
|
||||
[attr.aria-invalid]="invalid() ? 'true' : null"
|
||||
[attr.aria-describedby]="invalid() ? name() + '-error' : null">
|
||||
@for (opt of options(); track opt.value) {
|
||||
<label class="utrecht-form-label utrecht-form-label--radio-button" style="display:flex;align-items:center;gap:0.5rem;padding:0.25rem 0">
|
||||
<label class="utrecht-form-label utrecht-form-label--radio-button rhc-radio-option">
|
||||
<input
|
||||
class="utrecht-radio-button"
|
||||
[class.utrecht-radio-button--checked]="value === opt.value"
|
||||
@@ -34,6 +47,7 @@ export interface RadioOption {
|
||||
export class RadioGroupComponent implements ControlValueAccessor {
|
||||
options = input.required<RadioOption[]>();
|
||||
name = input.required<string>();
|
||||
invalid = input(false);
|
||||
|
||||
value = '';
|
||||
disabled = false;
|
||||
|
||||
@@ -6,15 +6,15 @@ import { Component, OnDestroy, OnInit, computed, input, signal } from '@angular/
|
||||
selector: 'app-skeleton',
|
||||
styles: [`
|
||||
:host{display:block}
|
||||
.sk{background:linear-gradient(90deg,#e8ebee 25%,#f3f5f6 37%,#e8ebee 63%);
|
||||
background-size:400% 100%;animation:sh 1.4s ease infinite;border-radius:4px;
|
||||
margin-block-end:0.6rem}
|
||||
.sk{background:linear-gradient(90deg,var(--rhc-color-cool-grey-200) 25%,var(--rhc-color-cool-grey-100) 37%,var(--rhc-color-cool-grey-200) 63%);
|
||||
background-size:400% 100%;animation:sh 1.4s ease infinite;border-radius:var(--rhc-border-radius-md);
|
||||
margin-block-end:var(--rhc-space-max-lg)}
|
||||
@keyframes sh{0%{background-position:100% 0}100%{background-position:0 0}}
|
||||
`],
|
||||
template: `
|
||||
@if (visible()) {
|
||||
@for (l of lines(); track $index) {
|
||||
<div class="sk" [style.width]="width()" [style.height]="height()"></div>
|
||||
<div class="sk" aria-hidden="true" [style.width]="width()" [style.height]="height()"></div>
|
||||
}
|
||||
}
|
||||
`,
|
||||
|
||||
@@ -6,10 +6,10 @@ import { Component, OnDestroy, OnInit, input, signal } from '@angular/core';
|
||||
selector: 'app-spinner',
|
||||
styles: [`
|
||||
:host{display:block}
|
||||
.sp{width:2rem;height:2rem;border-radius:50%;
|
||||
border:3px solid var(--rhc-color-grijs-300,#cad0d6);
|
||||
border-block-start-color:var(--rhc-color-lintblauw-700,#154273);
|
||||
animation:sp 0.8s linear infinite;margin:1.5rem auto}
|
||||
.sp{inline-size:var(--rhc-space-max-3xl);block-size:var(--rhc-space-max-3xl);border-radius:var(--rhc-border-radius-round);
|
||||
border:var(--rhc-space-max-xs) solid var(--rhc-color-cool-grey-300);
|
||||
border-block-start-color:var(--rhc-color-lintblauw-700);
|
||||
animation:sp 0.8s linear infinite;margin:var(--rhc-space-max-2xl) auto}
|
||||
@keyframes sp{to{transform:rotate(360deg)}}
|
||||
`],
|
||||
template: `
|
||||
|
||||
@@ -5,8 +5,9 @@ import { Component, input } from '@angular/core';
|
||||
This keeps the shared UI kernel free of any domain knowledge. */
|
||||
@Component({
|
||||
selector: 'app-status-badge',
|
||||
styles: [`.badge{display:inline-flex;align-items:center;gap:var(--rhc-space-max-md)}`],
|
||||
template: `
|
||||
<span style="display:inline-flex;align-items:center;gap:0.5rem">
|
||||
<span class="badge">
|
||||
<span class="rhc-dot-badge" [style.background-color]="color()" aria-hidden="true"></span>
|
||||
<span>{{ label() }}</span>
|
||||
</span>
|
||||
|
||||
46
src/app/shared/ui/stepper/stepper.component.ts
Normal file
46
src/app/shared/ui/stepper/stepper.component.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
|
||||
/** Molecule: a horizontal step indicator for a multi-step flow. Visual progress
|
||||
plus accessible semantics — an ordered list with `aria-current="step"` on the
|
||||
active step and a visually-hidden "Stap X van Y" summary. Domain-free. */
|
||||
@Component({
|
||||
selector: 'app-stepper',
|
||||
styles: [`
|
||||
:host{display:block}
|
||||
.sr-only{position:absolute;inline-size:1px;block-size:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
|
||||
.steps{display:flex;flex-wrap:wrap;gap:var(--rhc-space-max-xl);list-style:none;margin:0;padding:0}
|
||||
.step{display:flex;align-items:center;gap:var(--rhc-space-max-md);color:var(--rhc-color-foreground-subtle)}
|
||||
.num{
|
||||
display:grid;place-items:center;
|
||||
inline-size:var(--rhc-space-max-4xl);block-size:var(--rhc-space-max-4xl);
|
||||
border-radius:var(--rhc-border-radius-round);
|
||||
border:var(--rhc-space-max-xs) solid var(--rhc-color-cool-grey-400);
|
||||
font-weight:var(--rhc-text-font-weight-bold);
|
||||
}
|
||||
.step--done .num{background:var(--rhc-color-lintblauw-500);border-color:var(--rhc-color-lintblauw-500);color:var(--rhc-color-wit)}
|
||||
.step--current{color:var(--rhc-color-foreground-default)}
|
||||
.step--current .num{background:var(--rhc-color-lintblauw-700);border-color:var(--rhc-color-lintblauw-700);color:var(--rhc-color-wit)}
|
||||
.step--current .label{font-weight:var(--rhc-text-font-weight-semi-bold)}
|
||||
`],
|
||||
template: `
|
||||
<nav aria-label="Voortgang">
|
||||
<p class="sr-only">Stap {{ current() + 1 }} van {{ steps().length }}: {{ steps()[current()] }}</p>
|
||||
<ol class="steps">
|
||||
@for (label of steps(); track label; let i = $index) {
|
||||
<li
|
||||
class="step"
|
||||
[class.step--current]="i === current()"
|
||||
[class.step--done]="i < current()"
|
||||
[attr.aria-current]="i === current() ? 'step' : null">
|
||||
<span class="num" aria-hidden="true">{{ i + 1 }}</span>
|
||||
<span class="label">{{ label }}</span>
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
</nav>
|
||||
`,
|
||||
})
|
||||
export class StepperComponent {
|
||||
steps = input.required<string[]>();
|
||||
current = input.required<number>();
|
||||
}
|
||||
19
src/app/shared/ui/stepper/stepper.stories.ts
Normal file
19
src/app/shared/ui/stepper/stepper.stories.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { StepperComponent } from './stepper.component';
|
||||
|
||||
const meta: Meta<StepperComponent> = {
|
||||
title: 'Molecules/Stepper',
|
||||
component: StepperComponent,
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<app-stepper [steps]="steps" [current]="current" />`,
|
||||
}),
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<StepperComponent>;
|
||||
|
||||
const steps = ['Adres', 'Beroep', 'Controle'];
|
||||
|
||||
export const Eerste: Story = { args: { steps, current: 0 } };
|
||||
export const Midden: Story = { args: { steps, current: 1 } };
|
||||
export const Laatste: Story = { args: { steps, current: 2 } };
|
||||
@@ -10,6 +10,8 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||
[class.utrecht-textbox--invalid]="invalid()"
|
||||
[type]="type()"
|
||||
[id]="inputId()"
|
||||
[attr.aria-invalid]="invalid() ? 'true' : null"
|
||||
[attr.aria-describedby]="invalid() && inputId() ? inputId() + '-error' : null"
|
||||
[placeholder]="placeholder()"
|
||||
[disabled]="disabled"
|
||||
[value]="value"
|
||||
|
||||
@@ -142,24 +142,24 @@ function fakeResource<T>(status: string, value?: T, error?: Error): Resource<T>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 5. Branching: steps derived from answers -->
|
||||
<!-- 5. Fixed steps, questions revealed inline -->
|
||||
<section class="section">
|
||||
<app-heading [level]="2">5 · Vertakkende vragenlijst — "afleiden, niet opslaan"</app-heading>
|
||||
<app-heading [level]="2">5 · Vragenlijst met vaste stappen — "vragen tonen, niet stappen toevoegen"</app-heading>
|
||||
<p class="lead">
|
||||
Welke stappen bestaan is geen opgeslagen toestand maar een <em>pure functie</em> van de antwoorden
|
||||
(<code>visibleSteps(answers)</code>). Antwoord "ja" op buitenland of vul weinig uren in, en de
|
||||
lijst hieronder groeit mee.
|
||||
Het aantal stappen ligt vast (<code>STEPS</code>); vervolgvragen verschijnen <em>binnen</em> een stap
|
||||
op basis van eerdere antwoorden. Antwoord "ja" op buitenland of vul weinig uren in, en er komt een
|
||||
extra vraag bij in dezelfde stap — de voortgang "van N" blijft gelijk.
|
||||
</p>
|
||||
<div class="cols">
|
||||
<div class="card card--good">
|
||||
<p class="tag good">Live afgeleide stappen</p>
|
||||
<p class="tag good">Vaste stappen</p>
|
||||
<div class="steplist">
|
||||
@for (s of iw.steps(); track s; let last = $last) {
|
||||
<span class="pill" [class.extra]="s === 'buitenlandDetails' || s === 'scholing'">{{ s }}</span>
|
||||
@for (s of iw.steps; track s; let last = $last) {
|
||||
<span class="pill">{{ s }}</span>
|
||||
@if (!last) { <span class="arrow">→</span> }
|
||||
}
|
||||
</div>
|
||||
<p class="note">De gestippelde stappen verschijnen alleen door eerdere antwoorden. De voortgang "van N" verandert mee.</p>
|
||||
<p class="note">De stappen zijn altijd dezelfde; alleen de vragen <em>binnen</em> een stap verschijnen of verdwijnen.</p>
|
||||
</div>
|
||||
<div class="card card--good">
|
||||
<p class="tag good">De wizard</p>
|
||||
|
||||
Reference in New Issue
Block a user