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:
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' } } };
|
||||
|
||||
Reference in New Issue
Block a user