Restructure into DDD bounded contexts + functional state management

Reorganise from atomic-design-only folders into bounded contexts
(auth / registratie / herregistratie) over a shared kernel, each split into
domain / application / infrastructure / ui layers. Dependencies point inward;
the domain layer is framework-free. Path aliases (@shared/@auth/@registratie/
@herregistratie) make import direction explicit.

State management (Elm-style, native TS, no new deps):
- shared/application/store.ts — createStore(init, update): pure reducer + signal
- shared/application/remote-data.ts — add map/map2/map3/andThen combinators so
  several services fold into one RemoteData; <app-async> gains an [rd] input
- registratie/application/big-profile.store.ts — root singleton combining the
  BIG-register and BRP services via map2 into one state; holds the optimistic
  herregistratie flag shared with the dashboard
- herregistratie: machine gains a WizardMsg union + pure reduce; submit is a
  command that calls infra and dispatches the result, with optimistic update +
  rollback against the shared store
- auth: SessionStore + DigiD adapter + functional route guard; login establishes
  the session, protected routes use canActivate

Rich domain: registration.policy.ts (statusColor/label, herregistratie
eligibility, invariants); BigNummer/Postcode/Uren value objects with smart
constructors. status-badge is now domain-free (colour/label inputs).

Specs for the reducer, RemoteData combinators, and eligibility policy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 07:20:13 +02:00
parent 6bd6e854c7
commit 2114514ad7
74 changed files with 841 additions and 347 deletions

9
public/mock/brp.json Normal file
View File

@@ -0,0 +1,9 @@
{
"naam": "Dr. A. (Anna) de Vries",
"geboortedatum": "1985-03-14",
"adres": {
"straat": "Lange Voorhout 9",
"postcode": "2514 EA",
"woonplaats": "Den Haag"
}
}

View File

@@ -5,7 +5,7 @@ import { registerLocaleData } from '@angular/common';
import localeNl from '@angular/common/locales/nl'; import localeNl from '@angular/common/locales/nl';
import { routes } from './app.routes'; import { routes } from './app.routes';
import { scenarioInterceptor } from './core/scenario.interceptor'; import { scenarioInterceptor } from '@shared/infrastructure/scenario.interceptor';
registerLocaleData(localeNl); registerLocaleData(localeNl);

View File

