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