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:
@@ -5,7 +5,7 @@ import { registerLocaleData } from '@angular/common';
|
||||
import localeNl from '@angular/common/locales/nl';
|
||||
|
||||
import { routes } from './app.routes';
|
||||
import { scenarioInterceptor } from './core/scenario.interceptor';
|
||||
import { scenarioInterceptor } from '@shared/infrastructure/scenario.interceptor';
|
||||
|
||||
registerLocaleData(localeNl);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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 = [
|
||||
{
|
||||
@@ -7,11 +8,11 @@ export const routes: Routes = [
|
||||
component: ShellComponent, // persistent header/footer; only children swap
|
||||
children: [
|
||||
{ path: '', pathMatch: 'full', redirectTo: 'login' },
|
||||
{ path: 'login', loadComponent: () => import('./pages/login/login.page').then(m => m.LoginPage) },
|
||||
{ path: 'dashboard', loadComponent: () => import('./pages/dashboard/dashboard.page').then(m => m.DashboardPage) },
|
||||
{ path: 'registratie', loadComponent: () => import('./pages/registration-detail/registration-detail.page').then(m => m.RegistrationDetailPage) },
|
||||
{ path: 'herregistratie', loadComponent: () => import('./pages/herregistratie/herregistratie.page').then(m => m.HerregistratiePage) },
|
||||
{ path: 'concepts', loadComponent: () => import('./pages/concepts/concepts.page').then(m => m.ConceptsPage) },
|
||||
{ path: 'login', loadComponent: () => import('@auth/ui/login.page').then(m => m.LoginPage) },
|
||||
{ path: 'dashboard', canActivate: [authGuard], loadComponent: () => import('@registratie/ui/dashboard.page').then(m => m.DashboardPage) },
|
||||
{ path: 'registratie', canActivate: [authGuard], loadComponent: () => import('@registratie/ui/registration-detail.page').then(m => m.RegistrationDetailPage) },
|
||||
{ path: 'herregistratie', canActivate: [authGuard], loadComponent: () => import('@herregistratie/ui/herregistratie.page').then(m => m.HerregistratiePage) },
|
||||
{ path: 'concepts', loadComponent: () => import('./showcase/concepts.page').then(m => m.ConceptsPage) },
|
||||
{ path: '**', redirectTo: 'login' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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' } };
|
||||
30
src/app/auth/application/session.store.ts
Normal file
30
src/app/auth/application/session.store.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
src/app/auth/auth.guard.ts
Normal file
10
src/app/auth/auth.guard.ts
Normal 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']);
|
||||
};
|
||||
9
src/app/auth/domain/session.ts
Normal file
9
src/app/auth/domain/session.ts
Normal 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;
|
||||
}
|
||||
15
src/app/auth/infrastructure/digid.adapter.ts
Normal file
15
src/app/auth/infrastructure/digid.adapter.ts
Normal 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' });
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
import { Component, output } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { FormFieldComponent } from '../../molecules/form-field/form-field.component';
|
||||
import { TextInputComponent } from '../../atoms/text-input/text-input.component';
|
||||
import { ButtonComponent } from '../../atoms/button/button.component';
|
||||
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
|
||||
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
|
||||
/** Organism: DigiD-style mock login. No real auth — just composes atoms/molecules. */
|
||||
@Component({
|
||||
selector: 'app-login-form',
|
||||
imports: [FormsModule, FormFieldComponent, TextInputComponent, ButtonComponent],
|
||||
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-text-input inputId="bsn" [(ngModel)]="bsn" name="bsn" placeholder="123456789" />
|
||||
</app-form-field>
|
||||
@@ -27,5 +27,5 @@ import { ButtonComponent } from '../../atoms/button/button.component';
|
||||
export class LoginFormComponent {
|
||||
bsn = '';
|
||||
password = '';
|
||||
submit = output<void>();
|
||||
submit = output<string>();
|
||||
}
|
||||
29
src/app/auth/ui/login.page.ts
Normal file
29
src/app/auth/ui/login.page.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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: [] });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
14
src/app/herregistratie/application/submit-herregistratie.ts
Normal file
14
src/app/herregistratie/application/submit-herregistratie.ts
Normal 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);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ok, err } from '../../core/fp';
|
||||
import { initial, next, back, submit, resolve, WizardState } from './wizard.machine';
|
||||
import { ok, err } from '@shared/kernel/fp';
|
||||
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 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');
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Result } from '../../core/fp';
|
||||
import { Uren, parseUren } from '../../core/parse';
|
||||
import { Result, assertNever } from '@shared/kernel/fp';
|
||||
import { Uren, parseUren } from '@registratie/domain/value-objects/uren';
|
||||
|
||||
/** What the user is typing (raw, possibly invalid). */
|
||||
export interface Draft {
|
||||
@@ -64,3 +64,47 @@ 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: '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);
|
||||
}
|
||||
}
|
||||
@@ -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 { FormFieldComponent } from '../../molecules/form-field/form-field.component';
|
||||
import { TextInputComponent } from '../../atoms/text-input/text-input.component';
|
||||
import { ButtonComponent } from '../../atoms/button/button.component';
|
||||
import { AlertComponent } from '../../atoms/alert/alert.component';
|
||||
import { SpinnerComponent } from '../../atoms/spinner/spinner.component';
|
||||
import { ok } from '../../core/fp';
|
||||
import { WizardState, Draft, initial, next, back, submit, resolve } from './wizard.machine';
|
||||
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
|
||||
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||
import { SpinnerComponent } from '@shared/ui/spinner/spinner.component';
|
||||
import { createStore } from '@shared/application/store';
|
||||
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
|
||||
machine (wizard.machine.ts). The signal IS the union, so the UI just folds
|
||||
over its tag — no booleans like `submitting`/`submitted` that could contradict
|
||||
each other. Composition-only: reuses existing form-field/input/button/alert. */
|
||||
/** 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, ButtonComponent, AlertComponent, SpinnerComponent],
|
||||
@@ -22,17 +26,17 @@ import { WizardState, Draft, initial, next, back, submit, resolve } from './wiza
|
||||
<form (ngSubmit)="onPrimary()" style="max-width:28rem">
|
||||
@if (step() === 1) {
|
||||
<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" />
|
||||
</app-form-field>
|
||||
<app-button type="submit" variant="primary">Volgende</app-button>
|
||||
} @else {
|
||||
<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" />
|
||||
</app-form-field>
|
||||
<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>
|
||||
</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>
|
||||
}
|
||||
@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">
|
||||
<app-button variant="secondary" (click)="onRetry()">Opnieuw proberen</app-button>
|
||||
</div>
|
||||
@@ -54,45 +58,51 @@ import { WizardState, Draft, initial, next, back, submit, resolve } from './wiza
|
||||
`,
|
||||
})
|
||||
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. */
|
||||
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));
|
||||
protected step = computed(() => this.editing()?.step ?? 1);
|
||||
protected draft = computed<Draft>(() => this.editing()?.draft ?? { uren: '', punten: '' });
|
||||
protected errUren = computed(() => this.editing()?.errors.uren ?? '');
|
||||
protected errPunten = computed(() => this.editing()?.errors.punten ?? '');
|
||||
protected failedError = computed(() => (this.state().tag === 'Failed' ? (this.state() as Extract<WizardState, { tag: 'Failed' }>).error : ''));
|
||||
|
||||
constructor() {
|
||||
queueMicrotask(() => this.state.set(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 } });
|
||||
queueMicrotask(() => this.dispatch({ tag: 'Seed', state: this.seed() }));
|
||||
}
|
||||
|
||||
onPrimary() {
|
||||
const s = this.state();
|
||||
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();
|
||||
}
|
||||
|
||||
onRetry() {
|
||||
const s = this.state();
|
||||
if (s.tag !== 'Failed') return;
|
||||
this.state.set({ tag: 'Submitting', data: s.data });
|
||||
this.dispatch({ tag: 'Retry' });
|
||||
this.runIfSubmitting();
|
||||
}
|
||||
|
||||
// ponytail: fake the backend with a timer; swap for a real httpResource call later.
|
||||
private runIfSubmitting() {
|
||||
if (this.state().tag !== 'Submitting') return;
|
||||
setTimeout(() => this.state.set(resolve(this.state(), ok(undefined))), 800);
|
||||
/** 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() {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,18 @@
|
||||
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 { WizardState } from './wizard.machine';
|
||||
import { Uren } from '../../core/parse';
|
||||
import { WizardState } from '@herregistratie/domain/herregistratie.machine';
|
||||
import { Uren } from '@registratie/domain/value-objects/uren';
|
||||
|
||||
const validData = { uren: 4160 as Uren, punten: 200 };
|
||||
|
||||
const meta: Meta<HerregistratieWizardComponent> = {
|
||||
title: 'Organisms/Herregistratie Wizard',
|
||||
title: '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()] })],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<HerregistratieWizardComponent>;
|
||||
42
src/app/herregistratie/ui/herregistratie.page.ts
Normal file
42
src/app/herregistratie/ui/herregistratie.page.ts
Normal 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())),
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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']); }
|
||||
}
|
||||
60
src/app/registratie/application/big-profile.store.ts
Normal file
60
src/app/registratie/application/big-profile.store.ts
Normal 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
|
||||
}
|
||||
}
|
||||
12
src/app/registratie/domain/big-profile.ts
Normal file
12
src/app/registratie/domain/big-profile.ts
Normal 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;
|
||||
}
|
||||
12
src/app/registratie/domain/person.ts
Normal file
12
src/app/registratie/domain/person.ts
Normal 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;
|
||||
}
|
||||
27
src/app/registratie/domain/registration.policy.spec.ts
Normal file
27
src/app/registratie/domain/registration.policy.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
51
src/app/registratie/domain/registration.policy.ts
Normal file
51
src/app/registratie/domain/registration.policy.ts
Normal 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;
|
||||
}
|
||||
9
src/app/registratie/domain/value-objects/big-nummer.ts
Normal file
9
src/app/registratie/domain/value-objects/big-nummer.ts
Normal 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.');
|
||||
}
|
||||
17
src/app/registratie/domain/value-objects/postcode.ts
Normal file
17
src/app/registratie/domain/value-objects/postcode.ts
Normal 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);
|
||||
}
|
||||
14
src/app/registratie/domain/value-objects/uren.ts
Normal file
14
src/app/registratie/domain/value-objects/uren.ts
Normal 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);
|
||||
}
|
||||
20
src/app/registratie/infrastructure/big-register.adapter.ts
Normal file
20
src/app/registratie/infrastructure/big-register.adapter.ts
Normal 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: [] });
|
||||
}
|
||||
}
|
||||
11
src/app/registratie/infrastructure/brp.adapter.ts
Normal file
11
src/app/registratie/infrastructure/brp.adapter.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Component, output, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { FormFieldComponent } from '../../molecules/form-field/form-field.component';
|
||||
import { TextInputComponent } from '../../atoms/text-input/text-input.component';
|
||||
import { ButtonComponent } from '../../atoms/button/button.component';
|
||||
import { HeadingComponent } from '../../atoms/heading/heading.component';
|
||||
import { Postcode, parsePostcode } from '../../core/parse';
|
||||
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
|
||||
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||
import { Postcode, parsePostcode } from '@registratie/domain/value-objects/postcode';
|
||||
|
||||
/** A submitted change request carries a *parsed* postcode (branded Postcode),
|
||||
not a raw string — downstream code can't receive an unvalidated one. */
|
||||
72
src/app/registratie/ui/dashboard.page.ts
Normal file
72
src/app/registratie/ui/dashboard.page.ts
Normal 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);
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { PageShellComponent } from '../../templates/page-shell/page-shell.component';
|
||||
import { AlertComponent } from '../../atoms/alert/alert.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 { ChangeRequestFormComponent } from '../../organisms/change-request-form/change-request-form.component';
|
||||
import { RegistrationService } from '../../core/registration.service';
|
||||
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
|
||||
import { ASYNC } from '@shared/ui/async/async.component';
|
||||
import { RegistrationSummaryComponent } from '@registratie/ui/registration-summary/registration-summary.component';
|
||||
import { ChangeRequestFormComponent } from '@registratie/ui/change-request-form/change-request-form.component';
|
||||
import { BigProfileStore } from '@registratie/application/big-profile.store';
|
||||
|
||||
@Component({
|
||||
selector: 'app-registration-detail-page',
|
||||
@@ -15,9 +15,9 @@ import { RegistrationService } from '../../core/registration.service';
|
||||
],
|
||||
template: `
|
||||
<app-page-shell heading="Mijn gegevens" backLink="/dashboard">
|
||||
<app-async [resource]="reg" [isEmpty]="regEmpty">
|
||||
<ng-template appAsyncLoaded let-r>
|
||||
<app-registration-summary [reg]="$any(r)" />
|
||||
<app-async [data]="store.profile()">
|
||||
<ng-template appAsyncLoaded let-p>
|
||||
<app-registration-summary [reg]="$any(p).registration" />
|
||||
</ng-template>
|
||||
<ng-template appAsyncLoading>
|
||||
<app-skeleton height="2.5rem" [count]="6" />
|
||||
@@ -35,8 +35,6 @@ import { RegistrationService } from '../../core/registration.service';
|
||||
`,
|
||||
})
|
||||
export class RegistrationDetailPage {
|
||||
private svc = inject(RegistrationService);
|
||||
reg = this.svc.registrationResource();
|
||||
regEmpty = (v: unknown) => !v || Object.keys(v).length === 0;
|
||||
protected store = inject(BigProfileStore);
|
||||
submitted = signal(false);
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { Registration } from '../../core/models';
|
||||
import { DataRowComponent } from '../../molecules/data-row/data-row.component';
|
||||
import { StatusBadgeComponent } from '../../atoms/status-badge/status-badge.component';
|
||||
import { Registration } from '@registratie/domain/registration';
|
||||
import { statusColor, statusLabel } from '@registratie/domain/registration.policy';
|
||||
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. */
|
||||
@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="Beroep" [value]="reg().beroep" />
|
||||
<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 key="Registratiedatum" [value]="reg().registratiedatum | date:'longDate'" />
|
||||
<!-- 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 {
|
||||
reg = input.required<Registration>();
|
||||
protected label = () => statusLabel(this.reg().status.tag);
|
||||
protected color = () => statusColor(this.reg().status.tag);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { RegistrationSummaryComponent } from './registration-summary.component';
|
||||
import { Registration } from '../../core/models';
|
||||
import { Registration } from '@registratie/domain/registration';
|
||||
|
||||
const base = {
|
||||
bigNummer: '19012345601',
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { Aantekening } from '../../core/models';
|
||||
import { Aantekening } from '@registratie/domain/registration';
|
||||
|
||||
/** Organism: table of specialismen/aantekeningen. */
|
||||
@Component({
|
||||
22
src/app/shared/application/remote-data.spec.ts
Normal file
22
src/app/shared/application/remote-data.spec.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
90
src/app/shared/application/remote-data.ts
Normal file
90
src/app/shared/application/remote-data.ts
Normal 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;
|
||||
}
|
||||
29
src/app/shared/application/store.ts
Normal file
29
src/app/shared/application/store.ts
Normal 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)),
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
import { HeadingComponent } from '../../atoms/heading/heading.component';
|
||||
import { LinkComponent } from '../../atoms/link/link.component';
|
||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||
import { LinkComponent } from '@shared/ui/link/link.component';
|
||||
|
||||
/** Template: standard page body — optional back-link, a heading, optional intro,
|
||||
and projected content. Rendered inside the persistent ShellComponent via the
|
||||
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { applicationConfig, moduleMetadata } from '@storybook/angular';
|
||||
import { provideRouter } from '@angular/router';
|
||||
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> = {
|
||||
title: 'Templates/PageShell',
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
import { SiteHeaderComponent } from '../../organisms/site-header/site-header.component';
|
||||
import { SiteFooterComponent } from '../../organisms/site-footer/site-footer.component';
|
||||
import { SiteHeaderComponent } from '@shared/layout/site-header/site-header.component';
|
||||
import { SiteFooterComponent } from '@shared/layout/site-footer/site-footer.component';
|
||||
|
||||
/** Template: persistent app chrome. Header + footer mount once; only the routed
|
||||
content inside <router-outlet> changes (and cross-fades — see styles.scss). */
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Component, Directive, TemplateRef, computed, contentChild, input } from '@angular/core';
|
||||
import { NgTemplateOutlet } from '@angular/common';
|
||||
import type { Resource } from '@angular/core';
|
||||
import { SpinnerComponent } from '../../atoms/spinner/spinner.component';
|
||||
import { AlertComponent } from '../../atoms/alert/alert.component';
|
||||
import { ButtonComponent } from '../../atoms/button/button.component';
|
||||
import { fromResource, foldRemote } from '../../core/remote-data';
|
||||
import { SpinnerComponent } from '@shared/ui/spinner/spinner.component';
|
||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
import { RemoteData, fromResource, foldRemote } from '@shared/application/remote-data';
|
||||
|
||||
/* Slot markers. Put on <ng-template> children of <app-async>. */
|
||||
@Directive({ selector: '[appAsyncLoaded]' })
|
||||
@@ -63,7 +63,11 @@ export class AsyncErrorDirective {
|
||||
`,
|
||||
})
|
||||
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);
|
||||
|
||||
loadedTpl = contentChild.required(AsyncLoadedDirective);
|
||||
@@ -71,8 +75,13 @@ export class AsyncComponent<T> {
|
||||
emptyTpl = contentChild(AsyncEmptyDirective);
|
||||
errorTpl = contentChild(AsyncErrorDirective);
|
||||
|
||||
// Single source of truth: the resource projected into a RemoteData union.
|
||||
protected rd = computed(() => fromResource(this.resource(), this.isEmpty()));
|
||||
// Single source of truth: the supplied RemoteData, or the resource projected into one.
|
||||
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, only Failure carries an error, so these can't lie.
|
||||
@@ -85,7 +94,7 @@ export class AsyncComponent<T> {
|
||||
|
||||
retry = () => {
|
||||
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();
|
||||
}
|
||||
};
|
||||
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { moduleMetadata } from '@storybook/angular';
|
||||
import type { Resource } from '@angular/core';
|
||||
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
|
||||
state in isolation (no HTTP). */
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { moduleMetadata } from '@storybook/angular';
|
||||
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> = {
|
||||
title: 'Molecules/Form Field',
|
||||
18
src/app/shared/ui/status-badge/status-badge.component.ts
Normal file
18
src/app/shared/ui/status-badge/status-badge.component.ts
Normal 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>();
|
||||
}
|
||||
13
src/app/shared/ui/status-badge/status-badge.stories.ts
Normal file
13
src/app/shared/ui/status-badge/status-badge.stories.ts
Normal 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)' } };
|
||||
@@ -1,16 +1,16 @@
|
||||
import { Component, computed, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import type { Resource } from '@angular/core';
|
||||
import { PageShellComponent } from '../../templates/page-shell/page-shell.component';
|
||||
import { HeadingComponent } from '../../atoms/heading/heading.component';
|
||||
import { AlertComponent } from '../../atoms/alert/alert.component';
|
||||
import { TextInputComponent } from '../../atoms/text-input/text-input.component';
|
||||
import { ASYNC } from '../../molecules/async/async.component';
|
||||
import { SkeletonComponent } from '../../atoms/skeleton/skeleton.component';
|
||||
import { RegistrationSummaryComponent } from '../../organisms/registration-summary/registration-summary.component';
|
||||
import { HerregistratieWizardComponent } from '../../organisms/herregistratie-wizard/herregistratie-wizard.component';
|
||||
import { Registration } from '../../core/models';
|
||||
import { parsePostcode } from '../../core/parse';
|
||||
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
|
||||
import { ASYNC } from '@shared/ui/async/async.component';
|
||||
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
|
||||
import { RegistrationSummaryComponent } from '@registratie/ui/registration-summary/registration-summary.component';
|
||||
import { HerregistratieWizardComponent } from '@herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component';
|
||||
import { Registration } from '@registratie/domain/registration';
|
||||
import { parsePostcode } from '@registratie/domain/value-objects/postcode';
|
||||
|
||||
/** 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> {
|
||||
Reference in New Issue
Block a user