@@ -1,5 +1,6 @@
import { Routes } from '@angular/router'; import { Routes } from '@angular/router';
import { ShellComponent } from './templates/shell/shell.component'; import { ShellComponent } from '@shared/layout/shell/shell.component';
import { authGuard } from '@auth/auth.guard';
export const routes: Routes = [ export const routes: Routes = [
{ {
@@ -7,11 +8,11 @@ export const routes: Routes = [
component: ShellComponent, // persistent header/footer; only children swap component: ShellComponent, // persistent header/footer; only children swap
children: [ children: [
{ path: '', pathMatch: 'full', redirectTo: 'login' }, { path: '', pathMatch: 'full', redirectTo: 'login' },
{ path: 'login', loadComponent: () => import('./pages/login/login.page').then(m => m.LoginPage) }, { path: 'login', loadComponent: () => import('@auth/ui/login.page').then(m => m.LoginPage) },
{ path: 'dashboard', loadComponent: () => import('./pages/dashboard/dashboard.page').then(m => m.DashboardPage) }, { path: 'dashboard', canActivate: [authGuard], loadComponent: () => import('@registratie/ui/dashboard.page').then(m => m.DashboardPage) },
{ path: 'registratie', loadComponent: () => import('./pages/registration-detail/registration-detail.page').then(m => m.RegistrationDetailPage) }, { path: 'registratie', canActivate: [authGuard], loadComponent: () => import('@registratie/ui/registration-detail.page').then(m => m.RegistrationDetailPage) },
{ path: 'herregistratie', loadComponent: () => import('./pages/herregistratie/herregistratie.page').then(m => m.HerregistratiePage) }, { path: 'herregistratie', canActivate: [authGuard], loadComponent: () => import('@herregistratie/ui/herregistratie.page').then(m => m.HerregistratiePage) },
{ path: 'concepts', loadComponent: () => import('./pages/concepts/concepts.page').then(m => m.ConceptsPage) }, { path: 'concepts', loadComponent: () => import('./showcase/concepts.page').then(m => m.ConceptsPage) },
{ path: '**', redirectTo: 'login' }, { path: '**', redirectTo: 'login' },
], ],
}, },

View File

@@ -1,32 +0,0 @@
import { Component, computed, input } from '@angular/core';
import { StatusTag } from '../../core/models';
import { assertNever } from '../../core/fp';
/** Atom: status badge = colored RHC dot-badge + label. The color is a total
function of the status tag — assertNever makes a new status fail to compile
until a color is chosen for it. */
@Component({
selector: 'app-status-badge',
template: `
<span style="display:inline-flex;align-items:center;gap:0.5rem">
<span class="rhc-dot-badge" [style.background-color]="color()" aria-hidden="true"></span>
<span>{{ status() }}</span>
</span>
`,
})
export class StatusBadgeComponent {
status = input.required<StatusTag>();
color = computed(() => {
const tag = this.status();
switch (tag) {
case 'Geregistreerd':
return 'var(--rhc-color-groen-500)';
case 'Doorgehaald':
return 'var(--rhc-color-rood-500)';
case 'Geschorst':
return 'var(--rhc-color-oranje-500)';
default:
return assertNever(tag);
}
});
}

View File

@@ -1,13 +0,0 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { StatusBadgeComponent } from './status-badge.component';
const meta: Meta<StatusBadgeComponent> = {
title: 'Atoms/Status Badge',
component: StatusBadgeComponent,
};
export default meta;
type Story = StoryObj<StatusBadgeComponent>;
export const Geregistreerd: Story = { args: { status: 'Geregistreerd' } };
export const Geschorst: Story = { args: { status: 'Geschorst' } };
export const Doorgehaald: Story = { args: { status: 'Doorgehaald' } };

View File

@@ -0,0 +1,30 @@
import { Injectable, computed, inject, signal } from '@angular/core';
import { Result } from '@shared/kernel/fp';
import { Session } from '../domain/session';
import { DigidAdapter } from '../infrastructure/digid.adapter';
/**
* Holds the current session for the whole app. Because it is providedIn:'root'
* there is exactly one instance — every component that injects it sees the same
* session signal, so logging in is instantly visible everywhere (the guard, the
* header, etc.). ponytail: in-memory only; a refresh logs you out.
*/
@Injectable({ providedIn: 'root' })
export class SessionStore {
private digid = inject(DigidAdapter);
private _session = signal<Session | null>(null);
readonly session = this._session.asReadonly();
readonly isAuthenticated = computed(() => this._session() !== null);
/** Effectful command: authenticate, then store the session on success. */
async login(bsn: string): Promise<Result<string, Session>> {
const r = await this.digid.authenticate(bsn);
if (r.ok) this._session.set(r.value);
return r;
}
logout() {
this._session.set(null);
}
}

View File

@@ -0,0 +1,10 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { SessionStore } from './application/session.store';
/** Route guard: only let authenticated users in; otherwise redirect to /login. */
export const authGuard: CanActivateFn = () => {
const store = inject(SessionStore);
const router = inject(Router);
return store.isAuthenticated() ? true : router.createUrlTree(['/login']);
};

View File

@@ -0,0 +1,9 @@
/** Who is logged in. Framework-free domain type. */
export interface Session {
readonly bsn: string;
readonly naam: string;
}
export function isAuthenticated(s: Session | null): s is Session {
return s !== null;
}

View File

@@ -0,0 +1,15 @@
import { Injectable } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp';
import { Session } from '../domain/session';
/** Infrastructure: talks to the (mock) DigiD identity provider. */
@Injectable({ providedIn: 'root' })
export class DigidAdapter {
// ponytail: fake DigiD — any 9-digit BSN authenticates to a fixed identity.
// Swap for a real OIDC redirect flow when there's a backend.
async authenticate(bsn: string): Promise<Result<string, Session>> {
const t = bsn.trim();
if (!/^\d{9}$/.test(t)) return err('Voer een geldig BSN van 9 cijfers in.');
return ok({ bsn: t, naam: 'Dr. A. (Anna) de Vries' });
}
}

View File

@@ -1,15 +1,15 @@
import { Component, output } from '@angular/core'; import { Component, output } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { FormFieldComponent } from '../../molecules/form-field/form-field.component'; import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
import { TextInputComponent } from '../../atoms/text-input/text-input.component'; import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
import { ButtonComponent } from '../../atoms/button/button.component'; import { ButtonComponent } from '@shared/ui/button/button.component';
/** Organism: DigiD-style mock login. No real auth — just composes atoms/molecules. */ /** Organism: DigiD-style mock login. No real auth — just composes atoms/molecules. */
@Component({ @Component({
selector: 'app-login-form', selector: 'app-login-form',
imports: [FormsModule, FormFieldComponent, TextInputComponent, ButtonComponent], imports: [FormsModule, FormFieldComponent, TextInputComponent, ButtonComponent],
template: ` template: `
<form (ngSubmit)="submit.emit()"> <form (ngSubmit)="submit.emit(bsn)">
<app-form-field label="BSN" fieldId="bsn" description="9 cijfers (demo: vul iets in)"> <app-form-field label="BSN" fieldId="bsn" description="9 cijfers (demo: vul iets in)">
<app-text-input inputId="bsn" [(ngModel)]="bsn" name="bsn" placeholder="123456789" /> <app-text-input inputId="bsn" [(ngModel)]="bsn" name="bsn" placeholder="123456789" />
</app-form-field> </app-form-field>
@@ -27,5 +27,5 @@ import { ButtonComponent } from '../../atoms/button/button.component';
export class LoginFormComponent { export class LoginFormComponent {
bsn = ''; bsn = '';
password = ''; password = '';
submit = output<void>(); submit = output<string>();
} }

View File

@@ -0,0 +1,29 @@
import { Component, inject, signal } from '@angular/core';
import { Router } from '@angular/router';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { LoginFormComponent } from '@auth/ui/login-form/login-form.component';
import { SessionStore } from '@auth/application/session.store';
@Component({
selector: 'app-login-page',
imports: [PageShellComponent, AlertComponent, LoginFormComponent],
template: `
<app-page-shell heading="Inloggen" width="narrow"
intro="Log in op uw persoonlijke BIG-register omgeving.">
@if (error()) { <app-alert type="error">{{ error() }}</app-alert> }
<app-login-form (submit)="login($event)" />
</app-page-shell>
`,
})
export class LoginPage {
private store = inject(SessionStore);
private router = inject(Router);
error = signal('');
async login(bsn: string) {
const r = await this.store.login(bsn);
if (r.ok) this.router.navigate(['/dashboard']);
else this.error.set(r.error);
}
}

View File

@@ -1,29 +0,0 @@
import { Brand, Result, ok, err } from './fp';
/**
* "Parse, don't validate." A validated value gets its own branded type, and the
* smart constructor is the ONLY way to mint one. So once you hold a Postcode you
* know it's well-formed — validity is carried in the type, not re-checked
* everywhere or tracked in a parallel error flag.
*/
export type Postcode = Brand<string, 'Postcode'>;
export type Uren = Brand<number, 'Uren'>;
export function parsePostcode(raw: string): Result<string, Postcode> {
const t = raw.trim().toUpperCase();
if (!/^[1-9]\d{3}\s?[A-Z]{2}$/.test(t)) {
return err('Voer een geldige postcode in, bijv. 1234 AB.');
}
// Normalise to "1234 AB" — the parser also cleans up.
return ok(t.replace(/^(\d{4})\s?([A-Z]{2})$/, '$1 $2') as Postcode);
}
export function parseUren(raw: string): Result<string, Uren> {
const t = raw.trim();
const n = Number(t);
// Number('') is 0 — guard the empty string explicitly.
if (t === '' || !Number.isInteger(n) || n < 0) {
return err('Vul een geheel aantal in (0 of meer).');
}
return ok(n as Uren);
}

View File

@@ -1,20 +0,0 @@
import { Injectable } from '@angular/core';
import { httpResource } from '@angular/common/http';
import { Registration, Aantekening } from './models';
/**
* Exposes signal-based resources (Angular's httpResource). Each returns a
* Resource with status()/value()/error()/reload() — consumed by <app-async>,
* which turns those signals into the loading/empty/error/loaded UI.
* Call these from a component field initializer (injection context required).
*/
@Injectable({ providedIn: 'root' })
export class RegistrationService {
registrationResource() {
return httpResource<Registration>(() => 'mock/registration.json');
}
aantekeningenResource() {
return httpResource<Aantekening[]>(() => 'mock/notes.json', { defaultValue: [] });
}
}

View File

@@ -1,47 +0,0 @@
import type { Resource } from '@angular/core';
import { assertNever } from './fp';
/**
* The four mutually-exclusive states of an async fetch, as a tagged union.
* Crucially the data lives ON the state: only `Failure` has an `error`, only
* `Success` has a `value`. "Loaded but no value" or "error with stale value"
* are unrepresentable — Richard Feldman's RemoteData.
*/
export type RemoteData<E, T> =
| { tag: 'Loading' }
| { tag: 'Empty' }
| { tag: 'Failure'; error: E }
| { tag: 'Success'; value: T };
/** Project Angular's loosely-typed Resource into a RemoteData value. */
export function fromResource<T>(
r: Resource<T>,
isEmpty: (v: T) => boolean = () => false,
): RemoteData<Error | undefined, T> {
if (r.status() === 'error') return { tag: 'Failure', error: r.error() };
if (r.status() === 'loading') return { tag: 'Loading' };
if (r.hasValue()) {
const v = r.value();
return isEmpty(v) ? { tag: 'Empty' } : { tag: 'Success', value: v };
}
return { tag: 'Loading' };
}
/** Exhaustive fold: you must handle every case, checked at compile time. */
export function foldRemote<E, T, R>(
rd: RemoteData<E, T>,
h: { loading: () => R; empty: () => R; failure: (e: E) => R; success: (v: T) => R },
): R {
switch (rd.tag) {
case 'Loading':
return h.loading();
case 'Empty':
return h.empty();
case 'Failure':
return h.failure(rd.error);
case 'Success':
return h.success(rd.value);
default:
return assertNever(rd);
}
}

View File

@@ -0,0 +1,14 @@
import { Result, ok, err } from '@shared/kernel/fp';
import { Valid } from '../domain/herregistratie.machine';
/**
* The mutation/command: send a herregistratie application to the backend.
* Returns a Result so the caller can branch on success/failure without
* try/catch. ponytail: faked with a timer; swap for a real POST when there's
* an API. The "uren must be > 0" rule lets the demo show a failure path.
*/
export async function submitHerregistratie(data: Valid): Promise<Result<string, void>> {
await new Promise((r) => setTimeout(r, 800));
if (data.uren === 0) return err('Aanvraag afgewezen: geen gewerkte uren geregistreerd.');
return ok(undefined);
}

View File

@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { ok, err } from '../../core/fp'; import { ok, err } from '@shared/kernel/fp';
import { initial, next, back, submit, resolve, WizardState } from './wizard.machine'; import { initial, next, back, submit, resolve, reduce, WizardState } from './herregistratie.machine';
const editing1 = (uren: string, punten = ''): WizardState => ({ tag: 'Editing', step: 1, draft: { uren, punten }, errors: {} }); const editing1 = (uren: string, punten = ''): WizardState => ({ tag: 'Editing', step: 1, draft: { uren, punten }, errors: {} });
const editing2 = (uren: string, punten: string): WizardState => ({ tag: 'Editing', step: 2, draft: { uren, punten }, errors: {} }); const editing2 = (uren: string, punten: string): WizardState => ({ tag: 'Editing', step: 2, draft: { uren, punten }, errors: {} });
@@ -30,3 +30,29 @@ describe('wizard.machine', () => {
expect(resolve(submitting, err('boom')).tag).toBe('Failed'); expect(resolve(submitting, err('boom')).tag).toBe('Failed');
}); });
}); });
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: 'Next' });
expect(s.tag === 'Editing' && s.step).toBe(2);
s = reduce(s, { tag: 'SetField', key: 'punten', value: '200' });
s = reduce(s, { tag: 'Submit' });
expect(s.tag).toBe('Submitting');
s = reduce(s, { tag: 'SubmitConfirmed' });
expect(s.tag).toBe('Submitted');
});
it('SubmitFailed then Retry returns to Submitting with the same data', () => {
let s = reduce(reduce(editing2('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, punten: 200 });
});
it('Seed mounts an arbitrary state', () => {
expect(reduce(initial, { tag: 'Seed', state: editing2('1', '2') }).tag).toBe('Editing');
});
});

View File

@@ -1,5 +1,5 @@
import { Result } from '../../core/fp'; import { Result, assertNever } from '@shared/kernel/fp';
import { Uren, parseUren } from '../../core/parse'; import { Uren, parseUren } from '@registratie/domain/value-objects/uren';
/** What the user is typing (raw, possibly invalid). */ /** What the user is typing (raw, possibly invalid). */
export interface Draft { export interface Draft {
@@ -64,3 +64,47 @@ export function resolve(s: WizardState, r: Result<string, void>): WizardState {
if (s.tag !== 'Submitting') return s; if (s.tag !== 'Submitting') return s;
return r.ok ? { tag: 'Submitted', data: s.data } : { tag: 'Failed', data: s.data, error: r.error }; 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: 'Submit' }
| { tag: 'Retry' }
| { tag: 'SubmitConfirmed' }
| { tag: 'SubmitFailed'; error: string }
| { 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 '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 'Seed':
return m.state;
default:
return assertNever(m);
}
}

View File

@@ -1,17 +1,21 @@
import { Component, computed, input, signal } from '@angular/core'; import { Component, computed, inject, input } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { FormFieldComponent } from '../../molecules/form-field/form-field.component'; import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
import { TextInputComponent } from '../../atoms/text-input/text-input.component'; import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
import { ButtonComponent } from '../../atoms/button/button.component'; import { ButtonComponent } from '@shared/ui/button/button.component';
import { AlertComponent } from '../../atoms/alert/alert.component'; import { AlertComponent } from '@shared/ui/alert/alert.component';
import { SpinnerComponent } from '../../atoms/spinner/spinner.component'; import { SpinnerComponent } from '@shared/ui/spinner/spinner.component';
import { ok } from '../../core/fp'; import { createStore } from '@shared/application/store';
import { WizardState, Draft, initial, next, back, submit, resolve } from './wizard.machine'; import { BigProfileStore } from '@registratie/application/big-profile.store';
import { WizardState, WizardMsg, Draft, initial, reduce } from '@herregistratie/domain/herregistratie.machine';
import { submitHerregistratie } from '@herregistratie/application/submit-herregistratie';
/** Organism: multi-step herregistratie wizard driven entirely by a state /** Organism: multi-step herregistratie wizard. ALL state lives in one signal
machine (wizard.machine.ts). The signal IS the union, so the UI just folds driven by the pure `reduce` function (see herregistratie.machine.ts) via an
over its tag no booleans like `submitting`/`submitted` that could contradict Elm-style store. The UI just sends messages and folds over the state's tag
each other. Composition-only: reuses existing form-field/input/button/alert. */ 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({ @Component({
selector: 'app-herregistratie-wizard', selector: 'app-herregistratie-wizard',
imports: [FormsModule, FormFieldComponent, TextInputComponent, ButtonComponent, AlertComponent, SpinnerComponent], imports: [FormsModule, FormFieldComponent, TextInputComponent, ButtonComponent, AlertComponent, SpinnerComponent],
@@ -22,17 +26,17 @@ import { WizardState, Draft, initial, next, back, submit, resolve } from './wiza
<form (ngSubmit)="onPrimary()" style="max-width:28rem"> <form (ngSubmit)="onPrimary()" style="max-width:28rem">
@if (step() === 1) { @if (step() === 1) {
<app-form-field label="Gewerkte uren (afgelopen 5 jaar)" fieldId="uren" [error]="errUren()"> <app-form-field label="Gewerkte uren (afgelopen 5 jaar)" fieldId="uren" [error]="errUren()">
<app-text-input inputId="uren" [ngModel]="draft().uren" (ngModelChange)="set('uren', $event)" <app-text-input inputId="uren" [ngModel]="draft().uren" (ngModelChange)="dispatch({ tag: 'SetField', key: 'uren', value: $event })"
name="uren" [invalid]="!!errUren()" placeholder="bijv. 4160" /> name="uren" [invalid]="!!errUren()" placeholder="bijv. 4160" />
</app-form-field> </app-form-field>
<app-button type="submit" variant="primary">Volgende</app-button> <app-button type="submit" variant="primary">Volgende</app-button>
} @else { } @else {
<app-form-field label="Behaalde nascholingspunten" fieldId="punten" [error]="errPunten()"> <app-form-field label="Behaalde nascholingspunten" fieldId="punten" [error]="errPunten()">
<app-text-input inputId="punten" [ngModel]="draft().punten" (ngModelChange)="set('punten', $event)" <app-text-input inputId="punten" [ngModel]="draft().punten" (ngModelChange)="dispatch({ tag: 'SetField', key: 'punten', value: $event })"
name="punten" [invalid]="!!errPunten()" placeholder="bijv. 200" /> name="punten" [invalid]="!!errPunten()" placeholder="bijv. 200" />
</app-form-field> </app-form-field>
<div style="display:flex;gap:0.5rem"> <div style="display:flex;gap:0.5rem">
<app-button type="button" variant="secondary" (click)="state.set(back(state()))">Vorige</app-button> <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="submit" variant="primary">Herregistratie aanvragen</app-button>
</div> </div>
} }
@@ -45,7 +49,7 @@ import { WizardState, Draft, initial, next, back, submit, resolve } from './wiza
<app-alert type="ok">Uw aanvraag tot herregistratie is ontvangen.</app-alert> <app-alert type="ok">Uw aanvraag tot herregistratie is ontvangen.</app-alert>
} }
@case ('Failed') { @case ('Failed') {
<app-alert type="error">Indienen mislukt. Probeer het opnieuw.</app-alert> <app-alert type="error">Indienen mislukt: {{ failedError() }}</app-alert>
<div style="margin-top:1rem"> <div style="margin-top:1rem">
<app-button variant="secondary" (click)="onRetry()">Opnieuw proberen</app-button> <app-button variant="secondary" (click)="onRetry()">Opnieuw proberen</app-button>
</div> </div>
@@ -54,45 +58,51 @@ import { WizardState, Draft, initial, next, back, submit, resolve } from './wiza
`, `,
}) })
export class HerregistratieWizardComponent { export class HerregistratieWizardComponent {
private profile = inject(BigProfileStore);
private store = createStore<WizardState, WizardMsg>(initial, reduce);
/** Optional seed so Storybook / the showcase can mount any state directly. */ /** Optional seed so Storybook / the showcase can mount any state directly. */
seed = input<WizardState>(initial); seed = input<WizardState>(initial);
state = signal<WizardState>(initial);
back = back; protected state = this.store.model;
protected dispatch = this.store.dispatch;
private editing = computed(() => (this.state().tag === 'Editing' ? (this.state() as Extract<WizardState, { tag: 'Editing' }>) : null)); private editing = computed(() => (this.state().tag === 'Editing' ? (this.state() as Extract<WizardState, { tag: 'Editing' }>) : null));
protected step = computed(() => this.editing()?.step ?? 1); protected step = computed(() => this.editing()?.step ?? 1);
protected draft = computed<Draft>(() => this.editing()?.draft ?? { uren: '', punten: '' }); protected draft = computed<Draft>(() => this.editing()?.draft ?? { uren: '', punten: '' });
protected errUren = computed(() => this.editing()?.errors.uren ?? ''); protected errUren = computed(() => this.editing()?.errors.uren ?? '');
protected errPunten = computed(() => this.editing()?.errors.punten ?? ''); protected errPunten = computed(() => this.editing()?.errors.punten ?? '');
protected failedError = computed(() => (this.state().tag === 'Failed' ? (this.state() as Extract<WizardState, { tag: 'Failed' }>).error : ''));
constructor() { constructor() {
queueMicrotask(() => this.state.set(this.seed())); queueMicrotask(() => this.dispatch({ tag: 'Seed', state: this.seed() }));
}
set(key: keyof Draft, value: string) {
const s = this.state();
if (s.tag !== 'Editing') return;
this.state.set({ ...s, draft: { ...s.draft, [key]: value } });
} }
onPrimary() { onPrimary() {
const s = this.state(); const s = this.state();
if (s.tag !== 'Editing') return; if (s.tag !== 'Editing') return;
this.state.set(s.step === 1 ? next(s) : submit(s)); this.dispatch(s.step === 1 ? { tag: 'Next' } : { tag: 'Submit' });
this.runIfSubmitting(); this.runIfSubmitting();
} }
onRetry() { onRetry() {
const s = this.state(); this.dispatch({ tag: 'Retry' });
if (s.tag !== 'Failed') return;
this.state.set({ tag: 'Submitting', data: s.data });
this.runIfSubmitting(); this.runIfSubmitting();
} }
// ponytail: fake the backend with a timer; swap for a real httpResource call later. /** The effect: when we entered Submitting, call the backend command, flip the
private runIfSubmitting() { optimistic cross-page flag, then dispatch the result (and commit/rollback). */
if (this.state().tag !== 'Submitting') return; private async runIfSubmitting() {
setTimeout(() => this.state.set(resolve(this.state(), ok(undefined))), 800); const s = this.state();
if (s.tag !== 'Submitting') return;
this.profile.beginHerregistratie();
const r = await submitHerregistratie(s.data);
if (r.ok) {
this.dispatch({ tag: 'SubmitConfirmed' });
this.profile.confirmHerregistratie();
} else {
this.dispatch({ tag: 'SubmitFailed', error: r.error });
this.profile.rollbackHerregistratie();
}
} }
} }

View File

@@ -1,13 +1,18 @@
import type { Meta, StoryObj } from '@storybook/angular'; import type { Meta, StoryObj } from '@storybook/angular';
import { applicationConfig } from '@storybook/angular';
import { provideHttpClient } from '@angular/common/http';
import { HerregistratieWizardComponent } from './herregistratie-wizard.component'; import { HerregistratieWizardComponent } from './herregistratie-wizard.component';
import { WizardState } from './wizard.machine'; import { WizardState } from '@herregistratie/domain/herregistratie.machine';
import { Uren } from '../../core/parse'; import { Uren } from '@registratie/domain/value-objects/uren';
const validData = { uren: 4160 as Uren, punten: 200 }; const validData = { uren: 4160 as Uren, punten: 200 };
const meta: Meta<HerregistratieWizardComponent> = { const meta: Meta<HerregistratieWizardComponent> = {
title: 'Organisms/Herregistratie Wizard', title: 'Herregistratie/Wizard',
component: HerregistratieWizardComponent, 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()] })],
}; };
export default meta; export default meta;
type Story = StoryObj<HerregistratieWizardComponent>; type Story = StoryObj<HerregistratieWizardComponent>;

