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:
@@ -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