View File

@@ -0,0 +1,42 @@
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 { 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. */
@Component({
selector: 'app-herregistratie-page',
imports: [PageShellComponent, AlertComponent, ...ASYNC, HerregistratieWizardComponent],
template: `
<app-page-shell heading="Herregistratie aanvragen" backLink="/dashboard">
<app-async [data]="eligibility()">
<ng-template appAsyncLoaded let-eligible>
@if (eligible) {
<app-alert type="info">
Uw huidige registratie verloopt binnenkort. Vraag tijdig herregistratie aan.
</app-alert>
<div style="margin-top:1.5rem">
<app-herregistratie-wizard />
</div>
} @else {
<app-alert type="warning">
Voor uw huidige registratiestatus is herregistratie niet mogelijk.
</app-alert>
}
</ng-template>
</app-async>
</app-page-shell>
`,
})
export class HerregistratiePage {
private store = inject(BigProfileStore);
// Derive a boolean RemoteData from the combined profile via map (pure).
protected eligibility = computed(() =>
map(this.store.profile(), (p) => isHerregistratieEligible(p.registration, new Date())),
);
}

View File

@@ -1,63 +0,0 @@
import { Component, inject } from '@angular/core';
import { PageShellComponent } from '../../templates/page-shell/page-shell.component';
import { HeadingComponent } from '../../atoms/heading/heading.component';
import { LinkComponent } from '../../atoms/link/link.component';
import { SkeletonComponent } from '../../atoms/skeleton/skeleton.component';
import { ASYNC } from '../../molecules/async/async.component';
import { RegistrationSummaryComponent } from '../../organisms/registration-summary/registration-summary.component';
import { RegistrationTableComponent } from '../../organisms/registration-table/registration-table.component';
import { RegistrationService } from '../../core/registration.service';
import { Aantekening } from '../../core/models';
@Component({
selector: 'app-dashboard-page',
imports: [
PageShellComponent, HeadingComponent, LinkComponent, SkeletonComponent, ...ASYNC,
RegistrationSummaryComponent, RegistrationTableComponent,
],
template: `
<app-page-shell heading="Mijn BIG-registratie">
<!-- Summary: loading -> skeleton, error -> retry, else the card -->
<app-async [resource]="reg" [isEmpty]="regEmpty">
<ng-template appAsyncLoaded let-r>
<app-registration-summary [reg]="$any(r)" />
</ng-template>
<ng-template appAsyncLoading>
<app-skeleton height="2.5rem" [count]="6" />
</ng-template>
</app-async>
<div style="margin-top:2rem">
<app-heading [level]="2">Specialismen en aantekeningen</app-heading>
<app-async [resource]="notes" [isEmpty]="notesEmpty">
<ng-template appAsyncLoaded let-r>
<app-registration-table [rows]="$any(r)" />
</ng-template>
<ng-template appAsyncLoading>
<app-skeleton height="2.5rem" [count]="3" />
</ng-template>
<ng-template appAsyncEmpty>
<p class="rhc-paragraph">U heeft nog geen specialismen of aantekeningen.</p>
</ng-template>
</app-async>
</div>
<p style="margin-top:2rem">
<app-link to="/registratie">Gegevens bekijken of een wijziging doorgeven →</app-link>
</p>
<p>
<app-link to="/herregistratie">Herregistratie aanvragen →</app-link>
</p>
<p>
<app-link to="/concepts">Functionele patronen (impossible states) →</app-link>
</p>
</app-page-shell>
`,
})
export class DashboardPage {
private svc = inject(RegistrationService);
reg = this.svc.registrationResource();
notes = this.svc.aantekeningenResource();
notesEmpty = (v: Aantekening[]) => v.length === 0;
regEmpty = (v: unknown) => !v || Object.keys(v).length === 0;
}

View File

@@ -1,22 +0,0 @@
import { Component } from '@angular/core';
import { PageShellComponent } from '../../templates/page-shell/page-shell.component';
import { AlertComponent } from '../../atoms/alert/alert.component';
import { HerregistratieWizardComponent } from '../../organisms/herregistratie-wizard/herregistratie-wizard.component';
/** A whole new page built from existing building blocks — no new components.
This is the atomic-design payoff: a new flow is just composition. */
@Component({
selector: 'app-herregistratie-page',
imports: [PageShellComponent, AlertComponent, HerregistratieWizardComponent],
template: `
<app-page-shell heading="Herregistratie aanvragen" backLink="/dashboard">
<app-alert type="info">
Uw huidige registratie verloopt op 1 september 2027. Vraag tijdig herregistratie aan.
</app-alert>
<div style="margin-top:1.5rem">
<app-herregistratie-wizard />
</div>
</app-page-shell>
`,
})
export class HerregistratiePage {}

View File

@@ -1,19 +0,0 @@
import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';
import { PageShellComponent } from '../../templates/page-shell/page-shell.component';
import { LoginFormComponent } from '../../organisms/login-form/login-form.component';
@Component({
selector: 'app-login-page',
imports: [PageShellComponent, LoginFormComponent],
template: `
<app-page-shell heading="Inloggen" width="narrow"
intro="Log in op uw persoonlijke BIG-register omgeving.">
<app-login-form (submit)="login()" />
</app-page-shell>
`,
})
export class LoginPage {
private router = inject(Router);
login() { this.router.navigate(['/dashboard']); }
}

View File

@@ -0,0 +1,60 @@
import { Injectable, computed, inject, signal } from '@angular/core';
import { RemoteData, fromResource, map2 } from '@shared/application/remote-data';
import { Aantekening } from '../domain/registration';
import { BigProfile } from '../domain/big-profile';
import { BigRegisterAdapter } from '../infrastructure/big-register.adapter';
import { BrpAdapter } from '../infrastructure/brp.adapter';
type Err = Error | undefined;
/**
* The single source of truth for the logged-in professional's profile, shared
* across pages (providedIn:'root' = one instance). It owns the httpResources
* (created here, in the required injection context) and exposes them as
* RemoteData signals.
*
* The headline trick: `profile` combines TWO independent services — the
* BIG-register and the BRP — into ONE RemoteData via map2. A page renders a
* single state (loading / error / loaded), never juggling three.
*/
@Injectable({ providedIn: 'root' })
export class BigProfileStore {
private big = inject(BigRegisterAdapter);
private brp = inject(BrpAdapter);
private registrationRes = this.big.registrationResource();
private aantekeningenRes = this.big.aantekeningenResource();
private personRes = this.brp.personResource();
/** BIG-register + BRP folded into one state. */
readonly profile = computed<RemoteData<Err, BigProfile>>(() =>
map2(
fromResource(this.registrationRes),
fromResource(this.personRes),
// httpResource types value as T | undefined; in the Success branch it is
// always present, so narrowing here is safe.
(registration, person) => ({ registration: registration!, person: person! }),
),
);
/** Specialisms/notes stay a separate stream (they have their own empty state). */
readonly aantekeningen = computed<RemoteData<Err, Aantekening[]>>(() =>
fromResource(this.aantekeningenRes, (v) => v.length === 0),
);
// --- Optimistic herregistratie state, shared with the dashboard -----------
private pending = signal(false);
/** True while a herregistratie submission is in flight or just submitted. */
readonly pendingHerregistratie = this.pending.asReadonly();
beginHerregistratie() {
this.pending.set(true); // optimistic: show it immediately on the dashboard
}
confirmHerregistratie() {
this.pending.set(false);
this.registrationRes.reload(); // invalidate: re-fetch the now-updated registration
}
rollbackHerregistratie() {
this.pending.set(false); // submission failed — undo the optimistic flag
}
}

View File

@@ -0,0 +1,12 @@
import { Registration } from './registration';
import { Person } from './person';
/**
* The view the dashboard/detail render: a registration (from the BIG-register)
* enriched with person data (from the BRP). It only exists when BOTH sources
* have loaded — see BigProfileStore, which builds it with map2.
*/
export interface BigProfile {
registration: Registration;
person: Person;
}

View File

@@ -0,0 +1,12 @@
/** Person identity as supplied by the BRP (Basisregistratie Personen). */
export interface Adres {
straat: string;
postcode: string;
woonplaats: string;
}
export interface Person {
naam: string;
geboortedatum: string; // ISO date
adres: Adres;
}

View File

@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest';
import { Registration } from './registration';
import { isHerregistratieEligible, statusColor } from './registration.policy';
const reg = (status: Registration['status']): Registration => ({
bigNummer: '19012345601', naam: 'Test', beroep: 'Arts',
registratiedatum: '2012-09-01', geboortedatum: '1985-03-14', status,
});
describe('registration.policy', () => {
it('only an active registration within the window is eligible', () => {
const active = reg({ tag: 'Geregistreerd', herregistratieDatum: '2027-01-01' });
expect(isHerregistratieEligible(active, new Date('2026-06-01'))).toBe(true); // within 12 months
expect(isHerregistratieEligible(active, new Date('2020-01-01'))).toBe(false); // too early
});
it('struck-off / suspended registrations are never eligible', () => {
expect(isHerregistratieEligible(reg({ tag: 'Doorgehaald', doorgehaaldOp: '2024-05-01', reden: 'x' }), new Date('2027-01-01'))).toBe(false);
expect(isHerregistratieEligible(reg({ tag: 'Geschorst', geschorstTot: '2026-12-31', reden: 'x' }), new Date('2027-01-01'))).toBe(false);
});
it('statusColor is total over the union', () => {
expect(statusColor('Geregistreerd')).toContain('groen');
expect(statusColor('Doorgehaald')).toContain('rood');
expect(statusColor('Geschorst')).toContain('oranje');
});
});

View File

@@ -0,0 +1,51 @@
import { assertNever } from '@shared/kernel/fp';
import { Registration, RegistrationStatus, StatusTag } from './registration';
/**
* Domain logic for a registration — pure functions, NO Angular. This is where
* "what the business rules say" lives, separate from "how it looks" (UI) and
* "where the data comes from" (infrastructure). Keeping it framework-free means
* it is trivial to read and unit-test.
*/
/** Human-readable label for a status. */
export function statusLabel(tag: StatusTag): string {
return tag; // the tag already reads as Dutch; kept as a function so labels can diverge later
}
/** Brand colour token for a status. assertNever forces a colour for every new
status variant at compile time. */
export function statusColor(tag: StatusTag): string {
switch (tag) {
case 'Geregistreerd':
return 'var(--rhc-color-groen-500)';
case 'Doorgehaald':
return 'var(--rhc-color-rood-500)';
case 'Geschorst':
return 'var(--rhc-color-oranje-500)';
default:
return assertNever(tag);
}
}
/** The herregistratie deadline, if the status has one (only the active state does). */
export function herregistratieDeadline(reg: Registration): Date | null {
return reg.status.tag === 'Geregistreerd' ? new Date(reg.status.herregistratieDatum) : null;
}
/** A registration may apply for herregistratie only while active and within the
window before its deadline. A struck-off or suspended registration may not. */
export function isHerregistratieEligible(reg: Registration, today: Date, windowMonths = 12): boolean {
const deadline = herregistratieDeadline(reg);
if (!deadline) return false;
const windowStart = new Date(deadline);
windowStart.setMonth(windowStart.getMonth() - windowMonths);
return today >= windowStart;
}
/** Invariant check used in tests/demos: a non-active status must not carry a
herregistratie date. The union already enforces this structurally; this is
the runtime statement of the same rule. */
export function isStatusConsistent(status: RegistrationStatus): boolean {
return status.tag === 'Geregistreerd' ? typeof status.herregistratieDatum === 'string' : true;
}

View File

@@ -0,0 +1,9 @@
import { Brand, Result, ok, err } from '@shared/kernel/fp';
/** Value object: a BIG registration number — 11 digits. */
export type BigNummer = Brand<string, 'BigNummer'>;
export function parseBigNummer(raw: string): Result<string, BigNummer> {
const t = raw.trim();
return /^\d{11}$/.test(t) ? ok(t as BigNummer) : err('Een BIG-nummer bestaat uit 11 cijfers.');
}

View File

@@ -0,0 +1,17 @@
import { Brand, Result, ok, err } from '@shared/kernel/fp';
/**
* Value object: a Dutch postcode. "Parse, don't validate" — a Postcode is a
* distinct type from a raw string, mintable only via parsePostcode, so holding
* one is proof it is well-formed.
*/
export type Postcode = Brand<string, 'Postcode'>;
export function parsePostcode(raw: string): Result<string, Postcode> {
const t = raw.trim().toUpperCase();
if (!/^[1-9]\d{3}\s?[A-Z]{2}$/.test(t)) {
return err('Voer een geldige postcode in, bijv. 1234 AB.');
}
// Normalise to "1234 AB" — the parser also cleans up.
return ok(t.replace(/^(\d{4})\s?([A-Z]{2})$/, '$1 $2') as Postcode);
}

View File

@@ -0,0 +1,14 @@
import { Brand, Result, ok, err } from '@shared/kernel/fp';
/** Value object: a non-negative whole number of hours. */
export type Uren = Brand<number, 'Uren'>;
export function parseUren(raw: string): Result<string, Uren> {
const t = raw.trim();
const n = Number(t);
// Number('') is 0 — guard the empty string explicitly.
if (t === '' || !Number.isInteger(n) || n < 0) {
return err('Vul een geheel aantal in (0 of meer).');
}
return ok(n as Uren);
}

View File

@@ -0,0 +1,20 @@
import { Injectable } from '@angular/core';
import { httpResource } from '@angular/common/http';
import { Registration, Aantekening } from '../domain/registration';
/**
* Infrastructure adapter for the BIG-register source. Exposes signal-based
* resources (Angular's httpResource); each returns a Resource with
* status()/value()/error()/reload(). Call from an injection context
* (a field initializer in the store).
*/
@Injectable({ providedIn: 'root' })
export class BigRegisterAdapter {
registrationResource() {
return httpResource<Registration>(() => 'mock/registration.json');
}
aantekeningenResource() {
return httpResource<Aantekening[]>(() => 'mock/notes.json', { defaultValue: [] });
}
}

View File

@@ -0,0 +1,11 @@
import { Injectable } from '@angular/core';
import { httpResource } from '@angular/common/http';
import { Person } from '../domain/person';
/** Infrastructure adapter for the BRP (Basisregistratie Personen) source. */
@Injectable({ providedIn: 'root' })
export class BrpAdapter {
personResource() {
return httpResource<Person>(() => 'mock/brp.json');
}
}

View File

@@ -1,10 +1,10 @@
import { Component, output, signal } from '@angular/core'; import { Component, output, signal } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { FormFieldComponent } from '../../molecules/form-field/form-field.component'; import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
import { TextInputComponent } from '../../atoms/text-input/text-input.component'; import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
import { ButtonComponent } from '../../atoms/button/button.component'; import { ButtonComponent } from '@shared/ui/button/button.component';
import { HeadingComponent } from '../../atoms/heading/heading.component'; import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { Postcode, parsePostcode } from '../../core/parse'; import { Postcode, parsePostcode } from '@registratie/domain/value-objects/postcode';
/** A submitted change request carries a *parsed* postcode (branded Postcode), /** A submitted change request carries a *parsed* postcode (branded Postcode),
not a raw string downstream code can't receive an unvalidated one. */ not a raw string downstream code can't receive an unvalidated one. */

View File

@@ -0,0 +1,72 @@
import { Component, inject } from '@angular/core';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { LinkComponent } from '@shared/ui/link/link.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
import { ASYNC } from '@shared/ui/async/async.component';
import { RegistrationSummaryComponent } from '@registratie/ui/registration-summary/registration-summary.component';
import { RegistrationTableComponent } from '@registratie/ui/registration-table/registration-table.component';
import { BigProfileStore } from '@registratie/application/big-profile.store';
@Component({
selector: 'app-dashboard-page',
imports: [
PageShellComponent, HeadingComponent, LinkComponent, AlertComponent, SkeletonComponent,
DataRowComponent, ...ASYNC, RegistrationSummaryComponent, RegistrationTableComponent,
],
template: `
<app-page-shell heading="Mijn BIG-registratie">
@if (store.pendingHerregistratie()) {
<app-alert type="info">Uw herregistratie-aanvraag is in behandeling.</app-alert>
}
<!-- ONE state for two combined services (BIG-register + BRP). -->
<app-async [data]="store.profile()">
<ng-template appAsyncLoaded let-p>
<app-registration-summary [reg]="$any(p).registration" />
<div class="rhc-card rhc-card--default" style="margin-top:1rem">
<app-heading [level]="2">Persoonsgegevens (BRP)</app-heading>
<dl class="rhc-data-summary rhc-data-summary--row">
<app-data-row key="Straat" [value]="$any(p).person.adres.straat" />
<app-data-row key="Postcode" [value]="$any(p).person.adres.postcode" />
<app-data-row key="Woonplaats" [value]="$any(p).person.adres.woonplaats" />
</dl>
</div>
</ng-template>
<ng-template appAsyncLoading>
<app-skeleton height="2.5rem" [count]="6" />
</ng-template>
</app-async>
<div style="margin-top:2rem">
<app-heading [level]="2">Specialismen en aantekeningen</app-heading>
<app-async [data]="store.aantekeningen()">
<ng-template appAsyncLoaded let-r>
<app-registration-table [rows]="$any(r)" />
</ng-template>
<ng-template appAsyncLoading>
<app-skeleton height="2.5rem" [count]="3" />
</ng-template>
<ng-template appAsyncEmpty>
<p class="rhc-paragraph">U heeft nog geen specialismen of aantekeningen.</p>
</ng-template>
</app-async>
</div>
<p style="margin-top:2rem">
<app-link to="/registratie">Gegevens bekijken of een wijziging doorgeven →</app-link>
</p>
<p>
<app-link to="/herregistratie">Herregistratie aanvragen →</app-link>
</p>
<p>
<app-link to="/concepts">Functionele patronen (impossible states) →</app-link>
</p>
</app-page-shell>
`,
})
export class DashboardPage {
protected store = inject(BigProfileStore);
}

View File

@@ -1,11 +1,11 @@
import { Component, inject, signal } from '@angular/core'; import { Component, inject, signal } from '@angular/core';
import { PageShellComponent } from '../../templates/page-shell/page-shell.component'; import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { AlertComponent } from '../../atoms/alert/alert.component'; import { AlertComponent } from '@shared/ui/alert/alert.component';
import { SkeletonComponent } from '../../atoms/skeleton/skeleton.component'; import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
import { ASYNC } from '../../molecules/async/async.component'; import { ASYNC } from '@shared/ui/async/async.component';
import { RegistrationSummaryComponent } from '../../organisms/registration-summary/registration-summary.component'; import { RegistrationSummaryComponent } from '@registratie/ui/registration-summary/registration-summary.component';
import { ChangeRequestFormComponent } from '../../organisms/change-request-form/change-request-form.component'; import { ChangeRequestFormComponent } from '@registratie/ui/change-request-form/change-request-form.component';
import { RegistrationService } from '../../core/registration.service'; import { BigProfileStore } from '@registratie/application/big-profile.store';
@Component({ @Component({
selector: 'app-registration-detail-page', selector: 'app-registration-detail-page',
@@ -15,9 +15,9 @@ import { RegistrationService } from '../../core/registration.service';
], ],
template: ` template: `
<app-page-shell heading="Mijn gegevens" backLink="/dashboard"> <app-page-shell heading="Mijn gegevens" backLink="/dashboard">
<app-async [resource]="reg" [isEmpty]="regEmpty"> <app-async [data]="store.profile()">
<ng-template appAsyncLoaded let-r> <ng-template appAsyncLoaded let-p>
<app-registration-summary [reg]="$any(r)" /> <app-registration-summary [reg]="$any(p).registration" />
</ng-template> </ng-template>
<ng-template appAsyncLoading> <ng-template appAsyncLoading>
<app-skeleton height="2.5rem" [count]="6" /> <app-skeleton height="2.5rem" [count]="6" />
@@ -35,8 +35,6 @@ import { RegistrationService } from '../../core/registration.service';
`, `,
}) })
export class RegistrationDetailPage { export class RegistrationDetailPage {
private svc = inject(RegistrationService); protected store = inject(BigProfileStore);
reg = this.svc.registrationResource();
regEmpty = (v: unknown) => !v || Object.keys(v).length === 0;
submitted = signal(false); submitted = signal(false);
} }

View File

@@ -1,8 +1,9 @@
import { Component, input } from '@angular/core'; import { Component, input } from '@angular/core';
import { DatePipe } from '@angular/common'; import { DatePipe } from '@angular/common';
import { Registration } from '../../core/models'; import { Registration } from '@registratie/domain/registration';
import { DataRowComponent } from '../../molecules/data-row/data-row.component'; import { statusColor, statusLabel } from '@registratie/domain/registration.policy';
import { StatusBadgeComponent } from '../../atoms/status-badge/status-badge.component'; import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
import { StatusBadgeComponent } from '@shared/ui/status-badge/status-badge.component';
/** Organism: registration summary card. Composes data-row molecules + status-badge atom. */ /** Organism: registration summary card. Composes data-row molecules + status-badge atom. */
@Component({ @Component({
@@ -15,7 +16,7 @@ import { StatusBadgeComponent } from '../../atoms/status-badge/status-badge.comp
<app-data-row key="Naam" [value]="reg().naam" /> <app-data-row key="Naam" [value]="reg().naam" />
<app-data-row key="Beroep" [value]="reg().beroep" /> <app-data-row key="Beroep" [value]="reg().beroep" />
<app-data-row key="Status"> <app-data-row key="Status">
<app-status-badge [status]="reg().status.tag" /> <app-status-badge [label]="label()" [color]="color()" />
</app-data-row> </app-data-row>
<app-data-row key="Registratiedatum" [value]="reg().registratiedatum | date:'longDate'" /> <app-data-row key="Registratiedatum" [value]="reg().registratiedatum | date:'longDate'" />
<!-- Each status variant renders only the row its own data supports. --> <!-- Each status variant renders only the row its own data supports. -->
@@ -38,4 +39,6 @@ import { StatusBadgeComponent } from '../../atoms/status-badge/status-badge.comp
}) })
export class RegistrationSummaryComponent { export class RegistrationSummaryComponent {
reg = input.required<Registration>(); reg = input.required<Registration>();
protected label = () => statusLabel(this.reg().status.tag);
protected color = () => statusColor(this.reg().status.tag);
} }

View File

@@ -1,6 +1,6 @@
import type { Meta, StoryObj } from '@storybook/angular'; import type { Meta, StoryObj } from '@storybook/angular';
import { RegistrationSummaryComponent } from './registration-summary.component'; import { RegistrationSummaryComponent } from './registration-summary.component';
import { Registration } from '../../core/models'; import { Registration } from '@registratie/domain/registration';
const base = { const base = {
bigNummer: '19012345601', bigNummer: '19012345601',

View File

@@ -1,6 +1,6 @@
import { Component, input } from '@angular/core'; import { Component, input } from '@angular/core';
import { DatePipe } from '@angular/common'; import { DatePipe } from '@angular/common';
import { Aantekening } from '../../core/models'; import { Aantekening } from '@registratie/domain/registration';
/** Organism: table of specialismen/aantekeningen. */ /** Organism: table of specialismen/aantekeningen. */
@Component({ @Component({

View File

@@ -0,0 +1,22 @@
import { describe, it, expect } from 'vitest';
import { RemoteData, map2, map } from './remote-data';
const loading: RemoteData<string, number> = { tag: 'Loading' };
const failure: RemoteData<string, number> = { tag: 'Failure', error: 'x' };
const ok = (n: number): RemoteData<string, number> => ({ tag: 'Success', value: n });
describe('RemoteData combinators', () => {
it('map only touches Success', () => {
const times10 = (n: number) => n * 10;
expect(map(ok(2), times10)).toEqual({ tag: 'Success', value: 20 });
expect(map(loading, times10)).toEqual(loading);
});
it('map2 precedence: Failure > Loading > Success', () => {
const add = (a: number, b: number) => a + b;
expect(map2(failure, ok(1), add)).toEqual(failure); // a failed
expect(map2(ok(1), failure, add)).toEqual(failure); // b failed
expect(map2(loading, ok(1), add)).toEqual({ tag: 'Loading' });
expect(map2(ok(2), ok(3), add)).toEqual({ tag: 'Success', value: 5 });
});
});

View File

@@ -0,0 +1,90 @@
import type { Resource } from '@angular/core';
import { assertNever } from '@shared/kernel/fp';
/**
* The four mutually-exclusive states of an async fetch, as a tagged union.
* Crucially the data lives ON the state: only `Failure` has an `error`, only
* `Success` has a `value`. "Loaded but no value" or "error with stale value"
* are unrepresentable — Richard Feldman's RemoteData.
*/
export type RemoteData<E, T> =
| { tag: 'Loading' }
| { tag: 'Empty' }
| { tag: 'Failure'; error: E }
| { tag: 'Success'; value: T };
/** Project Angular's loosely-typed Resource into a RemoteData value. */
export function fromResource<T>(
r: Resource<T>,
isEmpty: (v: T) => boolean = () => false,
): RemoteData<Error | undefined, T> {
if (r.status() === 'error') return { tag: 'Failure', error: r.error() };
if (r.status() === 'loading') return { tag: 'Loading' };
if (r.hasValue()) {
const v = r.value();
return isEmpty(v) ? { tag: 'Empty' } : { tag: 'Success', value: v };
}
return { tag: 'Loading' };
}
/** Exhaustive fold: you must handle every case, checked at compile time. */
export function foldRemote<E, T, R>(
rd: RemoteData<E, T>,
h: { loading: () => R; empty: () => R; failure: (e: E) => R; success: (v: T) => R },
): R {
switch (rd.tag) {
case 'Loading':
return h.loading();
case 'Empty':
return h.empty();
case 'Failure':
return h.failure(rd.error);
case 'Success':
return h.success(rd.value);
default:
return assertNever(rd);
}
}
// --- Combinators -----------------------------------------------------------
// Let several independent async sources be treated as one. When you combine
// two streams the result is: a failure if EITHER failed, still loading if
// either is loading, empty if either is empty, and only Success when BOTH
// succeeded. Precedence: Failure > Loading > Empty > Success.
/** Transform the value inside a Success; pass other states through unchanged. */
export function map<E, A, B>(rd: RemoteData<E, A>, f: (a: A) => B): RemoteData<E, B> {
return rd.tag === 'Success' ? { tag: 'Success', value: f(rd.value) } : rd;
}
/** Combine two sources into one. Use this to merge e.g. a BIG-register call
and a BRP call into a single state the page can render. */
export function map2<E, A, B, R>(
a: RemoteData<E, A>,
b: RemoteData<E, B>,
f: (a: A, b: B) => R,
): RemoteData<E, R> {
if (a.tag === 'Failure') return a;
if (b.tag === 'Failure') return b;
if (a.tag === 'Loading' || b.tag === 'Loading') return { tag: 'Loading' };
if (a.tag === 'Empty' || b.tag === 'Empty') return { tag: 'Empty' };
return { tag: 'Success', value: f(a.value, b.value) };
}
/** Combine three sources (built on map2). */
export function map3<E, A, B, C, R>(
a: RemoteData<E, A>,
b: RemoteData<E, B>,
c: RemoteData<E, C>,
f: (a: A, b: B, c: C) => R,
): RemoteData<E, R> {
return map2(map2(a, b, (x, y) => [x, y] as const), c, ([x, y], z) => f(x, y, z));
}
/** Chain a second source that depends on the first one's value. */
export function andThen<E, A, B>(
rd: RemoteData<E, A>,
f: (a: A) => RemoteData<E, B>,
): RemoteData<E, B> {
return rd.tag === 'Success' ? f(rd.value) : rd;
}

View File

@@ -0,0 +1,29 @@
import { Signal, signal } from '@angular/core';
/**
* A tiny "Elm-style" store. The whole idea: all state lives in ONE value
* (the Model). The only way to change it is to send a message (Msg) to a PURE
* function `update(model, msg)` that returns the next Model. Nothing else
* mutates state, so to understand the app you only read the update function.
*
* Side effects (HTTP, timers) do NOT go in `update` — that stays pure and easy
* to test. Instead, effectful "command" functions call the network and then
* `dispatch` a message describing what happened (e.g. Loaded / Failed).
*/
export interface Store<Model, Msg> {
/** The current state, as a read-only Angular signal. */
readonly model: Signal<Model>;
/** Send a message; the model becomes update(model, msg). */
dispatch(msg: Msg): void;
}
export function createStore<Model, Msg>(
init: Model,
update: (model: Model, msg: Msg) => Model,
): Store<Model, Msg> {
const model = signal(init);
return {
model: model.asReadonly(),
dispatch: (msg) => model.set(update(model(), msg)),
};
}

View File

@@ -1,6 +1,6 @@
import { Component, input } from '@angular/core'; import { Component, input } from '@angular/core';
import { HeadingComponent } from '../../atoms/heading/heading.component'; import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { LinkComponent } from '../../atoms/link/link.component'; import { LinkComponent } from '@shared/ui/link/link.component';
/** Template: standard page body optional back-link, a heading, optional intro, /** Template: standard page body optional back-link, a heading, optional intro,
and projected content. Rendered inside the persistent ShellComponent via the and projected content. Rendered inside the persistent ShellComponent via the

View File

@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/angular';
import { applicationConfig, moduleMetadata } from '@storybook/angular'; import { applicationConfig, moduleMetadata } from '@storybook/angular';
import { provideRouter } from '@angular/router'; import { provideRouter } from '@angular/router';
import { PageShellComponent } from './page-shell.component'; import { PageShellComponent } from './page-shell.component';
import { ButtonComponent } from '../../atoms/button/button.component'; import { ButtonComponent } from '@shared/ui/button/button.component';
const meta: Meta<PageShellComponent> = { const meta: Meta<PageShellComponent> = {
title: 'Templates/PageShell', title: 'Templates/PageShell',

View File

@@ -1,7 +1,7 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router'; import { RouterOutlet } from '@angular/router';
import { SiteHeaderComponent } from '../../organisms/site-header/site-header.component'; import { SiteHeaderComponent } from '@shared/layout/site-header/site-header.component';
import { SiteFooterComponent } from '../../organisms/site-footer/site-footer.component'; import { SiteFooterComponent } from '@shared/layout/site-footer/site-footer.component';
/** Template: persistent app chrome. Header + footer mount once; only the routed /** Template: persistent app chrome. Header + footer mount once; only the routed
content inside <router-outlet> changes (and cross-fades see styles.scss). */ content inside <router-outlet> changes (and cross-fades see styles.scss). */

View File

@@ -1,10 +1,10 @@
import { Component, Directive, TemplateRef, computed, contentChild, input } from '@angular/core'; import { Component, Directive, TemplateRef, computed, contentChild, input } from '@angular/core';
import { NgTemplateOutlet } from '@angular/common'; import { NgTemplateOutlet } from '@angular/common';
import type { Resource } from '@angular/core'; import type { Resource } from '@angular/core';
import { SpinnerComponent } from '../../atoms/spinner/spinner.component'; import { SpinnerComponent } from '@shared/ui/spinner/spinner.component';
import { AlertComponent } from '../../atoms/alert/alert.component'; import { AlertComponent } from '@shared/ui/alert/alert.component';
import { ButtonComponent } from '../../atoms/button/button.component'; import { ButtonComponent } from '@shared/ui/button/button.component';
import { fromResource, foldRemote } from '../../core/remote-data'; import { RemoteData, fromResource, foldRemote } from '@shared/application/remote-data';
/* Slot markers. Put on <ng-template> children of <app-async>. */ /* Slot markers. Put on <ng-template> children of <app-async>. */
@Directive({ selector: '[appAsyncLoaded]' }) @Directive({ selector: '[appAsyncLoaded]' })
@@ -63,7 +63,11 @@ export class AsyncErrorDirective {
`, `,
}) })
export class AsyncComponent<T> { export class AsyncComponent<T> {
resource = input.required<Resource<T>>(); // Two ways to feed this component:
// [resource] — a raw httpResource (the common case), or
// [data] — an already-combined RemoteData (e.g. from a store via map2).
resource = input<Resource<T>>();
data = input<RemoteData<Error | undefined, T>>();
isEmpty = input<(v: T) => boolean>(() => false); isEmpty = input<(v: T) => boolean>(() => false);
loadedTpl = contentChild.required(AsyncLoadedDirective); loadedTpl = contentChild.required(AsyncLoadedDirective);
@@ -71,8 +75,13 @@ export class AsyncComponent<T> {
emptyTpl = contentChild(AsyncEmptyDirective); emptyTpl = contentChild(AsyncEmptyDirective);
errorTpl = contentChild(AsyncErrorDirective); errorTpl = contentChild(AsyncErrorDirective);
// Single source of truth: the resource projected into a RemoteData union. // Single source of truth: the supplied RemoteData, or the resource projected into one.
protected rd = computed(() => fromResource(this.resource(), this.isEmpty())); protected rd = computed<RemoteData<Error | undefined, T>>(() => {
const data = this.data();
if (data) return data;
const r = this.resource();
return r ? fromResource(r, this.isEmpty()) : { tag: 'Loading' };
});
// value/error are pulled out via the exhaustive fold — only Success carries a // value/error are pulled out via the exhaustive fold — only Success carries a
// value, only Failure carries an error, so these can't lie. // value, only Failure carries an error, so these can't lie.
@@ -85,7 +94,7 @@ export class AsyncComponent<T> {
retry = () => { retry = () => {
const r = this.resource(); const r = this.resource();
if ('reload' in r && typeof (r as { reload?: unknown }).reload === 'function') { if (r && 'reload' in r && typeof (r as { reload?: unknown }).reload === 'function') {
(r as { reload: () => void }).reload(); (r as { reload: () => void }).reload();
} }
}; };

View File

@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/angular';
import { moduleMetadata } from '@storybook/angular'; import { moduleMetadata } from '@storybook/angular';
import type { Resource } from '@angular/core'; import type { Resource } from '@angular/core';
import { ASYNC } from './async.component'; import { ASYNC } from './async.component';
import { SkeletonComponent } from '../../atoms/skeleton/skeleton.component'; import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
/** Minimal fake of a signal Resource so the wrapper can be driven through every /** Minimal fake of a signal Resource so the wrapper can be driven through every
state in isolation (no HTTP). */ state in isolation (no HTTP). */

View File

@@ -1,7 +1,7 @@
import type { Meta, StoryObj } from '@storybook/angular'; import type { Meta, StoryObj } from '@storybook/angular';
import { moduleMetadata } from '@storybook/angular'; import { moduleMetadata } from '@storybook/angular';
import { FormFieldComponent } from './form-field.component'; import { FormFieldComponent } from './form-field.component';
import { TextInputComponent } from '../../atoms/text-input/text-input.component'; import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
const meta: Meta<FormFieldComponent> = { const meta: Meta<FormFieldComponent> = {
title: 'Molecules/Form Field', title: 'Molecules/Form Field',

View File

@@ -0,0 +1,18 @@
import { Component, input } from '@angular/core';
/** Atom: a coloured dot + label. Purely presentational and domain-free — the
caller decides what colour and label mean (e.g. via registration.policy).
This keeps the shared UI kernel free of any domain knowledge. */
@Component({
selector: 'app-status-badge',
template: `
<span style="display:inline-flex;align-items:center;gap:0.5rem">
<span class="rhc-dot-badge" [style.background-color]="color()" aria-hidden="true"></span>
<span>{{ label() }}</span>
</span>
`,
})
export class StatusBadgeComponent {
label = input.required<string>();
color = input.required<string>();
}

View File

@@ -0,0 +1,13 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { StatusBadgeComponent } from './status-badge.component';
const meta: Meta<StatusBadgeComponent> = {
title: 'Shared UI/Status Badge',
component: StatusBadgeComponent,
};
export default meta;
type Story = StoryObj<StatusBadgeComponent>;
export const Geregistreerd: Story = { args: { label: 'Geregistreerd', color: 'var(--rhc-color-groen-500)' } };
export const Geschorst: Story = { args: { label: 'Geschorst', color: 'var(--rhc-color-oranje-500)' } };
export const Doorgehaald: Story = { args: { label: 'Doorgehaald', color: 'var(--rhc-color-rood-500)' } };

View File

@@ -1,16 +1,16 @@
import { Component, computed, signal } from '@angular/core'; import { Component, computed, signal } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import type { Resource } from '@angular/core'; import type { Resource } from '@angular/core';
import { PageShellComponent } from '../../templates/page-shell/page-shell.component'; import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { HeadingComponent } from '../../atoms/heading/heading.component'; import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { AlertComponent } from '../../atoms/alert/alert.component'; import { AlertComponent } from '@shared/ui/alert/alert.component';
import { TextInputComponent } from '../../atoms/text-input/text-input.component'; import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
import { ASYNC } from '../../molecules/async/async.component'; import { ASYNC } from '@shared/ui/async/async.component';
import { SkeletonComponent } from '../../atoms/skeleton/skeleton.component'; import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
import { RegistrationSummaryComponent } from '../../organisms/registration-summary/registration-summary.component'; import { RegistrationSummaryComponent } from '@registratie/ui/registration-summary/registration-summary.component';
import { HerregistratieWizardComponent } from '../../organisms/herregistratie-wizard/herregistratie-wizard.component'; import { HerregistratieWizardComponent } from '@herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component';
import { Registration } from '../../core/models'; import { Registration } from '@registratie/domain/registration';
import { parsePostcode } from '../../core/parse'; import { parsePostcode } from '@registratie/domain/value-objects/postcode';
/** Minimal fake Resource so <app-async> can be driven through every state without HTTP. */ /** Minimal fake Resource so <app-async> can be driven through every state without HTTP. */
function fakeResource<T>(status: string, value?: T, error?: Error): Resource<T> { function fakeResource<T>(status: string, value?: T, error?: Error): Resource<T> {

View File

@@ -12,7 +12,15 @@
"experimentalDecorators": true, "experimentalDecorators": true,
"importHelpers": true, "importHelpers": true,
"target": "ES2022", "target": "ES2022",
"module": "preserve" "module": "preserve",
"ignoreDeprecations": "6.0",
"baseUrl": ".",
"paths": {
"@shared/*": ["src/app/shared/*"],
"@auth/*": ["src/app/auth/*"],
"@registratie/*": ["src/app/registratie/*"],
"@herregistratie/*": ["src/app/herregistratie/*"]
}
}, },
"angularCompilerOptions": { "angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false, "enableI18nLegacyMessageIdFormat": false,