feat(WP-67): merge behandelportal into this repo as a monorepo
Restructures into apps/ssp + apps/behandelportal (two Angular projects) plus libs/shared + libs/beheer (cross-app libraries), replacing WP-61's separate sibling repo. That split had already produced real drift: a hand-vendored copy of the backend's OpenAPI doc, a shared/ui+layout tree forked and silently diverging (7 files), and beheer + the styles.scss token bridge duplicated byte-for-byte across both repos. - git mv the SSP's src/app/* into apps/ssp/; fold shared/, beheer/, environments/, the Storybook docs/*.mdx, and styles.scss into libs/shared + libs/beheer (all confirmed identical between the two repos before merging). auth stays deliberately duplicated per ADR-0002 (actor-specific, expected to diverge) - amended there. - One generated API client (libs/shared), no more vendored swagger.json. - .dependency-cruiser split into a base factory + one config per app, and Storybook into .storybook-ssp/.storybook-behandelportal - both forced by the @auth/* alias resolving to different directories per app. - SiteHeaderComponent/ShellComponent gained HEADER_NAV_ITEMS/ HEADER_ADMIN_LINKS/DEBUG_PANEL injection tokens so each app supplies its own nav/admin-links/dev-panel instead of one being hardcoded. - CLAUDE.md, ARCHITECTURE.md, dependencies.md, and ADR-0002 updated; WP-67 backlog entry documents the full decision trail. npm run ci green (lint, dep:check x2, 360 tests across ssp/ behandelportal/shared/beheer, both localized builds, backend tests, snippet + api-client drift); both dev servers, both Storybook instances, and docker compose verified working. The old sibling repo (/home/eho/repos/behandelportal) is left untouched, not deleted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"/api": {
|
||||
"target": "http://localhost:5000",
|
||||
"secure": false,
|
||||
"changeOrigin": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
ApplicationConfig,
|
||||
LOCALE_ID,
|
||||
isDevMode,
|
||||
provideBrowserGlobalErrorListeners,
|
||||
} from '@angular/core';
|
||||
import { provideRouter, withInMemoryScrolling, withViewTransitions } from '@angular/router';
|
||||
import type { ActivatedRouteSnapshot } from '@angular/router';
|
||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||
import { registerLocaleData } from '@angular/common';
|
||||
import localeNl from '@angular/common/locales/nl';
|
||||
import localeEn from '@angular/common/locales/en';
|
||||
|
||||
import { routes } from './app.routes';
|
||||
import { scenarioInterceptor } from '@shared/infrastructure/scenario.interceptor';
|
||||
import { roleInterceptor } from '@shared/infrastructure/role.interceptor';
|
||||
import { provideApiClient } from '@shared/infrastructure/api-client.provider';
|
||||
import { SESSION_PORT } from '@shared/application/session.port';
|
||||
import { SessionStore } from '@auth/application/session.store';
|
||||
import { provideRouteFocus } from '@shared/layout/route-focus';
|
||||
import { provideUnloadFlush } from '@shared/application/pending-saves';
|
||||
import { HEADER_ADMIN_LINKS, HEADER_NAV_ITEMS } from '@shared/layout/site-header/nav-config';
|
||||
import { DEBUG_PANEL } from '@shared/layout/shell/shell.component';
|
||||
import { ADMIN_LINKS, NAV_ITEMS } from './shell/nav.config';
|
||||
import { DebugStateComponent } from './shell/debug-state/debug-state.component';
|
||||
|
||||
// Both locales' data so DatePipe/number pipes work for whichever bundle is active.
|
||||
registerLocaleData(localeNl);
|
||||
registerLocaleData(localeEn);
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
provideRouter(
|
||||
routes,
|
||||
withInMemoryScrolling({ scrollPositionRestoration: 'enabled' }),
|
||||
// Cross-fade page-to-page navigations only. A silent same-route nav — e.g.
|
||||
// draft-sync stamping `?aanvraag=<id>` into the URL mid-wizard — must NOT
|
||||
// animate: for the transition's duration Firefox's `::view-transition`
|
||||
// overlay swallows pointer events (Chrome sets pointer-events:none, so it
|
||||
// doesn't), which loses a click landing on it and makes the wizard's "next"
|
||||
// button need a second click. Skip the transition when the route is unchanged.
|
||||
withViewTransitions({
|
||||
onViewTransitionCreated: ({ transition, from, to }) => {
|
||||
// `from`/`to` are the ROOT snapshots (the shared shell), so descend to the
|
||||
// leaf before comparing — otherwise every navigation looks "same route".
|
||||
const leaf = (r: ActivatedRouteSnapshot) => {
|
||||
while (r.firstChild) r = r.firstChild;
|
||||
return r;
|
||||
};
|
||||
if (leaf(from).routeConfig === leaf(to).routeConfig) transition.skipTransition();
|
||||
},
|
||||
}),
|
||||
),
|
||||
// Dev-only: the ?scenario= toggle must never reach a production build, where
|
||||
// a query param could otherwise force errors on the live app.
|
||||
provideHttpClient(withInterceptors(isDevMode() ? [scenarioInterceptor, roleInterceptor] : [])),
|
||||
provideApiClient(),
|
||||
{ provide: SESSION_PORT, useExisting: SessionStore },
|
||||
// Per-bundle locale: the localize build sets `$localize.locale` ('nl'/'en'); the
|
||||
// non-localized dev/source build leaves it undefined → fall back to 'nl'. (Was hardcoded
|
||||
// 'nl', which mis-formatted dates/numbers in the en bundle.)
|
||||
{ provide: LOCALE_ID, useFactory: () => $localize.locale ?? 'nl' },
|
||||
provideRouteFocus(),
|
||||
provideUnloadFlush(),
|
||||
{ provide: HEADER_NAV_ITEMS, useValue: NAV_ITEMS },
|
||||
{ provide: HEADER_ADMIN_LINKS, useValue: ADMIN_LINKS },
|
||||
{ provide: DEBUG_PANEL, useValue: DebugStateComponent },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { ShellComponent } from '@shared/layout/shell/shell.component';
|
||||
import { authGuard, capabilityGuard } from '@auth/auth.guard';
|
||||
import { flushPendingGuard } from '@shared/application/pending-saves';
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: ShellComponent, // persistent header/footer; only children swap
|
||||
children: [
|
||||
{ path: '', pathMatch: 'full', redirectTo: 'login' },
|
||||
{
|
||||
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: 'aanvraag/:id',
|
||||
canActivate: [authGuard],
|
||||
loadComponent: () =>
|
||||
import('@registratie/ui/aanvraag-detail.page').then((m) => m.AanvraagDetailPage),
|
||||
},
|
||||
{
|
||||
path: 'registreren',
|
||||
canActivate: [authGuard],
|
||||
// Autosave wizard: flush the pending debounced draft before leaving (pending-saves.ts).
|
||||
canDeactivate: [flushPendingGuard],
|
||||
loadComponent: () =>
|
||||
import('@registratie/ui/registratie.page').then((m) => m.RegistratiePage),
|
||||
},
|
||||
{
|
||||
path: 'herregistratie',
|
||||
canActivate: [authGuard],
|
||||
canDeactivate: [flushPendingGuard],
|
||||
loadComponent: () =>
|
||||
import('@herregistratie/ui/herregistratie.page').then((m) => m.HerregistratiePage),
|
||||
},
|
||||
{
|
||||
path: 'intake',
|
||||
canActivate: [authGuard],
|
||||
canDeactivate: [flushPendingGuard],
|
||||
loadComponent: () => import('@herregistratie/ui/intake.page').then((m) => m.IntakePage),
|
||||
},
|
||||
{
|
||||
path: 'brief',
|
||||
canActivate: [authGuard],
|
||||
canDeactivate: [flushPendingGuard],
|
||||
loadComponent: () => import('@brief/ui/brief.page').then((m) => m.BriefPage),
|
||||
},
|
||||
{
|
||||
path: 'brief/huisstijl',
|
||||
// Admin-only org-template editor (WP-26): capabilityGuard denies-by-default
|
||||
// unless GET /me resolved `orgtemplate:edit` (Admin role). Backend re-enforces
|
||||
// via the OrgAdmin gate — the guard just avoids loading a page that would 403.
|
||||
canActivate: [capabilityGuard('orgtemplate:edit')],
|
||||
canDeactivate: [flushPendingGuard],
|
||||
loadComponent: () => import('@brief/ui/org-template.page').then((m) => m.OrgTemplatePage),
|
||||
},
|
||||
{
|
||||
path: 'beheer/stamdata',
|
||||
// Admin-only stamdata maintenance editor (ADR-0004): capabilityGuard denies-by-default
|
||||
// unless GET /me resolved `stamdata:edit` (Admin role). Backend re-enforces via the
|
||||
// StamdataAdmin gate — the guard just avoids loading a page that would 403.
|
||||
canActivate: [capabilityGuard('stamdata:edit')],
|
||||
loadComponent: () => import('@beheer/ui/stamdata.page').then((m) => m.StamdataPage),
|
||||
},
|
||||
{
|
||||
path: 'beheer/zaken',
|
||||
// Admin-only cases overview + delete (WP-36): capabilityGuard denies-by-default
|
||||
// unless GET /me resolved `cases:manage` (Admin role). Backend re-enforces via the
|
||||
// CasesAdmin gate — the guard just avoids loading a page that would 403. The page
|
||||
// lives in registratie/ui (which owns the Aanvraag aggregate); routed under /beheer.
|
||||
canActivate: [capabilityGuard('cases:manage')],
|
||||
loadComponent: () =>
|
||||
import('@registratie/ui/admin-cases.page').then((m) => m.AdminCasesPage),
|
||||
},
|
||||
{
|
||||
path: 'beheer/audit',
|
||||
// Admin-only authz/PII-reveal audit trail (WP-41/42). capabilityGuard denies-by-default
|
||||
// unless GET /me resolved `cases:manage` (reused for audit read). Backend re-enforces.
|
||||
canActivate: [capabilityGuard('cases:manage')],
|
||||
loadComponent: () => import('@beheer/ui/audit.page').then((m) => m.AuditPage),
|
||||
},
|
||||
{
|
||||
path: 'beheer/functies',
|
||||
// Admin-only feature-flag toggles (WP-47), gated by `flags:manage`.
|
||||
canActivate: [capabilityGuard('flags:manage')],
|
||||
loadComponent: () =>
|
||||
import('@beheer/ui/feature-flags.page').then((m) => m.FeatureFlagsPage),
|
||||
},
|
||||
{
|
||||
path: 'concepts',
|
||||
loadComponent: () => import('./showcase/concepts.page').then((m) => m.ConceptsPage),
|
||||
},
|
||||
{ path: '**', redirectTo: 'login' },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [RouterOutlet],
|
||||
template: '<router-outlet />',
|
||||
})
|
||||
export class App {}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Injectable, computed, effect, inject, signal } from '@angular/core';
|
||||
import { Result } from '@shared/kernel/fp';
|
||||
import { Session } from '../domain/session';
|
||||
import { DigidAdapter } from '../infrastructure/digid.adapter';
|
||||
|
||||
const STORAGE_KEY = 'session-v1';
|
||||
|
||||
/** Restore a persisted session (best-effort; corrupt entry → logged out).
|
||||
G2: validate the shape before trusting it. G1: the BSN is never persisted
|
||||
(see the effect below), so a restored session carries an empty one — it is
|
||||
unused after login; only `naam` is shown in the chrome. */
|
||||
function restore(): Session | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as Partial<Session>;
|
||||
return typeof parsed?.naam === 'string' ? { bsn: '', naam: parsed.naam } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.). The session is mirrored to localStorage so a refresh, a deep-link,
|
||||
* or the full-page navigation the language switch performs (nl at `/` ⇄ en at `/en/`,
|
||||
* separate bundles) keeps you logged in. ponytail: localStorage, not sessionStorage —
|
||||
* sessionStorage's per-tab clearing dropped the login on the cross-bundle language
|
||||
* switch. Trade-off: the demo session now survives tab close; a real portal keeps auth
|
||||
* in an httpOnly cookie/token, not web storage.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SessionStore {
|
||||
private digid = inject(DigidAdapter);
|
||||
private _session = signal<Session | null>(restore());
|
||||
|
||||
readonly session = this._session.asReadonly();
|
||||
readonly isAuthenticated = computed(() => this._session() !== null);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const s = this._session();
|
||||
// G1: persist only `naam` — never write the BSN (national ID) to storage.
|
||||
if (s) localStorage.setItem(STORAGE_KEY, JSON.stringify({ naam: s.naam }));
|
||||
else localStorage.removeItem(STORAGE_KEY);
|
||||
});
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { Router } from '@angular/router';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { AccessStore } from '@shared/application/access.store';
|
||||
import { SessionStore } from './application/session.store';
|
||||
import { authGuard, capabilityGuard } from './auth.guard';
|
||||
|
||||
type Opts = {
|
||||
authed: boolean;
|
||||
can?: (c: string) => boolean;
|
||||
whenReady?: () => Promise<void>;
|
||||
};
|
||||
|
||||
function setup({ authed, can = () => false, whenReady = () => Promise.resolve() }: Opts) {
|
||||
const createUrlTree = vi.fn((cmds: string[]) => ({ tree: cmds }));
|
||||
const readySpy = vi.fn(whenReady);
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: SessionStore, useValue: { isAuthenticated: () => authed } },
|
||||
{ provide: AccessStore, useValue: { whenReady: readySpy, can } },
|
||||
{ provide: Router, useValue: { createUrlTree } },
|
||||
],
|
||||
});
|
||||
return { createUrlTree, readySpy };
|
||||
}
|
||||
|
||||
// The guards ignore their (route, state) args; cast to call with none.
|
||||
const call = <T>(fn: unknown) => TestBed.runInInjectionContext(() => (fn as () => T)());
|
||||
|
||||
describe('authGuard', () => {
|
||||
it('allows an authenticated user', () => {
|
||||
setup({ authed: true });
|
||||
expect(call(authGuard)).toBe(true);
|
||||
});
|
||||
|
||||
it('redirects an anonymous user to /login', () => {
|
||||
const { createUrlTree } = setup({ authed: false });
|
||||
expect(call(authGuard)).toEqual({ tree: ['/login'] });
|
||||
expect(createUrlTree).toHaveBeenCalledWith(['/login']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('capabilityGuard', () => {
|
||||
const guard = () => capabilityGuard('stamdata:edit');
|
||||
|
||||
it('waits for /me, then allows an entitled admin', async () => {
|
||||
const { readySpy } = setup({ authed: true, can: (c) => c === 'stamdata:edit' });
|
||||
await expect(call<Promise<unknown>>(guard())).resolves.toBe(true);
|
||||
expect(readySpy).toHaveBeenCalledOnce(); // it awaited caps before deciding
|
||||
});
|
||||
|
||||
it('sends an authenticated-but-unentitled user to /dashboard (not a login loop)', async () => {
|
||||
setup({ authed: true, can: () => false });
|
||||
await expect(call<Promise<unknown>>(guard())).resolves.toEqual({ tree: ['/dashboard'] });
|
||||
});
|
||||
|
||||
it('redirects an anonymous user to /login without waiting for caps', async () => {
|
||||
const { readySpy } = setup({ authed: false, can: () => true });
|
||||
await expect(call<Promise<unknown>>(guard())).resolves.toEqual({ tree: ['/login'] });
|
||||
expect(readySpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { CanActivateFn, Router } from '@angular/router';
|
||||
import { AccessStore } from '@shared/application/access.store';
|
||||
import { Capability } from '@shared/domain/capability';
|
||||
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']);
|
||||
};
|
||||
|
||||
/**
|
||||
* Route guard factory (PRD-0002 §6): authenticated AND holding `capability`, else
|
||||
* redirect. Used by the admin pages (`/brief/huisstijl`, `/beheer/stamdata`).
|
||||
*
|
||||
* **Async on purpose:** `can()` is deny-by-default, so it must not be read while `/me`
|
||||
* is still loading — it would deny an entitled admin and bounce them. We await
|
||||
* `AccessStore.whenReady()` (caps resolved) before deciding. An unauthenticated user
|
||||
* goes to `/login`; an authenticated-but-unentitled user goes to `/dashboard` (they're
|
||||
* logged in, just not allowed here — no re-login loop). The backend re-enforces
|
||||
* regardless (403); this guard is the UX pre-gate.
|
||||
*/
|
||||
export function capabilityGuard(capability: Capability): CanActivateFn {
|
||||
return async () => {
|
||||
const session = inject(SessionStore);
|
||||
const access = inject(AccessStore);
|
||||
const router = inject(Router);
|
||||
if (!session.isAuthenticated()) return router.createUrlTree(['/login']);
|
||||
await access.whenReady();
|
||||
return access.can(capability) ? true : router.createUrlTree(['/dashboard']);
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Result, ok } from '@shared/kernel/fp';
|
||||
import { parseBsn } from '@shared/kernel/bsn';
|
||||
import { Session } from '../domain/session';
|
||||
|
||||
/** Infrastructure: talks to the (mock) DigiD identity provider. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DigidAdapter {
|
||||
// ponytail: fake DigiD — any elfproef-valid BSN authenticates to a fixed identity.
|
||||
// Real BSN validation (parseBsn, WP-40) is the trust boundary; swap the fixed identity
|
||||
// for a real OIDC redirect flow when there's an IdP.
|
||||
async authenticate(bsn: string): Promise<Result<string, Session>> {
|
||||
const r = parseBsn(bsn);
|
||||
return r.ok ? ok({ bsn: r.value, naam: 'Dr. A. (Anna) de Vries' }) : r;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Component, output } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
|
||||
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
|
||||
import { 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)="submitted.emit(bsn)" class="form-horizontal">
|
||||
<div class="form-header">
|
||||
<div class="form-action">
|
||||
<span class="meta" i18n="@@form.verplichteVelden">* verplichte velden</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<app-form-field
|
||||
i18n-label="@@login.bsnLabel"
|
||||
label="BSN"
|
||||
fieldId="bsn"
|
||||
required
|
||||
i18n-description="@@login.bsnDescription"
|
||||
description="9-cijferig BSN, elfproef-geldig (demo: 123456782)"
|
||||
>
|
||||
<app-text-input
|
||||
inputId="bsn"
|
||||
hasDescription
|
||||
[(ngModel)]="bsn"
|
||||
name="bsn"
|
||||
placeholder="123456782"
|
||||
/>
|
||||
</app-form-field>
|
||||
|
||||
<app-form-field i18n-label="@@login.wachtwoordLabel" label="Wachtwoord" fieldId="pw" required>
|
||||
<app-text-input inputId="pw" type="password" [(ngModel)]="password" name="pw" />
|
||||
</app-form-field>
|
||||
|
||||
<app-button type="submit" variant="primary" i18n="@@login.submit"
|
||||
>Inloggen met DigiD</app-button
|
||||
>
|
||||
</form>
|
||||
`,
|
||||
})
|
||||
export class LoginFormComponent {
|
||||
bsn = '';
|
||||
password = '';
|
||||
submitted = output<string>();
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { LoginFormComponent } from './login-form.component';
|
||||
|
||||
const meta: Meta<LoginFormComponent> = {
|
||||
title: 'Domein/Auth/Login Form',
|
||||
component: LoginFormComponent,
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<LoginFormComponent>;
|
||||
|
||||
export const Default: Story = {};
|
||||
@@ -0,0 +1,36 @@
|
||||
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
|
||||
i18n-heading="@@login.heading"
|
||||
heading="Inloggen"
|
||||
width="narrow"
|
||||
i18n-intro="@@login.intro"
|
||||
intro="Log in op uw persoonlijke BIG-register omgeving."
|
||||
>
|
||||
@if (error()) {
|
||||
<app-alert type="error">{{ error() }}</app-alert>
|
||||
}
|
||||
<app-login-form (submitted)="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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { Result } from '@shared/kernel/fp';
|
||||
import { Brief, BriefDecisions, CaseContext, LetterBlock } from '@brief/domain/brief';
|
||||
import { OrgTemplate } from '@brief/domain/org-template';
|
||||
import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';
|
||||
import { LetterPreviewAdapter } from '@brief/infrastructure/letter-preview.adapter';
|
||||
import { RevealBigNummerAdapter } from '@brief/infrastructure/reveal-bignummer.adapter';
|
||||
import { BriefStore } from './brief.store';
|
||||
|
||||
const decisions: BriefDecisions = {
|
||||
canEdit: true,
|
||||
canApprove: true,
|
||||
canReject: true,
|
||||
canSend: true,
|
||||
canRevealBigNummer: true,
|
||||
};
|
||||
|
||||
const brief: Brief = {
|
||||
briefId: 'b1',
|
||||
beroep: 'arts',
|
||||
templateId: 't1',
|
||||
placeholders: [],
|
||||
sections: [],
|
||||
status: { tag: 'draft' },
|
||||
drafterId: 'u1',
|
||||
};
|
||||
|
||||
const orgTemplate: OrgTemplate = {
|
||||
subOrgId: 'cibg-registers',
|
||||
orgName: 'CIBG — Registers',
|
||||
returnAddress: 'Postbus 00000\n2500 AA Den Haag',
|
||||
footerContact: 'info@voorbeeld.example',
|
||||
footerLegal: 'KvK 00000000',
|
||||
signatureName: 'A. de Vries',
|
||||
signatureRole: 'Hoofd Registratie',
|
||||
signatureClosing: 'Met vriendelijke groet,',
|
||||
margins: { topMm: 25, rightMm: 25, bottomMm: 25, leftMm: 25 },
|
||||
version: 1,
|
||||
};
|
||||
|
||||
const caseContext: CaseContext = {
|
||||
zorgverlenerNaam: 'Dr. A. (Anna) de Vries',
|
||||
bigNummer: '19012345601',
|
||||
beroep: 'arts',
|
||||
aanvraagReferentie: 'HER-2026-000842',
|
||||
};
|
||||
|
||||
const view: BriefView = { brief, availablePassages: [], decisions, orgTemplate, caseContext };
|
||||
|
||||
function setup(adapter: Partial<BriefAdapter>): BriefStore {
|
||||
TestBed.configureTestingModule({ providers: [{ provide: BriefAdapter, useValue: adapter }] });
|
||||
return TestBed.inject(BriefStore);
|
||||
}
|
||||
|
||||
describe('BriefStore action state (Idle | Busy | Failed)', () => {
|
||||
it('is Busy synchronously once a transition starts', async () => {
|
||||
const approved: BriefView = {
|
||||
...view,
|
||||
brief: { ...brief, status: { tag: 'approved', approvedBy: 'u2', approvedAt: 't' } },
|
||||
};
|
||||
const store = setup({
|
||||
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
|
||||
save: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
|
||||
approve: (): Promise<Result<string, BriefView>> =>
|
||||
Promise.resolve({ ok: true, value: approved }),
|
||||
});
|
||||
await store.load();
|
||||
|
||||
const pending = store.approve();
|
||||
expect(store.busy()).toBe(true); // set synchronously, before any await resolves
|
||||
|
||||
await pending; // settle before the test ends
|
||||
});
|
||||
|
||||
it('settles to Idle on a successful transition', async () => {
|
||||
const approved: BriefView = {
|
||||
...view,
|
||||
brief: { ...brief, status: { tag: 'approved', approvedBy: 'u2', approvedAt: 't' } },
|
||||
};
|
||||
const store = setup({
|
||||
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
|
||||
save: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
|
||||
approve: (): Promise<Result<string, BriefView>> =>
|
||||
Promise.resolve({ ok: true, value: approved }),
|
||||
});
|
||||
await store.load();
|
||||
|
||||
await store.approve();
|
||||
expect(store.busy()).toBe(false);
|
||||
expect(store.lastError()).toBeNull();
|
||||
});
|
||||
|
||||
it('goes Busy then Failed on a failing transition, surfacing the error', async () => {
|
||||
const store = setup({
|
||||
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
|
||||
save: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
|
||||
approve: (): Promise<Result<string, BriefView>> =>
|
||||
Promise.resolve({ ok: false, error: 'niet toegestaan' }),
|
||||
});
|
||||
await store.load();
|
||||
|
||||
await store.approve();
|
||||
expect(store.busy()).toBe(false);
|
||||
expect(store.lastError()).toBe('niet toegestaan');
|
||||
});
|
||||
|
||||
it('a subsequent successful transition clears a prior Failed state', async () => {
|
||||
let approveResult: Result<string, BriefView> = { ok: false, error: 'eerste poging mislukt' };
|
||||
const store = setup({
|
||||
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
|
||||
save: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
|
||||
approve: (): Promise<Result<string, BriefView>> => Promise.resolve(approveResult),
|
||||
});
|
||||
await store.load();
|
||||
|
||||
await store.approve();
|
||||
expect(store.lastError()).toBe('eerste poging mislukt');
|
||||
|
||||
approveResult = {
|
||||
ok: true,
|
||||
value: {
|
||||
...view,
|
||||
brief: { ...brief, status: { tag: 'approved', approvedBy: 'u2', approvedAt: 't' } },
|
||||
},
|
||||
};
|
||||
await store.approve();
|
||||
expect(store.busy()).toBe(false);
|
||||
expect(store.lastError()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// --- WP-27: undo/redo history + rejection diff ---
|
||||
|
||||
function block(id: string, text: string): LetterBlock {
|
||||
return {
|
||||
type: 'freeText',
|
||||
blockId: id,
|
||||
content: { paragraphs: [{ nodes: [{ type: 'text', text }] }] },
|
||||
};
|
||||
}
|
||||
const kern = (blocks: LetterBlock[]) => ({
|
||||
sectionKey: 'kern',
|
||||
title: 'Kern',
|
||||
required: true,
|
||||
locked: false,
|
||||
blocks,
|
||||
});
|
||||
const filledBrief: Brief = { ...brief, sections: [kern([block('local-1', 'x')])] };
|
||||
const filledView: BriefView = { ...view, brief: filledBrief };
|
||||
|
||||
function loadedBrief(store: BriefStore): Brief {
|
||||
const s = store.model();
|
||||
if (s.tag !== 'loaded') throw new Error('not loaded');
|
||||
return s.brief;
|
||||
}
|
||||
|
||||
async function loadedStore(over: Partial<BriefAdapter> = {}): Promise<BriefStore> {
|
||||
const ok = (v: BriefView): Promise<Result<string, BriefView>> =>
|
||||
Promise.resolve({ ok: true, value: v });
|
||||
const store = setup({ load: () => ok(filledView), save: () => ok(filledView), ...over });
|
||||
await store.load();
|
||||
return store;
|
||||
}
|
||||
|
||||
describe('BriefStore undo/redo history', () => {
|
||||
it('records an edit, undoes and redoes it; buttons mirror; a no-op edit is not recorded', async () => {
|
||||
const store = await loadedStore();
|
||||
expect(store.canUndo()).toBe(false);
|
||||
|
||||
store.edit({ tag: 'BlockRemoved', blockId: 'local-1' });
|
||||
expect(loadedBrief(store).sections[0].blocks.length).toBe(0);
|
||||
expect(store.canUndo()).toBe(true);
|
||||
|
||||
store.undo();
|
||||
expect(loadedBrief(store).sections[0].blocks.length).toBe(1);
|
||||
expect(store.canRedo()).toBe(true);
|
||||
|
||||
store.redo();
|
||||
expect(loadedBrief(store).sections[0].blocks.length).toBe(0);
|
||||
|
||||
// A no-op edit (unknown block) changes nothing → leaves no dead history step.
|
||||
store.undo(); // back to 1 block, redo available
|
||||
store.edit({ tag: 'BlockRemoved', blockId: 'does-not-exist' });
|
||||
expect(store.canRedo()).toBe(true); // future NOT cleared by a no-op
|
||||
});
|
||||
|
||||
it('a new edit clears the redo future', async () => {
|
||||
const store = await loadedStore();
|
||||
store.edit({ tag: 'FreeTextBlockAdded', sectionKey: 'kern' });
|
||||
store.undo();
|
||||
expect(store.canRedo()).toBe(true);
|
||||
store.edit({ tag: 'FreeTextBlockAdded', sectionKey: 'kern' });
|
||||
expect(store.canRedo()).toBe(false);
|
||||
});
|
||||
|
||||
it('caps history at 50 snapshots', async () => {
|
||||
const store = await loadedStore();
|
||||
for (let i = 0; i < 55; i++) store.edit({ tag: 'FreeTextBlockAdded', sectionKey: 'kern' });
|
||||
let undos = 0;
|
||||
while (store.canUndo()) {
|
||||
store.undo();
|
||||
undos++;
|
||||
}
|
||||
expect(undos).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BriefStore rejection diff', () => {
|
||||
it('captures the rejected letter and diffs a subsequent edit against it', async () => {
|
||||
const submitted: Brief = {
|
||||
...filledBrief,
|
||||
status: { tag: 'submitted', submittedBy: 'u', submittedAt: 't' },
|
||||
};
|
||||
const rejected: Brief = {
|
||||
...filledBrief,
|
||||
status: { tag: 'rejected', rejectedBy: 'u2', rejectedAt: 't', comments: 'nee' },
|
||||
};
|
||||
const ok = (v: BriefView): Promise<Result<string, BriefView>> =>
|
||||
Promise.resolve({ ok: true, value: v });
|
||||
const store = setup({
|
||||
load: () => ok({ ...filledView, brief: submitted }),
|
||||
save: () => ok(filledView),
|
||||
reject: () => ok({ ...filledView, brief: rejected }),
|
||||
});
|
||||
await store.load();
|
||||
await store.reject('nee');
|
||||
expect(store.hasRejectionDiff()).toBe(false); // nothing changed yet
|
||||
|
||||
store.edit({
|
||||
tag: 'BlockContentEdited',
|
||||
blockId: 'local-1',
|
||||
content: { paragraphs: [{ nodes: [{ type: 'text', text: 'CHANGED' }] }] },
|
||||
});
|
||||
expect(store.blockDiffs().get('local-1')).toBe('changed');
|
||||
expect(store.removedSinceReject()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BriefStore.previewLetter', () => {
|
||||
// vi.spyOn reuses an existing spy (and its call history) if one is already on
|
||||
// the property — window.open/URL.createObjectURL must be restored between tests.
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('opens the composed letter in a new tab on success', async () => {
|
||||
const store = setup({
|
||||
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
|
||||
});
|
||||
await store.load();
|
||||
const blob = new Blob(['<html></html>'], { type: 'text/html' });
|
||||
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock');
|
||||
const open = vi.spyOn(window, 'open').mockImplementation(() => null);
|
||||
vi.spyOn(TestBed.inject(LetterPreviewAdapter), 'preview').mockResolvedValue({
|
||||
ok: true,
|
||||
value: blob,
|
||||
});
|
||||
|
||||
await store.previewLetter();
|
||||
expect(open).toHaveBeenCalledWith('blob:mock', '_blank');
|
||||
expect(store.lastError()).toBeNull();
|
||||
});
|
||||
|
||||
it('surfaces the error without opening a tab on failure', async () => {
|
||||
const store = setup({
|
||||
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
|
||||
});
|
||||
await store.load();
|
||||
const open = vi.spyOn(window, 'open').mockImplementation(() => null);
|
||||
vi.spyOn(TestBed.inject(LetterPreviewAdapter), 'preview').mockResolvedValue({
|
||||
ok: false,
|
||||
error: 'De voorvertoning kon niet worden geopend.',
|
||||
});
|
||||
|
||||
await store.previewLetter();
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
expect(store.lastError()).toBe('De voorvertoning kon niet worden geopend.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('BriefStore.revealBigNummer (PRD-0002 §5c)', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
// Loaded with a MASKED BIG-nummer, as the server ships it by default.
|
||||
const maskedView: BriefView = {
|
||||
...view,
|
||||
caseContext: { ...caseContext, bigNummer: '********601' },
|
||||
};
|
||||
|
||||
it('swaps the masked value for the revealed one on success', async () => {
|
||||
const store = setup({ load: () => Promise.resolve({ ok: true, value: maskedView }) });
|
||||
await store.load();
|
||||
expect(store.caseContext()?.bigNummer).toBe('********601');
|
||||
vi.spyOn(TestBed.inject(RevealBigNummerAdapter), 'reveal').mockResolvedValue({
|
||||
ok: true,
|
||||
value: '19012345601',
|
||||
});
|
||||
|
||||
await store.revealBigNummer();
|
||||
expect(store.caseContext()?.bigNummer).toBe('19012345601');
|
||||
expect(store.lastError()).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the value masked and surfaces the error on failure', async () => {
|
||||
const store = setup({ load: () => Promise.resolve({ ok: true, value: maskedView }) });
|
||||
await store.load();
|
||||
vi.spyOn(TestBed.inject(RevealBigNummerAdapter), 'reveal').mockResolvedValue({
|
||||
ok: false,
|
||||
error: 'geweigerd',
|
||||
});
|
||||
|
||||
await store.revealBigNummer();
|
||||
expect(store.caseContext()?.bigNummer).toBe('********601'); // unchanged
|
||||
expect(store.lastError()).toBe('geweigerd');
|
||||
});
|
||||
});
|
||||
|
||||
describe('BriefStore.flushPending (CanDeactivate guard / beforeunload)', () => {
|
||||
const okSave = () =>
|
||||
vi.fn(() => Promise.resolve({ ok: true, value: filledView } as Result<string, BriefView>));
|
||||
|
||||
it('flushes a pending debounced edit immediately and clears the pending flag', async () => {
|
||||
const save = okSave();
|
||||
const store = await loadedStore({ save });
|
||||
expect(store.hasPendingSave()).toBe(false);
|
||||
|
||||
store.edit({ tag: 'FreeTextBlockAdded', sectionKey: 'kern' });
|
||||
expect(store.hasPendingSave()).toBe(true); // 600ms debounce armed, not yet fired
|
||||
|
||||
await store.flushPending();
|
||||
expect(save).toHaveBeenCalledTimes(1); // no timer wait needed
|
||||
expect(store.hasPendingSave()).toBe(false); // timer consumed
|
||||
});
|
||||
|
||||
it('is a no-op when no edit is pending', async () => {
|
||||
const save = okSave();
|
||||
const store = await loadedStore({ save });
|
||||
await store.flushPending();
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,294 @@
|
||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
import { Result } from '@shared/kernel/fp';
|
||||
import { createStore } from '@shared/application/store';
|
||||
import { ActionState, SaveState } from '@shared/application/action-state';
|
||||
import { createHistory } from '@shared/application/history';
|
||||
import { createDebouncedSave } from '@shared/application/debounced-save';
|
||||
import { machineRemoteData } from '@shared/application/machine-remote-data';
|
||||
import {
|
||||
Brief,
|
||||
CaseContext,
|
||||
allDiagnostics,
|
||||
canSubmit,
|
||||
hasBlockingErrors,
|
||||
unresolvedPlaceholders,
|
||||
} from '@brief/domain/brief';
|
||||
import { BriefMsg, BriefState, initial, reduce } from '@brief/domain/brief.machine';
|
||||
import { BlockDiffKind, changedBlocks, diffBlocks } from '@brief/domain/brief-diff';
|
||||
import { OrgTemplate } from '@brief/domain/org-template';
|
||||
import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';
|
||||
import { LetterPreviewAdapter } from '@brief/infrastructure/letter-preview.adapter';
|
||||
import { RevealBigNummerAdapter } from '@brief/infrastructure/reveal-bignummer.adapter';
|
||||
import { uploadContentUrl } from '@shared/upload/upload.adapter';
|
||||
import { PendingSave, registerPendingSave } from '@shared/application/pending-saves';
|
||||
|
||||
/**
|
||||
* Root singleton for the letter: the Elm store (Model + dispatch), the derived
|
||||
* read-model, and the commands (effects) that call the adapter and dispatch the
|
||||
* outcome. Mirrors `BigProfileStore`. All of `canEdit`/`canApprove`/`canReject`/
|
||||
* `canSend`, `diagnostics`, `unresolved`, `canSubmit` are DERIVED here — never
|
||||
* stored. The permission flags come from the server's decision DTO (PRD-0002 phase
|
||||
* P1) via `BriefState.loaded.decisions` — this store never computes them itself.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class BriefStore implements PendingSave {
|
||||
private adapter = inject(BriefAdapter);
|
||||
private previewAdapter = inject(LetterPreviewAdapter);
|
||||
private revealAdapter = inject(RevealBigNummerAdapter);
|
||||
private store = createStore<BriefState, BriefMsg>(initial, reduce);
|
||||
|
||||
readonly model = this.store.model;
|
||||
|
||||
private actionState = signal<ActionState>({ tag: 'Idle' });
|
||||
readonly busy = computed(() => this.actionState().tag === 'Busy');
|
||||
readonly lastError = computed(() => {
|
||||
const s = this.actionState();
|
||||
return s.tag === 'Failed' ? s.error : null;
|
||||
});
|
||||
|
||||
/** Surfaced autosave state for the indicator + aria-live region. */
|
||||
readonly saveState = signal<SaveState>({ tag: 'Idle' });
|
||||
|
||||
/** Undo/redo is SHELL state, not machine state (WP-27): a `createHistory` stack of
|
||||
`Brief` snapshots (WP-31 extracted the mechanics). Only CONTENT edits are recorded
|
||||
(they flow through `edit()`); status transitions never enter history, or undo would
|
||||
replay workflow state. Restore re-dispatches the existing `Seed` Msg — zero machine
|
||||
changes. */
|
||||
private history = createHistory<Brief>(50);
|
||||
readonly canUndo = this.history.canUndo;
|
||||
readonly canRedo = this.history.canRedo;
|
||||
|
||||
/** The letter as it stood when it was REJECTED, captured shell-side (WP-27). The
|
||||
approver diffs it against the resubmitted letter. POC limit: in-memory only, so a
|
||||
full page reload loses it — a real system would persist the rejected revision. */
|
||||
private rejectionSnapshot = signal<Brief | null>(null);
|
||||
/** Changed/added/removed blocks since rejection — a pure fold over two snapshots. */
|
||||
readonly blockDiffs = computed<ReadonlyMap<string, BlockDiffKind>>(() => {
|
||||
const before = this.rejectionSnapshot();
|
||||
const after = this.brief();
|
||||
return before && after ? changedBlocks(diffBlocks(before, after)) : new Map();
|
||||
});
|
||||
/** Count of blocks removed since rejection — badged as a summary, since a removed
|
||||
block no longer renders inline. */
|
||||
readonly removedSinceReject = computed(
|
||||
() => [...this.blockDiffs().values()].filter((k) => k === 'removed').length,
|
||||
);
|
||||
readonly hasRejectionDiff = computed(() => this.blockDiffs().size > 0);
|
||||
|
||||
/** The org template the letter renders with (WP-24). Server-owned appearance data,
|
||||
not letter state — held beside the machine, never inside it (`brief.machine.ts`
|
||||
stays untouched by design). Set from every server view that carries it. */
|
||||
readonly orgTemplate = signal<OrgTemplate | null>(null);
|
||||
|
||||
/** The case (zorgverlener + aanvraag) this letter concerns — server-joined context for
|
||||
the behandel scherm header, not letter state. Set from every server view. */
|
||||
readonly caseContext = signal<CaseContext | null>(null);
|
||||
|
||||
/** The org logo's content URL for the letterhead, or null when the template has none. */
|
||||
readonly logoUrl = computed<string | null>(() => {
|
||||
const id = this.orgTemplate()?.logoDocumentId;
|
||||
return id ? uploadContentUrl(id) : null;
|
||||
});
|
||||
|
||||
/** The load lifecycle as `RemoteData`, for `<app-async>` — the machine keeps
|
||||
owning the letter's own domain lifecycle (draft/submitted/approved/…); this is
|
||||
purely a projection of its loading/failed tags onto the shared async seam. */
|
||||
readonly remoteData = computed(() => machineRemoteData(this.model()));
|
||||
|
||||
private brief = computed<Brief | null>(() => {
|
||||
const s = this.model();
|
||||
return s.tag === 'loaded' ? s.brief : null;
|
||||
});
|
||||
|
||||
readonly canEdit = computed(() => this.decisions()?.canEdit ?? false);
|
||||
readonly canApprove = computed(() => this.decisions()?.canApprove ?? false);
|
||||
readonly canReject = computed(() => this.decisions()?.canReject ?? false);
|
||||
readonly canSend = computed(() => this.decisions()?.canSend ?? false);
|
||||
/** Field-level PII reveal (PRD-0002 §5c), deny-by-default like the action gates. */
|
||||
readonly canRevealBigNummer = computed(() => this.decisions()?.canRevealBigNummer ?? false);
|
||||
|
||||
private decisions = computed(() => {
|
||||
const s = this.model();
|
||||
return s.tag === 'loaded' ? s.decisions : null;
|
||||
});
|
||||
readonly diagnostics = computed(() => (this.brief() ? allDiagnostics(this.brief()!) : []));
|
||||
readonly unresolved = computed(() => (this.brief() ? unresolvedPlaceholders(this.brief()!) : []));
|
||||
/** Submit is allowed only when required sections are filled AND no blocking errors. */
|
||||
readonly canSubmit = computed(() => {
|
||||
const b = this.brief();
|
||||
return !!b && canSubmit(b) && !hasBlockingErrors(this.diagnostics());
|
||||
});
|
||||
|
||||
async load() {
|
||||
const r = await this.adapter.load();
|
||||
if (r.ok) {
|
||||
this.orgTemplate.set(r.value.orgTemplate);
|
||||
this.caseContext.set(r.value.caseContext);
|
||||
this.history.clear();
|
||||
this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
|
||||
} else {
|
||||
this.store.dispatch({ tag: 'BriefLoadFailed', reason: r.error });
|
||||
}
|
||||
}
|
||||
|
||||
/** An edit: apply it optimistically in the pure reducer, then debounce-save. Records
|
||||
an undo step only when the reducer actually changed the brief (a no-op edit — e.g.
|
||||
a locked section — returns the same value and leaves no dead history step). */
|
||||
edit(msg: BriefMsg) {
|
||||
const before = this.brief();
|
||||
this.store.dispatch(msg);
|
||||
const after = this.brief();
|
||||
// Record only a real change: a no-op edit (e.g. a locked section) returns the same
|
||||
// value and leaves no dead history step.
|
||||
if (before && after && after !== before) this.history.record(before);
|
||||
this.debouncedSave.schedule();
|
||||
}
|
||||
|
||||
/** Undo/redo: restore a snapshot via the existing `Seed` Msg, then autosave. */
|
||||
undo() {
|
||||
this.restore((current) => this.history.undo(current));
|
||||
}
|
||||
redo() {
|
||||
this.restore((current) => this.history.redo(current));
|
||||
}
|
||||
private restore(step: (current: Brief) => Brief | undefined) {
|
||||
const s = this.model();
|
||||
if (s.tag !== 'loaded') return;
|
||||
const target = step(s.brief);
|
||||
if (target === undefined) return;
|
||||
this.store.dispatch({ tag: 'Seed', state: { ...s, brief: target } });
|
||||
this.debouncedSave.schedule();
|
||||
}
|
||||
|
||||
constructor() {
|
||||
// Register so the CanDeactivate guard / beforeunload handler can flush a pending
|
||||
// debounced edit before navigation or unload (see pending-saves.ts).
|
||||
registerPendingSave(this);
|
||||
}
|
||||
|
||||
// 600ms debounced autosave (the server is the store of record). Timer mechanics live in
|
||||
// the shared helper; `flushSave` below is the store-specific write + save-state (WP-31).
|
||||
private debouncedSave = createDebouncedSave({
|
||||
canSave: () => this.canEdit(),
|
||||
flush: () => this.flushSave(),
|
||||
});
|
||||
/** PendingSave: delegate to the debounce helper so the guard/unload can flush. */
|
||||
hasPendingSave = () => this.debouncedSave.hasPendingSave();
|
||||
flushPending = () => this.debouncedSave.flushPending();
|
||||
private async flushSave() {
|
||||
const b = this.brief();
|
||||
if (!b) return;
|
||||
this.saveState.set({ tag: 'Saving' });
|
||||
const r = await this.adapter.save(b.sections);
|
||||
if (r.ok) {
|
||||
this.saveState.set({ tag: 'Saved' });
|
||||
} else {
|
||||
this.actionState.set({ tag: 'Failed', error: r.error });
|
||||
this.saveState.set({ tag: 'Error' });
|
||||
}
|
||||
}
|
||||
|
||||
/** Retry a failed autosave — reuses the existing flush path, no new state (WP-27). */
|
||||
retrySave() {
|
||||
void this.flushSave();
|
||||
}
|
||||
|
||||
/** Demo "start over": recreate the brief server-side and load the fresh view. */
|
||||
async resetDemo() {
|
||||
this.actionState.set({ tag: 'Busy' });
|
||||
this.debouncedSave.cancel();
|
||||
const r = await this.adapter.reset();
|
||||
this.saveState.set({ tag: 'Idle' });
|
||||
if (r.ok) {
|
||||
this.actionState.set({ tag: 'Idle' });
|
||||
this.orgTemplate.set(r.value.orgTemplate);
|
||||
this.caseContext.set(r.value.caseContext);
|
||||
this.history.clear();
|
||||
this.rejectionSnapshot.set(null);
|
||||
this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
|
||||
} else {
|
||||
this.actionState.set({ tag: 'Failed', error: r.error });
|
||||
}
|
||||
}
|
||||
|
||||
submit = () => this.transition(() => this.adapter.submit());
|
||||
approve = () => this.transition(() => this.adapter.approve());
|
||||
reject = (comments: string) => this.transition(() => this.adapter.reject(comments));
|
||||
send = () => this.transition(() => this.adapter.send());
|
||||
|
||||
/** Explicit action, never a live re-render (PRD §8): opens the server-composed
|
||||
letter in a new tab. ponytail: the blob URL is never revoked — it's cheap and
|
||||
the tab outlives this call; not worth a teardown hook for a POC. */
|
||||
async previewLetter() {
|
||||
this.actionState.set({ tag: 'Busy' });
|
||||
const r = await this.previewAdapter.preview();
|
||||
if (!r.ok) {
|
||||
this.actionState.set({ tag: 'Failed', error: r.error });
|
||||
return;
|
||||
}
|
||||
this.actionState.set({ tag: 'Idle' });
|
||||
window.open(URL.createObjectURL(r.value), '_blank');
|
||||
}
|
||||
|
||||
/** Reveal the masked case BIG-nummer (PRD-0002 §5c). Server re-checks the capability
|
||||
+ step-up and audits the attempt; on success we swap the masked value in the
|
||||
already-loaded caseContext (a field update, not a reload). The step-up gesture
|
||||
itself is the UI's concern — this command just runs the audited server call. */
|
||||
async revealBigNummer() {
|
||||
const r = await this.revealAdapter.reveal();
|
||||
if (!r.ok) {
|
||||
this.actionState.set({ tag: 'Failed', error: r.error });
|
||||
return;
|
||||
}
|
||||
this.caseContext.update((c) => (c ? { ...c, bigNummer: r.value } : c));
|
||||
}
|
||||
|
||||
// A transition: flush any pending save, call the server (authoritative), then mirror
|
||||
// the returned status through the pure reducer's guarded transition.
|
||||
private async transition(action: () => Promise<Result<string, BriefView>>) {
|
||||
this.actionState.set({ tag: 'Busy' });
|
||||
this.debouncedSave.cancel();
|
||||
await this.flushSave();
|
||||
const r = await action();
|
||||
if (!r.ok) {
|
||||
this.actionState.set({ tag: 'Failed', error: r.error });
|
||||
return;
|
||||
}
|
||||
this.actionState.set({ tag: 'Idle' });
|
||||
this.applyServerStatus(r.value);
|
||||
}
|
||||
|
||||
private applyServerStatus(view: BriefView) {
|
||||
// `send` pins the org-template version server-side — mirror whatever came back.
|
||||
this.orgTemplate.set(view.orgTemplate);
|
||||
this.caseContext.set(view.caseContext);
|
||||
const { brief, decisions } = view;
|
||||
const s = brief.status;
|
||||
switch (s.tag) {
|
||||
case 'submitted':
|
||||
this.store.dispatch({ tag: 'Submitted', by: s.submittedBy, at: s.submittedAt, decisions });
|
||||
break;
|
||||
case 'approved':
|
||||
this.store.dispatch({ tag: 'Approved', by: s.approvedBy, at: s.approvedAt, decisions });
|
||||
break;
|
||||
case 'rejected':
|
||||
// Capture the letter as-rejected for the resubmission diff (WP-27). This is the
|
||||
// "before" snapshot the approver later compares against.
|
||||
this.rejectionSnapshot.set(brief);
|
||||
this.store.dispatch({
|
||||
tag: 'Rejected',
|
||||
by: s.rejectedBy,
|
||||
at: s.rejectedAt,
|
||||
comments: s.comments,
|
||||
decisions,
|
||||
});
|
||||
break;
|
||||
case 'sent':
|
||||
this.store.dispatch({ tag: 'Sent', at: s.sentAt, decisions });
|
||||
break;
|
||||
case 'draft':
|
||||
// reopened by a save on a rejected letter — reducer already handled it locally.
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import { Injectable, computed, effect, inject, signal } from '@angular/core';
|
||||
import { createStore } from '@shared/application/store';
|
||||
import { ActionState, SaveState } from '@shared/application/action-state';
|
||||
import { createDebouncedSave } from '@shared/application/debounced-save';
|
||||
import { machineRemoteData } from '@shared/application/machine-remote-data';
|
||||
import { UploadAdapter } from '@shared/upload/upload.adapter';
|
||||
import { UploadShellService } from '@shared/upload/upload-shell.service';
|
||||
import { UploadMsg, initialUpload, rejectReason } from '@shared/upload/upload.machine';
|
||||
import {
|
||||
MARGIN_MAX_MM,
|
||||
MARGIN_MIN_MM,
|
||||
OrgTemplate,
|
||||
SubOrgSummary,
|
||||
} from '@brief/domain/org-template';
|
||||
import {
|
||||
OrgTemplateMsg,
|
||||
OrgTemplateState,
|
||||
initial,
|
||||
reduce,
|
||||
} from '@brief/domain/org-template.machine';
|
||||
import { OrgTemplateAdapter } from '@brief/infrastructure/org-template.adapter';
|
||||
import { PendingSave, registerPendingSave } from '@shared/application/pending-saves';
|
||||
|
||||
type LoadedState = Extract<OrgTemplateState, { tag: 'loaded' }>;
|
||||
|
||||
const LOGO_CATEGORY = 'org-logo';
|
||||
const NO_SUBORGS = $localize`:@@orgTemplate.noSubOrgs:Er zijn geen organisatiesjablonen om te beheren.`;
|
||||
|
||||
/**
|
||||
* Root singleton for the admin org-template editor (WP-26). The Elm machine owns the
|
||||
* editable draft; commands here do the debounced save, publish (impact-confirm),
|
||||
* rollback and proefbrief, then dispatch the outcome — the reducer stays pure. The
|
||||
* logo upload reuses the shared upload transport; its completion mutates the draft
|
||||
* (in the reducer) and triggers a save (here). Mirrors `BriefStore`.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class OrgTemplateStore implements PendingSave {
|
||||
private adapter = inject(OrgTemplateAdapter);
|
||||
private uploadAdapter = inject(UploadAdapter);
|
||||
private shell = inject(UploadShellService);
|
||||
private store = createStore<OrgTemplateState, OrgTemplateMsg>(initial, reduce);
|
||||
|
||||
readonly model = this.store.model;
|
||||
|
||||
readonly subOrgs = signal<readonly SubOrgSummary[]>([]);
|
||||
readonly selectedSubOrgId = signal<string | null>(null);
|
||||
|
||||
private actionState = signal<ActionState>({ tag: 'Idle' });
|
||||
readonly busy = computed(() => this.actionState().tag === 'Busy');
|
||||
readonly lastError = computed(() => {
|
||||
const s = this.actionState();
|
||||
return s.tag === 'Failed' ? s.error : null;
|
||||
});
|
||||
readonly saveState = signal<SaveState>({ tag: 'Idle' });
|
||||
|
||||
/** The publish impact-confirm gate (PRD §7h: show N affected letters before POST). */
|
||||
readonly pendingPublish = signal(false);
|
||||
|
||||
readonly remoteData = computed(() => machineRemoteData(this.model()));
|
||||
|
||||
private loaded = computed<LoadedState | null>(() => {
|
||||
const s = this.model();
|
||||
return s.tag === 'loaded' ? s : null;
|
||||
});
|
||||
readonly draft = computed<OrgTemplate | null>(() => this.loaded()?.draft ?? null);
|
||||
readonly uploadState = computed(() => this.loaded()?.upload ?? initialUpload);
|
||||
readonly history = computed(() => this.loaded()?.history ?? []);
|
||||
readonly publishedVersion = computed(() => this.loaded()?.publishedVersion ?? 0);
|
||||
readonly unsentBriefs = computed(() => this.loaded()?.unsentBriefs ?? 0);
|
||||
readonly logoUrl = computed<string | null>(() => {
|
||||
const id = this.draft()?.logoDocumentId;
|
||||
return id ? this.uploadAdapter.contentUrl(id) : null;
|
||||
});
|
||||
|
||||
/** Client-side mirror of the server rules (`OrgTemplateRules`) for instant feedback;
|
||||
the server re-validates and stays the authority — publish is gated on this. */
|
||||
readonly draftValid = computed(() => {
|
||||
const d = this.draft();
|
||||
if (!d) return false;
|
||||
const marginsOk = [
|
||||
d.margins.topMm,
|
||||
d.margins.rightMm,
|
||||
d.margins.bottomMm,
|
||||
d.margins.leftMm,
|
||||
].every((v) => v >= MARGIN_MIN_MM && v <= MARGIN_MAX_MM);
|
||||
return d.orgName.trim().length > 0 && d.signatureName.trim().length > 0 && marginsOk;
|
||||
});
|
||||
|
||||
// Live File blobs keyed by localId — needed to retry a failed upload (a reducer can't hold these).
|
||||
private files = new Map<string, File>();
|
||||
private categoriesRes = this.uploadAdapter.categoriesResource('org-template');
|
||||
|
||||
constructor() {
|
||||
// Feed the logo category into the machine's upload sub-state once loaded. Tracks
|
||||
// `model()` so it re-fires after a sub-org switch reseeds an empty upload state;
|
||||
// the length guard makes it idempotent (no dispatch loop).
|
||||
effect(() => {
|
||||
const s = this.model();
|
||||
if (s.tag !== 'loaded' || s.upload.categories.length > 0) return;
|
||||
const status = this.categoriesRes.status();
|
||||
if (status === 'resolved' || status === 'local')
|
||||
this.dispatchUpload({
|
||||
type: 'CategoriesLoaded',
|
||||
categories: this.categoriesRes.value() ?? [],
|
||||
});
|
||||
});
|
||||
// Flush a pending debounced edit before navigation/unload (see pending-saves.ts).
|
||||
registerPendingSave(this);
|
||||
}
|
||||
|
||||
async load() {
|
||||
this.store.dispatch({ tag: 'Loading' });
|
||||
const list = await this.adapter.list();
|
||||
if (!list.ok) {
|
||||
this.store.dispatch({ tag: 'LoadFailed', reason: list.error });
|
||||
return;
|
||||
}
|
||||
this.subOrgs.set(list.value);
|
||||
const first = list.value[0];
|
||||
if (!first) {
|
||||
this.store.dispatch({ tag: 'LoadFailed', reason: NO_SUBORGS });
|
||||
return;
|
||||
}
|
||||
await this.selectSubOrg(first.subOrgId);
|
||||
}
|
||||
|
||||
async selectSubOrg(subOrgId: string) {
|
||||
this.selectedSubOrgId.set(subOrgId);
|
||||
this.saveState.set({ tag: 'Idle' });
|
||||
this.debouncedSave.cancel();
|
||||
this.store.dispatch({ tag: 'Loading' });
|
||||
const r = await this.adapter.load(subOrgId);
|
||||
if (r.ok) this.store.dispatch({ tag: 'DraftLoaded', view: r.value });
|
||||
else this.store.dispatch({ tag: 'LoadFailed', reason: r.error });
|
||||
}
|
||||
|
||||
/** An in-place canvas or margin edit: apply optimistically, then debounce-save. */
|
||||
edit(msg: OrgTemplateMsg) {
|
||||
this.store.dispatch(msg);
|
||||
this.debouncedSave.schedule();
|
||||
}
|
||||
|
||||
// 600ms debounced autosave (same idiom as BriefStore, WP-31). Timer mechanics live in the
|
||||
// shared helper; `flushSave` below is the store-specific write + save-state.
|
||||
private debouncedSave = createDebouncedSave({
|
||||
canSave: () => this.loaded() !== null,
|
||||
flush: () => this.flushSave(),
|
||||
});
|
||||
/** PendingSave: delegate to the debounce helper so the guard/unload can flush. */
|
||||
hasPendingSave = () => this.debouncedSave.hasPendingSave();
|
||||
flushPending = () => this.debouncedSave.flushPending();
|
||||
private async flushSave() {
|
||||
const s = this.loaded();
|
||||
if (!s || !s.dirty) return;
|
||||
const { subOrgId, draft } = s;
|
||||
this.saveState.set({ tag: 'Saving' });
|
||||
const r = await this.adapter.save(subOrgId, draft);
|
||||
if (r.ok) {
|
||||
this.saveState.set({ tag: 'Saved' });
|
||||
this.store.dispatch({ tag: 'DraftSaved', savedDraft: draft });
|
||||
} else {
|
||||
this.saveState.set({ tag: 'Error' });
|
||||
this.actionState.set({ tag: 'Failed', error: r.error });
|
||||
}
|
||||
}
|
||||
|
||||
// --- publish (impact-confirm) / rollback / proefbrief ---
|
||||
|
||||
requestPublish() {
|
||||
this.pendingPublish.set(true);
|
||||
}
|
||||
cancelPublish() {
|
||||
this.pendingPublish.set(false);
|
||||
}
|
||||
async confirmPublish() {
|
||||
const s = this.loaded();
|
||||
if (!s) return;
|
||||
this.pendingPublish.set(false);
|
||||
this.actionState.set({ tag: 'Busy' });
|
||||
this.debouncedSave.cancel();
|
||||
await this.flushSave(); // publish the saved draft — flush any pending edit first
|
||||
const r = await this.adapter.publish(s.subOrgId);
|
||||
if (!r.ok) {
|
||||
this.actionState.set({ tag: 'Failed', error: r.error });
|
||||
return;
|
||||
}
|
||||
this.actionState.set({ tag: 'Idle' });
|
||||
await this.selectSubOrg(s.subOrgId); // reload: new version, history, unsentBriefs = 0
|
||||
}
|
||||
|
||||
async rollback(version: number) {
|
||||
const s = this.loaded();
|
||||
if (!s) return;
|
||||
this.actionState.set({ tag: 'Busy' });
|
||||
this.debouncedSave.cancel();
|
||||
const r = await this.adapter.rollback(s.subOrgId, version);
|
||||
if (!r.ok) {
|
||||
this.actionState.set({ tag: 'Failed', error: r.error });
|
||||
return;
|
||||
}
|
||||
this.actionState.set({ tag: 'Idle' });
|
||||
this.store.dispatch({ tag: 'DraftLoaded', view: r.value }); // old version copied into draft
|
||||
}
|
||||
|
||||
async proefbrief() {
|
||||
const s = this.loaded();
|
||||
if (!s) return;
|
||||
this.actionState.set({ tag: 'Busy' });
|
||||
this.debouncedSave.cancel();
|
||||
await this.flushSave(); // the proefbrief renders the server's draft
|
||||
const r = await this.adapter.proefbrief(s.subOrgId);
|
||||
if (!r.ok) {
|
||||
this.actionState.set({ tag: 'Failed', error: r.error });
|
||||
return;
|
||||
}
|
||||
this.actionState.set({ tag: 'Idle' });
|
||||
window.open(URL.createObjectURL(r.value), '_blank');
|
||||
}
|
||||
|
||||
// --- logo upload (reuses the shared upload transport; single `org-logo` file) ---
|
||||
|
||||
onLogoSelected(files: File[]) {
|
||||
const s = this.loaded();
|
||||
const cat = s?.upload.categories.find((c) => c.categoryId === LOGO_CATEGORY);
|
||||
const file = files[0];
|
||||
if (!s || !cat || !file) return;
|
||||
const reason = rejectReason(cat, { type: file.type, sizeMb: file.size / 1e6 });
|
||||
if (reason) {
|
||||
this.dispatchUpload({ type: 'FileRejected', categoryId: cat.categoryId, reason });
|
||||
return;
|
||||
}
|
||||
const localId = crypto.randomUUID();
|
||||
this.files.set(localId, file);
|
||||
this.dispatchUpload({
|
||||
type: 'FileSelected',
|
||||
categoryId: cat.categoryId,
|
||||
localId,
|
||||
fileName: file.name,
|
||||
fileSizeMb: file.size / 1e6,
|
||||
});
|
||||
this.shell.upload(
|
||||
{ localId, categoryId: cat.categoryId, wizardId: 'org-template', file },
|
||||
(m) => this.onUploadMsg(m),
|
||||
);
|
||||
}
|
||||
|
||||
onLogoRemoved(localId: string) {
|
||||
this.shell.cancel([localId]);
|
||||
this.files.delete(localId);
|
||||
this.onUploadMsg({ type: 'UploadRemoved', localId });
|
||||
}
|
||||
|
||||
onLogoRetry(localId: string) {
|
||||
const file = this.files.get(localId);
|
||||
const up = this.loaded()?.upload.uploads.find((u) => u.localId === localId);
|
||||
if (!file || !up) return;
|
||||
this.dispatchUpload({ type: 'UploadRetried', localId });
|
||||
this.shell.upload({ localId, categoryId: up.categoryId, wizardId: 'org-template', file }, (m) =>
|
||||
this.onUploadMsg(m),
|
||||
);
|
||||
}
|
||||
|
||||
private dispatchUpload(msg: UploadMsg) {
|
||||
this.store.dispatch({ tag: 'Upload', msg });
|
||||
}
|
||||
/** Upload effects arriving from the transport: a finished/removed logo edits the
|
||||
draft (in the reducer) and needs persisting. */
|
||||
private onUploadMsg(msg: UploadMsg) {
|
||||
this.dispatchUpload(msg);
|
||||
if (msg.type === 'UploadComplete' || msg.type === 'UploadRemoved')
|
||||
this.debouncedSave.schedule();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Besluit, LetterBlock, LibraryPassage } from './brief';
|
||||
import { besluitGuidance, inferSelection, passagesForBesluit, redenenFor } from './besluit';
|
||||
|
||||
const block = (t: string): LibraryPassage['content'] => ({
|
||||
paragraphs: [{ nodes: [{ type: 'text', text: t }] }],
|
||||
});
|
||||
|
||||
const p = (over: Partial<LibraryPassage>): LibraryPassage => ({
|
||||
passageId: over.passageId ?? 'x',
|
||||
scope: 'global',
|
||||
sectionKey: 'kern',
|
||||
label: over.label ?? 'x',
|
||||
content: block('x'),
|
||||
version: 1,
|
||||
...over,
|
||||
});
|
||||
|
||||
const lib: LibraryPassage[] = [
|
||||
p({ passageId: 'intro', besluit: undefined }), // shared, any besluit
|
||||
p({ passageId: 'pos', besluit: 'positief' }),
|
||||
p({ passageId: 'neg', besluit: 'negatief' }),
|
||||
p({
|
||||
passageId: 'neg-scholing',
|
||||
besluit: 'negatief',
|
||||
reason: 'onvoldoende_scholing',
|
||||
label: 'Onvoldoende scholing',
|
||||
}),
|
||||
p({
|
||||
passageId: 'neg-gegevens',
|
||||
besluit: 'negatief',
|
||||
reason: 'onjuiste_gegevens',
|
||||
label: 'Onjuiste gegevens',
|
||||
}),
|
||||
p({ passageId: 'slot-x', sectionKey: 'slot', besluit: undefined }), // not kern → never offered
|
||||
];
|
||||
|
||||
describe('passagesForBesluit', () => {
|
||||
it('positief = shared intro + the positief passage, no negatief/reason passages', () => {
|
||||
const ids = passagesForBesluit(lib, 'positief', []).map((x) => x.passageId);
|
||||
expect(ids).toEqual(['intro', 'pos']);
|
||||
});
|
||||
|
||||
it('negatief without redenen = intro + negatief base, but no reason-specific passages', () => {
|
||||
const ids = passagesForBesluit(lib, 'negatief', []).map((x) => x.passageId);
|
||||
expect(ids).toEqual(['intro', 'neg']);
|
||||
});
|
||||
|
||||
it('negatief with a reden ticked includes that reason-specific passage only', () => {
|
||||
const ids = passagesForBesluit(lib, 'negatief', ['onvoldoende_scholing']).map(
|
||||
(x) => x.passageId,
|
||||
);
|
||||
expect(ids).toEqual(['intro', 'neg', 'neg-scholing']);
|
||||
});
|
||||
|
||||
it('preserves library order (= reading order)', () => {
|
||||
const ids = passagesForBesluit(lib, 'negatief', [
|
||||
'onjuiste_gegevens',
|
||||
'onvoldoende_scholing',
|
||||
]).map((x) => x.passageId);
|
||||
expect(ids).toEqual(['intro', 'neg', 'neg-scholing', 'neg-gegevens']);
|
||||
});
|
||||
|
||||
it('never offers non-kern passages', () => {
|
||||
expect(passagesForBesluit(lib, 'positief', []).some((x) => x.sectionKey !== 'kern')).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('redenenFor', () => {
|
||||
it('derives reason checkboxes (code + label) from the negatief reason passages', () => {
|
||||
expect(redenenFor(lib, 'negatief')).toEqual([
|
||||
{ code: 'onvoldoende_scholing', label: 'Onvoldoende scholing' },
|
||||
{ code: 'onjuiste_gegevens', label: 'Onjuiste gegevens' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('positief has no reason-specific redenen', () => {
|
||||
expect(redenenFor(lib, 'positief')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('inferSelection', () => {
|
||||
// Build the kern blocks a besluit would produce, then read the selection back off them.
|
||||
const kern = (besluit: Besluit, reasons: string[]): LetterBlock[] =>
|
||||
passagesForBesluit(lib, besluit, reasons).map((p, i) => ({
|
||||
type: 'passage',
|
||||
blockId: `local-${i + 1}`,
|
||||
sourcePassageId: p.passageId,
|
||||
sourceVersion: p.version,
|
||||
content: p.content,
|
||||
edited: false,
|
||||
}));
|
||||
|
||||
it('round-trips a positief selection', () => {
|
||||
expect(inferSelection(kern('positief', []), lib)).toEqual({ besluit: 'positief', reasons: [] });
|
||||
});
|
||||
|
||||
it('round-trips a negatief selection with redenen (in order)', () => {
|
||||
const blocks = kern('negatief', ['onjuiste_gegevens', 'onvoldoende_scholing']);
|
||||
expect(inferSelection(blocks, lib)).toEqual({
|
||||
besluit: 'negatief',
|
||||
reasons: ['onvoldoende_scholing', 'onjuiste_gegevens'], // library order
|
||||
});
|
||||
});
|
||||
|
||||
it('an empty kern (nothing chosen) infers no besluit', () => {
|
||||
expect(inferSelection([], lib)).toEqual({ besluit: null, reasons: [] });
|
||||
});
|
||||
|
||||
it('ignores free-text blocks and unknown passage ids', () => {
|
||||
const blocks: LetterBlock[] = [
|
||||
{ type: 'freeText', blockId: 'local-9', content: block('vrij') },
|
||||
...kern('positief', []),
|
||||
];
|
||||
expect(inferSelection(blocks, lib)).toEqual({ besluit: 'positief', reasons: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('besluitGuidance', () => {
|
||||
it('positief: counts inserted passages, no reden needed (positief has no redenen)', () => {
|
||||
expect(besluitGuidance(lib, 'positief', [])).toEqual({ insertedCount: 2, needsReason: false });
|
||||
});
|
||||
|
||||
it('negatief without a reden: flags that a reden must be chosen', () => {
|
||||
expect(besluitGuidance(lib, 'negatief', [])).toEqual({ insertedCount: 2, needsReason: true });
|
||||
});
|
||||
|
||||
it('negatief with a reden: no longer flags, and the reason passage is counted', () => {
|
||||
expect(besluitGuidance(lib, 'negatief', ['onvoldoende_scholing'])).toEqual({
|
||||
insertedCount: 3,
|
||||
needsReason: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Besluit, LetterBlock, LibraryPassage } from './brief';
|
||||
|
||||
/**
|
||||
* Guided drafting: given the behandelaar's besluit + chosen redenen, which library
|
||||
* passages belong in the kern. This is the "don't make them a detective" logic —
|
||||
* pure, so it's unit-tested directly and the UI just renders the result.
|
||||
*
|
||||
* A passage is offered when:
|
||||
* - it has no besluit tag (a shared intro/toelichting, relevant to any besluit), OR
|
||||
* - its besluit matches AND either it isn't reason-specific, or its reason is ticked.
|
||||
*
|
||||
* Kept in library order (server order = reading order), so an inserted set already
|
||||
* flows as a letter.
|
||||
*/
|
||||
export function passagesForBesluit(
|
||||
passages: readonly LibraryPassage[],
|
||||
besluit: Besluit,
|
||||
reasons: readonly string[],
|
||||
): LibraryPassage[] {
|
||||
return passages.filter((p) => {
|
||||
if (p.sectionKey !== 'kern') return false;
|
||||
if (p.besluit === undefined) return true; // shared, any besluit
|
||||
if (p.besluit !== besluit) return false;
|
||||
if (p.reason === undefined) return true; // besluit-level, not reason-specific
|
||||
return reasons.includes(p.reason);
|
||||
});
|
||||
}
|
||||
|
||||
/** A selectable reden for a besluit, derived from the reason-specific passages — no
|
||||
separate catalog. `code` drives `passagesForBesluit`; `label` is the checkbox text.
|
||||
ponytail: assumes one passage per reason (true for the seed); dedupes on code if not. */
|
||||
export interface Reden {
|
||||
readonly code: string;
|
||||
readonly label: string;
|
||||
}
|
||||
|
||||
/** Visible assistance for the behandelaar on top of the silent auto-insert: how many kern
|
||||
standaardteksten the current besluit+redenen produced, and whether a reden still needs
|
||||
choosing (the besluit has reason-specific motivering passages but none is ticked). Pure
|
||||
DATA — the component maps it to localized copy. */
|
||||
export interface BesluitGuidance {
|
||||
readonly insertedCount: number;
|
||||
readonly needsReason: boolean;
|
||||
}
|
||||
|
||||
export function besluitGuidance(
|
||||
passages: readonly LibraryPassage[],
|
||||
besluit: Besluit,
|
||||
reasons: readonly string[],
|
||||
): BesluitGuidance {
|
||||
return {
|
||||
insertedCount: passagesForBesluit(passages, besluit, reasons).length,
|
||||
needsReason: redenenFor(passages, besluit).length > 0 && reasons.length === 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function redenenFor(passages: readonly LibraryPassage[], besluit: Besluit): Reden[] {
|
||||
const seen = new Set<string>();
|
||||
const out: Reden[] = [];
|
||||
for (const p of passages) {
|
||||
if (p.sectionKey !== 'kern' || p.besluit !== besluit || p.reason === undefined) continue;
|
||||
if (seen.has(p.reason)) continue;
|
||||
seen.add(p.reason);
|
||||
out.push({ code: p.reason, label: p.label });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The inverse of `passagesForBesluit`: read the current besluit + redenen back off the
|
||||
* kern's passage blocks (each carries its `sourcePassageId`), so the panel can re-seed
|
||||
* itself on reload/undo without persisting the selection separately. Kern passage blocks
|
||||
* are besluit-derived by construction (the only way passages enter the kern), so this
|
||||
* round-trips: `inferSelection(kern(passagesForBesluit(lib, b, r)), lib) === { b, r }`.
|
||||
* Free-text blocks carry no provenance and are ignored.
|
||||
*/
|
||||
export function inferSelection(
|
||||
kernBlocks: readonly LetterBlock[],
|
||||
passages: readonly LibraryPassage[],
|
||||
): { besluit: Besluit | null; reasons: string[] } {
|
||||
const byId = new Map(passages.map((p) => [p.passageId, p]));
|
||||
let besluit: Besluit | null = null;
|
||||
const reasons: string[] = [];
|
||||
for (const b of kernBlocks) {
|
||||
if (b.type !== 'passage') continue;
|
||||
const source = byId.get(b.sourcePassageId);
|
||||
if (!source) continue;
|
||||
if (source.besluit !== undefined) besluit = source.besluit;
|
||||
if (source.reason !== undefined && !reasons.includes(source.reason))
|
||||
reasons.push(source.reason);
|
||||
}
|
||||
return { besluit, reasons };
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Brief, LetterBlock } from './brief';
|
||||
import { diffBlocks, changedBlocks } from './brief-diff';
|
||||
|
||||
function block(id: string, text: string): LetterBlock {
|
||||
return {
|
||||
type: 'freeText',
|
||||
blockId: id,
|
||||
content: { paragraphs: [{ nodes: [{ type: 'text', text }] }] },
|
||||
};
|
||||
}
|
||||
|
||||
function brief(blocks: LetterBlock[]): Brief {
|
||||
return {
|
||||
briefId: 'b1',
|
||||
beroep: 'arts',
|
||||
templateId: 't1',
|
||||
placeholders: [],
|
||||
sections: [{ sectionKey: 'kern', title: 'Kern', required: true, locked: false, blocks }],
|
||||
status: { tag: 'draft' },
|
||||
drafterId: 'u1',
|
||||
};
|
||||
}
|
||||
|
||||
describe('diffBlocks', () => {
|
||||
it('marks added, removed, changed and unchanged by blockId', () => {
|
||||
const before = brief([block('local-1', 'a'), block('local-2', 'b'), block('local-3', 'c')]);
|
||||
const after = brief([block('local-1', 'a'), block('local-2', 'B!'), block('local-4', 'd')]);
|
||||
const diffs = diffBlocks(before, after);
|
||||
const byId = new Map(diffs.map((d) => [d.blockId, d.kind]));
|
||||
expect(byId.get('local-1')).toBe('unchanged');
|
||||
expect(byId.get('local-2')).toBe('changed');
|
||||
expect(byId.get('local-3')).toBe('removed'); // gone from after
|
||||
expect(byId.get('local-4')).toBe('added'); // new in after
|
||||
});
|
||||
|
||||
it('changedBlocks drops unchanged and keeps added/removed/changed', () => {
|
||||
const before = brief([block('local-1', 'a'), block('local-2', 'b')]);
|
||||
const after = brief([block('local-1', 'a'), block('local-2', 'B'), block('local-3', 'c')]);
|
||||
const map = changedBlocks(diffBlocks(before, after));
|
||||
expect(map.has('local-1')).toBe(false);
|
||||
expect(map.get('local-2')).toBe('changed');
|
||||
expect(map.get('local-3')).toBe('added');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Brief, LetterBlock, allBlocks } from './brief';
|
||||
|
||||
/**
|
||||
* The rejection diff as a PURE function over two immutable `Brief` values — the whole
|
||||
* teaching payload of WP-27: because state is one value, "what changed since the letter
|
||||
* was rejected" is just a fold over two snapshots, no change-tracking bookkeeping.
|
||||
*
|
||||
* Blocks are matched by `blockId` (stable `local-N`/seed ids):
|
||||
* - in `after` but not `before` → `added`
|
||||
* - in `before` but not `after` → `removed`
|
||||
* - in both, different content → `changed`
|
||||
* - in both, same content → `unchanged`
|
||||
*/
|
||||
|
||||
export type BlockDiffKind = 'added' | 'removed' | 'changed' | 'unchanged';
|
||||
|
||||
export interface BlockDiff {
|
||||
readonly blockId: string;
|
||||
readonly kind: BlockDiffKind;
|
||||
}
|
||||
|
||||
/** Content equality by value. Blocks are JSON-shaped immutable trees, so a canonical
|
||||
stringify is an honest deep-equal here (no functions, no cycles). */
|
||||
function contentEqual(a: LetterBlock, b: LetterBlock): boolean {
|
||||
return JSON.stringify(a.content) === JSON.stringify(b.content);
|
||||
}
|
||||
|
||||
export function diffBlocks(before: Brief, after: Brief): BlockDiff[] {
|
||||
const beforeById = new Map(allBlocks(before).map((b) => [b.blockId, b]));
|
||||
const afterById = new Map(allBlocks(after).map((b) => [b.blockId, b]));
|
||||
const out: BlockDiff[] = [];
|
||||
for (const a of afterById.values()) {
|
||||
const b = beforeById.get(a.blockId);
|
||||
out.push({
|
||||
blockId: a.blockId,
|
||||
kind: !b ? 'added' : contentEqual(a, b) ? 'unchanged' : 'changed',
|
||||
});
|
||||
}
|
||||
for (const b of beforeById.values()) {
|
||||
if (!afterById.has(b.blockId)) out.push({ blockId: b.blockId, kind: 'removed' });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Lookup of only the blocks that changed since rejection (drops `unchanged`), for
|
||||
badging the canvas. Keyed by `blockId`; removed ids are present too (the caller
|
||||
surfaces them as a count — a removed block no longer renders inline). */
|
||||
export function changedBlocks(diffs: readonly BlockDiff[]): ReadonlyMap<string, BlockDiffKind> {
|
||||
return new Map(diffs.filter((d) => d.kind !== 'unchanged').map((d) => [d.blockId, d.kind]));
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Besluit, Brief, BriefDecisions, BriefStatus, LibraryPassage } from './brief';
|
||||
import { RichTextBlock } from '@shared/kernel/rich-text';
|
||||
import { PlaceholderDef } from './placeholders';
|
||||
import { BriefState, reduce } from './brief.machine';
|
||||
|
||||
const placeholders: PlaceholderDef[] = [
|
||||
{ key: 'naam', label: 'Naam', autoResolvable: true },
|
||||
{ key: 'reden', label: 'Reden', autoResolvable: false },
|
||||
];
|
||||
|
||||
const text = (t: string): RichTextBlock => ({
|
||||
paragraphs: [{ nodes: [{ type: 'text', text: t }] }],
|
||||
});
|
||||
|
||||
const libPassage = (
|
||||
id: string,
|
||||
sectionKey: string,
|
||||
extra: Partial<LibraryPassage> = {},
|
||||
): LibraryPassage => ({
|
||||
passageId: id,
|
||||
scope: 'global',
|
||||
sectionKey,
|
||||
label: `Passage ${id}`,
|
||||
content: text(`inhoud ${id}`),
|
||||
version: 3,
|
||||
...extra,
|
||||
});
|
||||
|
||||
// A besluit-tagged kern library: `intro` is shared (any besluit), `pos`/`neg` are
|
||||
// besluit-level, `neg-r` is reason-specific. This drives every `BesluitSelected` here.
|
||||
const lib: LibraryPassage[] = [
|
||||
libPassage('intro', 'kern'),
|
||||
libPassage('pos', 'kern', { besluit: 'positief' }),
|
||||
libPassage('neg', 'kern', { besluit: 'negatief' }),
|
||||
libPassage('neg-r', 'kern', { besluit: 'negatief', reason: 'r1', label: 'Reden 1' }),
|
||||
];
|
||||
|
||||
const besluit = (b: Besluit | null, reasons: string[] = []) =>
|
||||
({ tag: 'BesluitSelected', besluit: b, reasons }) as const;
|
||||
|
||||
function briefWith(status: BriefStatus, sections?: Brief['sections']): Brief {
|
||||
return {
|
||||
briefId: 'b1',
|
||||
beroep: 'arts',
|
||||
templateId: 't1',
|
||||
placeholders,
|
||||
sections: sections ?? [
|
||||
{ sectionKey: 'kern', title: 'Kern', required: true, locked: false, blocks: [] },
|
||||
{ sectionKey: 'slot', title: 'Slot', required: false, locked: false, blocks: [] },
|
||||
],
|
||||
status,
|
||||
drafterId: 'u1',
|
||||
};
|
||||
}
|
||||
|
||||
// A machine test cares about status transitions, not who may act — a fixed,
|
||||
// unrestrictive fixture keeps every existing assertion focused on that.
|
||||
const decisions: BriefDecisions = {
|
||||
canEdit: true,
|
||||
canApprove: true,
|
||||
canReject: true,
|
||||
canSend: true,
|
||||
canRevealBigNummer: true,
|
||||
};
|
||||
|
||||
const loaded = (
|
||||
status: BriefStatus = { tag: 'draft' },
|
||||
sections?: Brief['sections'],
|
||||
): BriefState => ({
|
||||
tag: 'loaded',
|
||||
brief: briefWith(status, sections),
|
||||
availablePassages: lib,
|
||||
decisions,
|
||||
});
|
||||
|
||||
const sectionBlocks = (s: BriefState, key: string) =>
|
||||
s.tag === 'loaded' ? s.brief.sections.find((x) => x.sectionKey === key)!.blocks : [];
|
||||
|
||||
const passageIds = (s: BriefState, key: string) =>
|
||||
sectionBlocks(s, key)
|
||||
.filter((b) => b.type === 'passage')
|
||||
.map((b) => (b.type === 'passage' ? b.sourcePassageId : ''));
|
||||
|
||||
describe('brief.machine reduce', () => {
|
||||
it('BriefLoaded moves loading to loaded', () => {
|
||||
expect(
|
||||
reduce(initialLoading(), {
|
||||
tag: 'BriefLoaded',
|
||||
brief: briefWith({ tag: 'draft' }),
|
||||
availablePassages: [],
|
||||
decisions,
|
||||
}).tag,
|
||||
).toBe('loaded');
|
||||
});
|
||||
|
||||
it('BriefLoadFailed moves loading to failed with the reason', () => {
|
||||
expect(reduce(initialLoading(), { tag: 'BriefLoadFailed', reason: 'x' })).toEqual({
|
||||
tag: 'failed',
|
||||
reason: 'x',
|
||||
});
|
||||
});
|
||||
|
||||
it('Seed sets the state directly', () => {
|
||||
const seeded = loaded();
|
||||
expect(reduce(initialLoading(), { tag: 'Seed', state: seeded })).toBe(seeded);
|
||||
});
|
||||
|
||||
it('BesluitSelected composes the kern: the besluit passages, in reading order, as frozen local blocks', () => {
|
||||
const s = reduce(loaded(), besluit('positief'));
|
||||
const blocks = sectionBlocks(s, 'kern');
|
||||
expect(blocks.map((b) => b.blockId)).toEqual(['local-1', 'local-2']);
|
||||
expect(blocks.every((b) => b.type === 'passage' && b.edited === false)).toBe(true);
|
||||
expect(passageIds(s, 'kern')).toEqual(['intro', 'pos']);
|
||||
});
|
||||
|
||||
it('BesluitSelected swaps the passages when the selection changes, keeping free text', () => {
|
||||
let s = reduce(loaded(), besluit('positief'));
|
||||
s = reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'kern' }); // drafter's own remark
|
||||
s = reduce(s, besluit('negatief', ['r1']));
|
||||
const blocks = sectionBlocks(s, 'kern');
|
||||
expect(blocks.map((b) => b.type)).toEqual(['passage', 'passage', 'passage', 'freeText']);
|
||||
expect(passageIds(s, 'kern')).toEqual(['intro', 'neg', 'neg-r']);
|
||||
// Deselecting the besluit leaves only the free text.
|
||||
s = reduce(s, besluit(null));
|
||||
expect(sectionBlocks(s, 'kern').map((b) => b.type)).toEqual(['freeText']);
|
||||
});
|
||||
|
||||
it('BesluitSelected deep-copies content — later library mutation does not leak in', () => {
|
||||
const passage = libPassage('intro', 'kern'); // shared → offered for any besluit
|
||||
const st: BriefState = {
|
||||
tag: 'loaded',
|
||||
brief: briefWith({ tag: 'draft' }),
|
||||
availablePassages: [passage],
|
||||
decisions,
|
||||
};
|
||||
const s = reduce(st, besluit('positief'));
|
||||
// Mutate the source passage object after composition.
|
||||
(passage.content.paragraphs[0].nodes as { type: 'text'; text: string }[])[0].text = 'HACKED';
|
||||
const block = sectionBlocks(s, 'kern')[0];
|
||||
expect(block.content.paragraphs[0].nodes[0]).toEqual({ type: 'text', text: 'inhoud intro' });
|
||||
});
|
||||
|
||||
it('FreeTextBlockAdded appends an empty free-text block', () => {
|
||||
const s = reduce(loaded(), { tag: 'FreeTextBlockAdded', sectionKey: 'kern' });
|
||||
const blocks = sectionBlocks(s, 'kern');
|
||||
expect(blocks).toHaveLength(1);
|
||||
expect(blocks[0].type).toBe('freeText');
|
||||
});
|
||||
|
||||
it('BlockContentEdited replaces content and marks a passage block edited', () => {
|
||||
let s = reduce(loaded(), besluit('positief'));
|
||||
s = reduce(s, { tag: 'BlockContentEdited', blockId: 'local-1', content: text('aangepast') });
|
||||
const block = sectionBlocks(s, 'kern')[0];
|
||||
expect(block.type === 'passage' && block.edited).toBe(true);
|
||||
expect(block.content).toEqual(text('aangepast'));
|
||||
});
|
||||
|
||||
it('BlockMovedWithinSection reorders blocks within a section', () => {
|
||||
let s = reduce(loaded(), besluit('positief')); // local-1 intro, local-2 pos
|
||||
s = reduce(s, { tag: 'BlockMovedWithinSection', blockId: 'local-1', toIndex: 1 });
|
||||
expect(sectionBlocks(s, 'kern').map((b) => b.blockId)).toEqual(['local-2', 'local-1']);
|
||||
});
|
||||
|
||||
it('BlockRemoved drops a block from a section', () => {
|
||||
let s = reduce(loaded(), besluit('positief')); // local-1 intro, local-2 pos
|
||||
s = reduce(s, { tag: 'BlockRemoved', blockId: 'local-2' });
|
||||
expect(sectionBlocks(s, 'kern').map((b) => b.blockId)).toEqual(['local-1']);
|
||||
});
|
||||
|
||||
it('edits to a locked section are no-ops (besluit, free-text, content, remove, move)', () => {
|
||||
const lockedSections: Brief['sections'] = [
|
||||
{
|
||||
sectionKey: 'kern',
|
||||
title: 'Kern',
|
||||
required: true,
|
||||
locked: true,
|
||||
blocks: [{ type: 'freeText', blockId: 'local-1', content: text('vast') }],
|
||||
},
|
||||
{ sectionKey: 'slot', title: 'Slot', required: false, locked: false, blocks: [] },
|
||||
];
|
||||
const s = loaded({ tag: 'draft' }, lockedSections);
|
||||
// The brief value is left untouched (withEdit reallocates state, but the guard returns
|
||||
// the same brief), so assert on deep equality of the section contents.
|
||||
expect(reduce(s, besluit('positief'))).toEqual(s);
|
||||
expect(reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'kern' })).toEqual(s);
|
||||
expect(
|
||||
reduce(s, { tag: 'BlockContentEdited', blockId: 'local-1', content: text('gehackt') }),
|
||||
).toEqual(s);
|
||||
expect(reduce(s, { tag: 'BlockRemoved', blockId: 'local-1' })).toEqual(s);
|
||||
expect(reduce(s, { tag: 'BlockMovedWithinSection', blockId: 'local-1', toIndex: 0 })).toEqual(
|
||||
s,
|
||||
);
|
||||
// the unlocked section still accepts edits
|
||||
const edited = reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'slot' });
|
||||
expect(sectionBlocks(edited, 'slot')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('edits are no-ops once submitted (status invariant)', () => {
|
||||
const s = loaded({ tag: 'submitted', submittedBy: 'u1', submittedAt: 't' });
|
||||
expect(reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'slot' })).toBe(s);
|
||||
});
|
||||
|
||||
it('editing a rejected letter reopens it to draft', () => {
|
||||
const s = loaded({
|
||||
tag: 'rejected',
|
||||
rejectedBy: 'u2',
|
||||
rejectedAt: 't',
|
||||
comments: 'graag aanpassen',
|
||||
});
|
||||
const next = reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'slot' });
|
||||
expect(next.tag === 'loaded' && next.brief.status.tag).toBe('draft');
|
||||
expect(sectionBlocks(next, 'slot')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('Submitted fires only from draft and only when required sections are filled', () => {
|
||||
// required 'kern' empty → no-op
|
||||
expect(reduce(loaded(), { tag: 'Submitted', by: 'u1', at: 't', decisions })).toEqual(loaded());
|
||||
// fill the required section via the besluit, then submit
|
||||
const filled = reduce(loaded(), besluit('positief'));
|
||||
const submitted = reduce(filled, { tag: 'Submitted', by: 'u1', at: 't', decisions });
|
||||
expect(submitted.tag === 'loaded' && submitted.brief.status).toEqual({
|
||||
tag: 'submitted',
|
||||
submittedBy: 'u1',
|
||||
submittedAt: 't',
|
||||
});
|
||||
});
|
||||
|
||||
it('approve fires only from submitted', () => {
|
||||
const submitted = loaded({ tag: 'submitted', submittedBy: 'u1', submittedAt: 't' });
|
||||
// approve from draft is a no-op
|
||||
expect(reduce(loaded(), { tag: 'Approved', by: 'u2', at: 't', decisions })).toEqual(loaded());
|
||||
const approved = reduce(submitted, { tag: 'Approved', by: 'u2', at: 't2', decisions });
|
||||
expect(approved.tag === 'loaded' && approved.brief.status).toEqual({
|
||||
tag: 'approved',
|
||||
approvedBy: 'u2',
|
||||
approvedAt: 't2',
|
||||
});
|
||||
});
|
||||
|
||||
it('reject fires from submitted, carrying comments', () => {
|
||||
const submitted = loaded({ tag: 'submitted', submittedBy: 'u1', submittedAt: 't' });
|
||||
const rejected = reduce(submitted, {
|
||||
tag: 'Rejected',
|
||||
by: 'u2',
|
||||
at: 't2',
|
||||
comments: 'nee',
|
||||
decisions,
|
||||
});
|
||||
expect(rejected.tag === 'loaded' && rejected.brief.status).toEqual({
|
||||
tag: 'rejected',
|
||||
rejectedBy: 'u2',
|
||||
rejectedAt: 't2',
|
||||
comments: 'nee',
|
||||
});
|
||||
});
|
||||
|
||||
it('send fires only from approved', () => {
|
||||
const submitted = loaded({ tag: 'submitted', submittedBy: 'u1', submittedAt: 't' });
|
||||
const approved = reduce(submitted, { tag: 'Approved', by: 'u2', at: 't2', decisions });
|
||||
// send from submitted is a no-op
|
||||
expect(reduce(submitted, { tag: 'Sent', at: 't', decisions })).toBe(submitted);
|
||||
const sent = reduce(approved, { tag: 'Sent', at: 't3', decisions });
|
||||
expect(sent.tag === 'loaded' && sent.brief.status).toEqual({ tag: 'sent', sentAt: 't3' });
|
||||
});
|
||||
|
||||
it('a status transition replaces decisions with the fresh server value', () => {
|
||||
const submitted = loaded({ tag: 'submitted', submittedBy: 'u1', submittedAt: 't' });
|
||||
const staleApprover: BriefDecisions = {
|
||||
canEdit: false,
|
||||
canApprove: false,
|
||||
canReject: false,
|
||||
canSend: false,
|
||||
canRevealBigNummer: false,
|
||||
};
|
||||
const approved = reduce(submitted, {
|
||||
tag: 'Approved',
|
||||
by: 'u2',
|
||||
at: 't2',
|
||||
decisions: staleApprover,
|
||||
});
|
||||
expect(approved.tag === 'loaded' && approved.decisions).toEqual(staleApprover);
|
||||
});
|
||||
});
|
||||
|
||||
function initialLoading(): BriefState {
|
||||
return { tag: 'loading' };
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import { assertNever } from '@shared/kernel/fp';
|
||||
import {
|
||||
Besluit,
|
||||
Brief,
|
||||
BriefDecisions,
|
||||
BriefStatus,
|
||||
LetterBlock,
|
||||
LetterSection,
|
||||
LibraryPassage,
|
||||
allBlocks,
|
||||
canSubmit,
|
||||
} from './brief';
|
||||
import { RichTextBlock, deepCopyBlock, emptyBlock } from '@shared/kernel/rich-text';
|
||||
import { passagesForBesluit } from './besluit';
|
||||
|
||||
/**
|
||||
* The letter composition state machine (Model + Msg + pure reduce), modeled on
|
||||
* `herregistratie/domain/intake.machine.ts`.
|
||||
*
|
||||
* Two invariants are enforced *here*, not in the UI:
|
||||
* - Status transitions are total and guarded — an out-of-order transition Msg is a
|
||||
* no-op (`draft→submitted→approved/rejected→draft`, `approved→sent`).
|
||||
* - Edits are only possible in `draft`/`rejected`; editing a `rejected` letter flips
|
||||
* it back to `draft`. Sections can never be added, removed, or reordered — there
|
||||
* is no Msg for it, so it is unrepresentable.
|
||||
*
|
||||
* Authorization is NOT a reducer concern: `decisions` (canEdit/canApprove/canReject/
|
||||
* canSend) arrives from the server on every load and every status transition (PRD-0002
|
||||
* phase P1) and is carried through unchanged by the reducer — never recomputed here.
|
||||
* The reducer guards the status invariant; the server is the sole authority on who may
|
||||
* act on it.
|
||||
*
|
||||
* Note: there is no `PlaceholderInserted` Msg. The editor inserts a placeholder NODE
|
||||
* at the caret and emits the whole new block via `BlockContentEdited`; its insert menu
|
||||
* only offers keys from `brief.placeholders`, so inserting an unknown key is
|
||||
* structurally impossible (a pasted `{{…}}` is caught by the linter as `malformed`).
|
||||
*/
|
||||
|
||||
export type BriefState =
|
||||
| { tag: 'loading' }
|
||||
| {
|
||||
tag: 'loaded';
|
||||
brief: Brief;
|
||||
availablePassages: readonly LibraryPassage[];
|
||||
decisions: BriefDecisions;
|
||||
}
|
||||
| { tag: 'failed'; reason: string };
|
||||
|
||||
export const initial: BriefState = { tag: 'loading' };
|
||||
|
||||
export type BriefMsg =
|
||||
| {
|
||||
tag: 'BriefLoaded';
|
||||
brief: Brief;
|
||||
availablePassages: readonly LibraryPassage[];
|
||||
decisions: BriefDecisions;
|
||||
}
|
||||
| { tag: 'BriefLoadFailed'; reason: string }
|
||||
| { tag: 'BesluitSelected'; besluit: Besluit | null; reasons: readonly string[] } // recomposes the kern's passages
|
||||
| { tag: 'FreeTextBlockAdded'; sectionKey: string }
|
||||
| { tag: 'BlockContentEdited'; blockId: string; content: RichTextBlock }
|
||||
| { tag: 'BlockRemoved'; blockId: string }
|
||||
| { tag: 'BlockMovedWithinSection'; blockId: string; toIndex: number }
|
||||
| { tag: 'Submitted'; by: string; at: string; decisions: BriefDecisions } // draft → submitted
|
||||
| { tag: 'Approved'; by: string; at: string; decisions: BriefDecisions } // submitted → approved
|
||||
| { tag: 'Rejected'; by: string; at: string; comments: string; decisions: BriefDecisions } // submitted → rejected
|
||||
| { tag: 'Sent'; at: string; decisions: BriefDecisions } // approved → sent
|
||||
| { tag: 'Seed'; state: BriefState };
|
||||
|
||||
/** Edits are allowed only in these statuses; editing a rejected letter reopens it. */
|
||||
function isEditable(status: BriefStatus): boolean {
|
||||
return status.tag === 'draft' || status.tag === 'rejected';
|
||||
}
|
||||
|
||||
/** Next `local-N` block id — DERIVED from existing ids (max + 1), not a stored counter. */
|
||||
function nextLocalIndex(brief: Brief): number {
|
||||
let max = 0;
|
||||
for (const b of allBlocks(brief)) {
|
||||
const m = /^local-(\d+)$/.exec(b.blockId);
|
||||
if (m) max = Math.max(max, Number(m[1]));
|
||||
}
|
||||
return max + 1;
|
||||
}
|
||||
|
||||
function mapSection(
|
||||
brief: Brief,
|
||||
sectionKey: string,
|
||||
f: (s: LetterSection) => LetterSection,
|
||||
): Brief {
|
||||
return {
|
||||
...brief,
|
||||
sections: brief.sections.map((s) => (s.sectionKey === sectionKey ? f(s) : s)),
|
||||
};
|
||||
}
|
||||
|
||||
/** The section a block currently lives in, or undefined if the block is gone. */
|
||||
function sectionKeyOfBlock(brief: Brief, blockId: string): string | undefined {
|
||||
return brief.sections.find((s) => s.blocks.some((b) => b.blockId === blockId))?.sectionKey;
|
||||
}
|
||||
|
||||
/** A section accepts edits only when it is not a locked (predefined) template section. */
|
||||
function isSectionEditable(brief: Brief, sectionKey: string | undefined): boolean {
|
||||
const section = brief.sections.find((s) => s.sectionKey === sectionKey);
|
||||
return !!section && !section.locked;
|
||||
}
|
||||
|
||||
function mapBlocks(brief: Brief, f: (blocks: readonly LetterBlock[]) => LetterBlock[]): Brief {
|
||||
return { ...brief, sections: brief.sections.map((s) => ({ ...s, blocks: f(s.blocks) })) };
|
||||
}
|
||||
|
||||
/** Apply an edit to the brief, guarded by status. A rejected letter reopens to draft. */
|
||||
function withEdit(s: BriefState, f: (b: Brief) => Brief): BriefState {
|
||||
if (s.tag !== 'loaded' || !isEditable(s.brief.status)) return s;
|
||||
let brief = f(s.brief);
|
||||
if (brief.status.tag === 'rejected') brief = { ...brief, status: { tag: 'draft' } };
|
||||
return { ...s, brief };
|
||||
}
|
||||
|
||||
function buildPassageBlocks(brief: Brief, passages: readonly LibraryPassage[]): LetterBlock[] {
|
||||
let idx = nextLocalIndex(brief);
|
||||
// The freeze happens HERE: each block gets a deep VALUE copy of the library content,
|
||||
// so later library edits can never mutate this letter (frozen snapshot).
|
||||
return passages.map((p) => ({
|
||||
type: 'passage',
|
||||
blockId: `local-${idx++}`,
|
||||
sourcePassageId: p.passageId,
|
||||
sourceVersion: p.version,
|
||||
content: deepCopyBlock(p.content),
|
||||
edited: false,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Recompose the kern for a besluit selection: the besluit-driven passages (in reading
|
||||
order) followed by the drafter's free-text blocks. The kern's `passage` blocks are
|
||||
besluit-derived by construction, so replacing them wholesale is the reactive swap; the
|
||||
`freeText` blocks are the drafter's own remarks and survive.
|
||||
ponytail: free text always trails the besluit passages after a recompute. */
|
||||
function composeKern(
|
||||
brief: Brief,
|
||||
availablePassages: readonly LibraryPassage[],
|
||||
besluit: Besluit | null,
|
||||
reasons: readonly string[],
|
||||
): Brief {
|
||||
const besluitBlocks = besluit
|
||||
? buildPassageBlocks(brief, passagesForBesluit(availablePassages, besluit, reasons))
|
||||
: [];
|
||||
return mapSection(brief, 'kern', (s) => ({
|
||||
...s,
|
||||
blocks: [...besluitBlocks, ...s.blocks.filter((b) => b.type === 'freeText')],
|
||||
}));
|
||||
}
|
||||
|
||||
function addFreeText(brief: Brief, sectionKey: string): Brief {
|
||||
const block: LetterBlock = {
|
||||
type: 'freeText',
|
||||
blockId: `local-${nextLocalIndex(brief)}`,
|
||||
content: emptyBlock(),
|
||||
};
|
||||
return mapSection(brief, sectionKey, (s) => ({ ...s, blocks: [...s.blocks, block] }));
|
||||
}
|
||||
|
||||
function editBlockContent(brief: Brief, blockId: string, content: RichTextBlock): Brief {
|
||||
return mapBlocks(brief, (blocks) =>
|
||||
blocks.map((b) =>
|
||||
b.blockId !== blockId
|
||||
? b
|
||||
: b.type === 'passage'
|
||||
? { ...b, content, edited: true } // editing a snapshot marks it, keeps provenance
|
||||
: { ...b, content },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function moveWithinSection(
|
||||
blocks: readonly LetterBlock[],
|
||||
blockId: string,
|
||||
toIndex: number,
|
||||
): LetterBlock[] {
|
||||
const from = blocks.findIndex((b) => b.blockId === blockId);
|
||||
if (from === -1) return [...blocks];
|
||||
const clamped = Math.max(0, Math.min(toIndex, blocks.length - 1));
|
||||
const next = [...blocks];
|
||||
const [moved] = next.splice(from, 1);
|
||||
next.splice(clamped, 0, moved);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function reduce(s: BriefState, m: BriefMsg): BriefState {
|
||||
switch (m.tag) {
|
||||
case 'BriefLoaded':
|
||||
return {
|
||||
tag: 'loaded',
|
||||
brief: m.brief,
|
||||
availablePassages: m.availablePassages,
|
||||
decisions: m.decisions,
|
||||
};
|
||||
case 'BriefLoadFailed':
|
||||
return { tag: 'failed', reason: m.reason };
|
||||
case 'Seed':
|
||||
return m.state;
|
||||
|
||||
// The kern is besluit-driven: (re)compose its passages from the selection, keeping the
|
||||
// drafter's free text. `availablePassages` lives on the loaded state, so this stays pure.
|
||||
case 'BesluitSelected':
|
||||
return withEdit(s, (b) =>
|
||||
s.tag === 'loaded' && isSectionEditable(b, 'kern')
|
||||
? composeKern(b, s.availablePassages, m.besluit, m.reasons)
|
||||
: b,
|
||||
);
|
||||
case 'FreeTextBlockAdded':
|
||||
return withEdit(s, (b) =>
|
||||
isSectionEditable(b, m.sectionKey) ? addFreeText(b, m.sectionKey) : b,
|
||||
);
|
||||
case 'BlockContentEdited':
|
||||
return withEdit(s, (b) =>
|
||||
isSectionEditable(b, sectionKeyOfBlock(b, m.blockId))
|
||||
? editBlockContent(b, m.blockId, m.content)
|
||||
: b,
|
||||
);
|
||||
case 'BlockRemoved':
|
||||
return withEdit(s, (b) =>
|
||||
isSectionEditable(b, sectionKeyOfBlock(b, m.blockId))
|
||||
? mapBlocks(b, (blocks) => blocks.filter((x) => x.blockId !== m.blockId))
|
||||
: b,
|
||||
);
|
||||
case 'BlockMovedWithinSection':
|
||||
return withEdit(s, (b) =>
|
||||
isSectionEditable(b, sectionKeyOfBlock(b, m.blockId))
|
||||
? mapBlocks(b, (blocks) =>
|
||||
blocks.some((x) => x.blockId === m.blockId)
|
||||
? moveWithinSection(blocks, m.blockId, m.toIndex)
|
||||
: [...blocks],
|
||||
)
|
||||
: b,
|
||||
);
|
||||
|
||||
case 'Submitted':
|
||||
// Guard the transition AND the completeness invariant.
|
||||
return transition(
|
||||
s,
|
||||
'draft',
|
||||
() => ({ tag: 'submitted', submittedBy: m.by, submittedAt: m.at }),
|
||||
m.decisions,
|
||||
canSubmit,
|
||||
);
|
||||
case 'Approved':
|
||||
return transition(
|
||||
s,
|
||||
'submitted',
|
||||
() => ({ tag: 'approved', approvedBy: m.by, approvedAt: m.at }),
|
||||
m.decisions,
|
||||
);
|
||||
case 'Rejected':
|
||||
return transition(
|
||||
s,
|
||||
'submitted',
|
||||
() => ({ tag: 'rejected', rejectedBy: m.by, rejectedAt: m.at, comments: m.comments }),
|
||||
m.decisions,
|
||||
);
|
||||
case 'Sent':
|
||||
return transition(s, 'approved', () => ({ tag: 'sent', sentAt: m.at }), m.decisions);
|
||||
|
||||
default:
|
||||
return assertNever(m);
|
||||
}
|
||||
}
|
||||
|
||||
/** A guarded status transition: only fires from `from`, and only if `guard` passes.
|
||||
`decisions` replaces the prior server-computed flags — always fresh from the
|
||||
same response that carried the new status. */
|
||||
function transition(
|
||||
s: BriefState,
|
||||
from: BriefStatus['tag'],
|
||||
next: () => BriefStatus,
|
||||
decisions: BriefDecisions,
|
||||
guard: (b: Brief) => boolean = () => true,
|
||||
): BriefState {
|
||||
if (s.tag !== 'loaded' || s.brief.status.tag !== from || !guard(s.brief)) return s;
|
||||
return { ...s, brief: { ...s.brief, status: next() }, decisions };
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
Brief,
|
||||
LetterBlock,
|
||||
allDiagnostics,
|
||||
canSubmit,
|
||||
hasBlockingErrors,
|
||||
unresolvedPlaceholders,
|
||||
} from './brief';
|
||||
import { PlaceholderDef } from './placeholders';
|
||||
import { RichTextBlock } from '@shared/kernel/rich-text';
|
||||
|
||||
const placeholders: PlaceholderDef[] = [
|
||||
{ key: 'naam', label: 'Naam', autoResolvable: true },
|
||||
{ key: 'reden', label: 'Reden', autoResolvable: false },
|
||||
];
|
||||
|
||||
const content = (...keys: string[]): RichTextBlock => ({
|
||||
paragraphs: [{ nodes: keys.map((key) => ({ type: 'placeholder', key })) }],
|
||||
});
|
||||
|
||||
const passage = (blockId: string, ...keys: string[]): LetterBlock => ({
|
||||
type: 'freeText',
|
||||
blockId,
|
||||
content: content(...keys),
|
||||
});
|
||||
|
||||
function brief(sections: Brief['sections']): Brief {
|
||||
return {
|
||||
briefId: 'b1',
|
||||
beroep: 'arts',
|
||||
templateId: 't1',
|
||||
placeholders,
|
||||
sections,
|
||||
status: { tag: 'draft' },
|
||||
drafterId: 'u1',
|
||||
};
|
||||
}
|
||||
|
||||
describe('brief selectors', () => {
|
||||
it('unresolvedPlaceholders returns deduped manual keys only (auto excluded)', () => {
|
||||
const b = brief([
|
||||
{
|
||||
sectionKey: 's1',
|
||||
title: 'S1',
|
||||
required: true,
|
||||
locked: false,
|
||||
blocks: [passage('local-1', 'naam', 'reden')],
|
||||
},
|
||||
{
|
||||
sectionKey: 's2',
|
||||
title: 'S2',
|
||||
required: false,
|
||||
locked: false,
|
||||
blocks: [passage('local-2', 'reden')],
|
||||
},
|
||||
]);
|
||||
expect(unresolvedPlaceholders(b)).toEqual(['reden']); // 'naam' is auto; 'reden' deduped
|
||||
});
|
||||
|
||||
it('allDiagnostics flattens across sections and blocks', () => {
|
||||
const b = brief([
|
||||
{
|
||||
sectionKey: 's1',
|
||||
title: 'S1',
|
||||
required: true,
|
||||
locked: false,
|
||||
blocks: [passage('local-1', 'reden', 'onbekend')],
|
||||
},
|
||||
]);
|
||||
const codes = allDiagnostics(b).map((d) => d.code);
|
||||
expect(codes).toContain('unresolved-at-send'); // reden
|
||||
expect(codes).toContain('unknown-placeholder'); // onbekend
|
||||
expect(hasBlockingErrors(allDiagnostics(b))).toBe(true); // unknown is an error
|
||||
});
|
||||
|
||||
it('canSubmit is false when a required section is empty, true otherwise', () => {
|
||||
expect(
|
||||
canSubmit(
|
||||
brief([{ sectionKey: 's1', title: 'S1', required: true, locked: false, blocks: [] }]),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
canSubmit(
|
||||
brief([{ sectionKey: 's1', title: 'S1', required: false, locked: false, blocks: [] }]),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
canSubmit(
|
||||
brief([
|
||||
{
|
||||
sectionKey: 's1',
|
||||
title: 'S1',
|
||||
required: true,
|
||||
locked: false,
|
||||
blocks: [passage('local-1')],
|
||||
},
|
||||
]),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { RichTextBlock, placeholderKeysIn } from '@shared/kernel/rich-text';
|
||||
import { Diagnostic, lintPlaceholders, PlaceholderDef } from './placeholders';
|
||||
|
||||
/**
|
||||
* The `Brief` (letter) entity and its derived selectors.
|
||||
*
|
||||
* A letter has a FIXED section structure (from a template, server-instantiated); the
|
||||
* drafter fills a skeleton, never reorders sections. Each block is either a frozen
|
||||
* snapshot of a library passage (provenance kept) or free text. Everything the UI
|
||||
* needs beyond the stored shape — diagnostics, unresolved placeholders, whether it
|
||||
* can be submitted — is DERIVED here, never stored.
|
||||
*/
|
||||
|
||||
export type PassageScope = 'global' | 'beroep';
|
||||
|
||||
/** The decision the behandelaar is communicating. Drives which passages are offered. */
|
||||
export type Besluit = 'positief' | 'negatief';
|
||||
|
||||
// Re-export placeholderKeysIn for one-import convenience at call sites.
|
||||
export { placeholderKeysIn };
|
||||
|
||||
/** A passage in the library (the source). Snapshotted into a letter on insert. */
|
||||
export interface LibraryPassage {
|
||||
readonly passageId: string;
|
||||
readonly scope: PassageScope;
|
||||
readonly beroep?: string; // set when scope === 'beroep'
|
||||
readonly sectionKey: string;
|
||||
readonly label: string;
|
||||
readonly content: RichTextBlock;
|
||||
readonly version: number; // library version, for provenance only
|
||||
// Guided-drafting tags: the behandelaar picks a besluit + reden, and `passagesForBesluit`
|
||||
// (besluit.ts) filters to the matching passages. undefined besluit = shown for any
|
||||
// besluit; undefined reason = not reason-specific. See @brief/domain/besluit.
|
||||
readonly besluit?: Besluit;
|
||||
readonly reason?: string;
|
||||
}
|
||||
|
||||
/** A block inside a letter section: a frozen passage snapshot, or free text. */
|
||||
export type LetterBlock =
|
||||
| {
|
||||
readonly type: 'passage';
|
||||
readonly blockId: string;
|
||||
readonly sourcePassageId: string; // provenance
|
||||
readonly sourceVersion: number; // library version at snapshot time (audit only)
|
||||
readonly content: RichTextBlock; // FROZEN, possibly edited — source of truth for this block
|
||||
readonly edited: boolean; // changed from the snapshot?
|
||||
}
|
||||
| {
|
||||
readonly type: 'freeText';
|
||||
readonly blockId: string;
|
||||
readonly content: RichTextBlock;
|
||||
};
|
||||
|
||||
export interface LetterSection {
|
||||
readonly sectionKey: string;
|
||||
readonly title: string;
|
||||
readonly required: boolean;
|
||||
// Predefined template sections (aanhef, slot) arrive locked and prefilled — the drafter
|
||||
// composes only the unlocked section(s). The reducer refuses edits to locked sections.
|
||||
// These come from the case-type template (`Brief.templateId`), so e.g. the slot's closing
|
||||
// can differ per case type; the drafter never edits it, and it renders only in the preview.
|
||||
readonly locked: boolean;
|
||||
readonly blocks: readonly LetterBlock[];
|
||||
}
|
||||
|
||||
/** The approval state machine as a sum type — transitions are total and guarded in
|
||||
`brief.machine.ts`; illegal transitions are unrepresentable. */
|
||||
export type BriefStatus =
|
||||
| { readonly tag: 'draft' }
|
||||
| { readonly tag: 'submitted'; readonly submittedBy: string; readonly submittedAt: string }
|
||||
| { readonly tag: 'approved'; readonly approvedBy: string; readonly approvedAt: string }
|
||||
| {
|
||||
readonly tag: 'rejected';
|
||||
readonly rejectedBy: string;
|
||||
readonly rejectedAt: string;
|
||||
readonly comments: string;
|
||||
}
|
||||
| { readonly tag: 'sent'; readonly sentAt: string };
|
||||
|
||||
export interface Brief {
|
||||
readonly briefId: string;
|
||||
readonly beroep: string; // drives which beroep-scoped passages apply
|
||||
readonly templateId: string;
|
||||
readonly placeholders: readonly PlaceholderDef[]; // valid fields for this letter
|
||||
readonly sections: readonly LetterSection[]; // instantiated from the template, in order
|
||||
readonly status: BriefStatus;
|
||||
readonly drafterId: string;
|
||||
}
|
||||
|
||||
// --- Derived selectors (pure; recomputed, never stored) ---
|
||||
|
||||
export function allBlocks(brief: Brief): LetterBlock[] {
|
||||
return brief.sections.flatMap((s) => s.blocks);
|
||||
}
|
||||
|
||||
/** Every diagnostic in the letter, in section→block→node order. This is what the
|
||||
diagnostics panel renders and what the send gate checks. */
|
||||
export function allDiagnostics(brief: Brief): Diagnostic[] {
|
||||
return allBlocks(brief).flatMap((b) =>
|
||||
lintPlaceholders(b.content, brief.placeholders, b.blockId),
|
||||
);
|
||||
}
|
||||
|
||||
export function hasBlockingErrors(diagnostics: readonly Diagnostic[]): boolean {
|
||||
return diagnostics.some((d) => d.severity === 'error');
|
||||
}
|
||||
|
||||
/** Manual (non-auto-resolvable) placeholder keys still present, deduped. These are the
|
||||
`unresolved-at-send` warnings, surfaced as a completeness list. */
|
||||
export function unresolvedPlaceholders(brief: Brief): string[] {
|
||||
const auto = new Set(brief.placeholders.filter((p) => p.autoResolvable).map((p) => p.key));
|
||||
const used = allBlocks(brief).flatMap((b) => placeholderKeysIn(b.content));
|
||||
return [...new Set(used.filter((k) => !auto.has(k)))];
|
||||
}
|
||||
|
||||
/** A letter can be submitted only when every REQUIRED section has at least one block. */
|
||||
export function canSubmit(brief: Brief): boolean {
|
||||
return brief.sections.every((s) => !s.required || s.blocks.length > 0);
|
||||
}
|
||||
|
||||
/** The case this letter concerns — the zorgverlener + aanvraag the behandelaar is
|
||||
handling. Server-joined onto the brief view (brief/ stays a shared-only leaf, so it
|
||||
can't read the registratie context directly). Header context only. */
|
||||
export interface CaseContext {
|
||||
readonly zorgverlenerNaam: string;
|
||||
readonly bigNummer: string;
|
||||
readonly beroep: string;
|
||||
readonly aanvraagReferentie: string;
|
||||
}
|
||||
|
||||
/** Server-computed decision flags for the acting principal + this brief's live
|
||||
status (PRD-0002 phase P1) — rendered as-is, never recomputed here. */
|
||||
export interface BriefDecisions {
|
||||
readonly canEdit: boolean;
|
||||
readonly canApprove: boolean;
|
||||
readonly canReject: boolean;
|
||||
readonly canSend: boolean;
|
||||
/** Field-level PII (PRD-0002 §5c): may the acting principal unmask the case
|
||||
BIG-nummer, which the server ships masked? Status-independent. */
|
||||
readonly canRevealBigNummer: boolean;
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { OrgTemplate, OrgTemplateAdminView } from './org-template';
|
||||
import { OrgTemplateState, reduce } from './org-template.machine';
|
||||
import { DocumentCategory } from '@shared/upload/upload.machine';
|
||||
|
||||
const template: OrgTemplate = {
|
||||
subOrgId: 'cibg-registers',
|
||||
orgName: 'CIBG',
|
||||
returnAddress: 'Postbus 1\n2500 AA Den Haag',
|
||||
footerContact: 'info@cibg.nl',
|
||||
footerLegal: 'CIBG is onderdeel van VWS',
|
||||
signatureName: 'A. de Vries',
|
||||
signatureRole: 'Hoofd Registratie',
|
||||
signatureClosing: 'Met vriendelijke groet,',
|
||||
margins: { topMm: 25, rightMm: 20, bottomMm: 25, leftMm: 20 },
|
||||
version: 3,
|
||||
};
|
||||
|
||||
const view = (over: Partial<OrgTemplateAdminView> = {}): OrgTemplateAdminView => ({
|
||||
draft: template,
|
||||
publishedVersion: 3,
|
||||
history: [],
|
||||
unsentBriefs: 2,
|
||||
...over,
|
||||
});
|
||||
|
||||
const loaded = (): OrgTemplateState =>
|
||||
reduce({ tag: 'loading' }, { tag: 'DraftLoaded', view: view() });
|
||||
|
||||
const logoCategory: DocumentCategory = {
|
||||
categoryId: 'org-logo',
|
||||
label: 'Logo',
|
||||
description: '',
|
||||
required: false,
|
||||
acceptedTypes: ['image/png'],
|
||||
maxSizeMb: 2,
|
||||
multiple: false,
|
||||
allowPostDelivery: false,
|
||||
};
|
||||
|
||||
describe('org-template.machine', () => {
|
||||
it('DraftLoaded moves to loaded with the draft, clean', () => {
|
||||
const s = loaded();
|
||||
expect(s.tag).toBe('loaded');
|
||||
if (s.tag !== 'loaded') return;
|
||||
expect(s.draft.orgName).toBe('CIBG');
|
||||
expect(s.subOrgId).toBe('cibg-registers');
|
||||
expect(s.unsentBriefs).toBe(2);
|
||||
expect(s.dirty).toBe(false);
|
||||
});
|
||||
|
||||
it('LoadFailed carries the reason', () => {
|
||||
const s = reduce({ tag: 'loading' }, { tag: 'LoadFailed', reason: 'boom' });
|
||||
expect(s).toEqual({ tag: 'failed', reason: 'boom' });
|
||||
});
|
||||
|
||||
it('FieldEdited edits the draft and marks dirty', () => {
|
||||
const s = reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'CIBG Nieuw' });
|
||||
expect(s.tag === 'loaded' && s.draft.orgName).toBe('CIBG Nieuw');
|
||||
expect(s.tag === 'loaded' && s.dirty).toBe(true);
|
||||
});
|
||||
|
||||
it('MarginEdited edits one edge and marks dirty', () => {
|
||||
const s = reduce(loaded(), { tag: 'MarginEdited', edge: 'topMm', value: 40 });
|
||||
expect(s.tag === 'loaded' && s.draft.margins.topMm).toBe(40);
|
||||
expect(s.tag === 'loaded' && s.draft.margins.leftMm).toBe(20);
|
||||
expect(s.tag === 'loaded' && s.dirty).toBe(true);
|
||||
});
|
||||
|
||||
it('DraftSaved clears dirty when the saved draft is the current one', () => {
|
||||
const edited = reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'X' });
|
||||
const savedDraft = edited.tag === 'loaded' ? edited.draft : template;
|
||||
const s = reduce(edited, { tag: 'DraftSaved', savedDraft });
|
||||
expect(s.tag === 'loaded' && s.dirty).toBe(false);
|
||||
expect(s.tag === 'loaded' && s.draft.orgName).toBe('X');
|
||||
});
|
||||
|
||||
it('DraftSaved keeps dirty when an edit landed during the save round-trip', () => {
|
||||
const editing = reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'X' });
|
||||
const savedDraft = editing.tag === 'loaded' ? editing.draft : template;
|
||||
// a further edit changes the draft reference before the save resolves
|
||||
const raced = reduce(editing, { tag: 'FieldEdited', field: 'orgName', value: 'Y' });
|
||||
const s = reduce(raced, { tag: 'DraftSaved', savedDraft });
|
||||
expect(s.tag === 'loaded' && s.dirty).toBe(true);
|
||||
});
|
||||
|
||||
it('edits are no-ops in non-loaded states', () => {
|
||||
expect(
|
||||
reduce({ tag: 'loading' }, { tag: 'FieldEdited', field: 'orgName', value: 'x' }),
|
||||
).toEqual({
|
||||
tag: 'loading',
|
||||
});
|
||||
});
|
||||
|
||||
it('a completed logo upload sets logoDocumentId + dirty', () => {
|
||||
const withCat = reduce(loaded(), {
|
||||
tag: 'Upload',
|
||||
msg: { type: 'CategoriesLoaded', categories: [logoCategory] },
|
||||
});
|
||||
const selected = reduce(withCat, {
|
||||
tag: 'Upload',
|
||||
msg: {
|
||||
type: 'FileSelected',
|
||||
categoryId: 'org-logo',
|
||||
localId: 'a',
|
||||
fileName: 'l.png',
|
||||
fileSizeMb: 0.1,
|
||||
},
|
||||
});
|
||||
const done = reduce(selected, {
|
||||
tag: 'Upload',
|
||||
msg: { type: 'UploadComplete', localId: 'a', documentId: 'doc-1' },
|
||||
});
|
||||
expect(done.tag === 'loaded' && done.draft.logoDocumentId).toBe('doc-1');
|
||||
expect(done.tag === 'loaded' && done.dirty).toBe(true);
|
||||
});
|
||||
|
||||
it('removing the logo clears logoDocumentId + dirty', () => {
|
||||
const withLogo = reduce(loaded(), {
|
||||
tag: 'Upload',
|
||||
msg: { type: 'UploadComplete', localId: 'a', documentId: 'doc-1' },
|
||||
});
|
||||
const removed = reduce(withLogo, {
|
||||
tag: 'Upload',
|
||||
msg: { type: 'UploadRemoved', localId: 'a' },
|
||||
});
|
||||
expect(removed.tag === 'loaded' && removed.draft.logoDocumentId).toBeUndefined();
|
||||
expect(removed.tag === 'loaded' && removed.dirty).toBe(true);
|
||||
});
|
||||
|
||||
it('DraftLoaded (sub-org switch) keeps the loaded logo category, drops uploads', () => {
|
||||
const withCat = reduce(loaded(), {
|
||||
tag: 'Upload',
|
||||
msg: { type: 'CategoriesLoaded', categories: [logoCategory] },
|
||||
});
|
||||
const switched = reduce(withCat, {
|
||||
tag: 'DraftLoaded',
|
||||
view: view({ draft: { ...template, subOrgId: 'cibg-vakbekwaamheid' } }),
|
||||
});
|
||||
expect(switched.tag === 'loaded' && switched.upload.categories).toHaveLength(1);
|
||||
expect(switched.tag === 'loaded' && switched.upload.uploads).toHaveLength(0);
|
||||
expect(switched.tag === 'loaded' && switched.subOrgId).toBe('cibg-vakbekwaamheid');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { assertNever } from '@shared/kernel/fp';
|
||||
import { Margins, OrgTemplate, OrgTemplateAdminView, OrgTemplateVersion } from './org-template';
|
||||
import { UploadMsg, UploadState, initialUpload, reduceUpload } from '@shared/upload/upload.machine';
|
||||
|
||||
/**
|
||||
* The admin org-template editor as one Elm-style machine (WP-26, PRD Brief v2 §5) —
|
||||
* the same idiom as the wizards. The DRAFT org template is form state (edited in
|
||||
* place on the canvas); publish/rollback are effects that come back as `DraftLoaded`.
|
||||
* `dirty` tracks unsaved edits (the store debounce-saves them). The logo upload is
|
||||
* the composable upload sub-machine folded in, exactly like the wizards fold
|
||||
* `reduceUpload` — its `UploadComplete`/`UploadRemoved` also mutate `draft.logoDocumentId`.
|
||||
*/
|
||||
|
||||
/** The org-identity text fields editable directly on the letter canvas. */
|
||||
export type OrgTemplateTextField =
|
||||
| 'orgName'
|
||||
| 'returnAddress'
|
||||
| 'footerContact'
|
||||
| 'footerLegal'
|
||||
| 'signatureName'
|
||||
| 'signatureRole'
|
||||
| 'signatureClosing';
|
||||
|
||||
export type OrgTemplateState =
|
||||
| { tag: 'loading' }
|
||||
| { tag: 'failed'; reason: string }
|
||||
| {
|
||||
tag: 'loaded';
|
||||
subOrgId: string;
|
||||
draft: OrgTemplate;
|
||||
publishedVersion: number;
|
||||
history: readonly OrgTemplateVersion[];
|
||||
unsentBriefs: number;
|
||||
dirty: boolean;
|
||||
/** Logo upload sub-state (single file, `org-logo` category). */
|
||||
upload: UploadState;
|
||||
};
|
||||
|
||||
export const initial: OrgTemplateState = { tag: 'loading' };
|
||||
|
||||
export type OrgTemplateMsg =
|
||||
| { tag: 'Loading' }
|
||||
| { tag: 'DraftLoaded'; view: OrgTemplateAdminView }
|
||||
| { tag: 'LoadFailed'; reason: string }
|
||||
| { tag: 'FieldEdited'; field: OrgTemplateTextField; value: string }
|
||||
| { tag: 'MarginEdited'; edge: keyof Margins; value: number }
|
||||
/** Carries the draft that was saved: clears `dirty` only if no edit landed during
|
||||
the round-trip (reference-equal), so a concurrent edit keeps its pending save. */
|
||||
| { tag: 'DraftSaved'; savedDraft: OrgTemplate }
|
||||
| { tag: 'Upload'; msg: UploadMsg };
|
||||
|
||||
/** Edit the loaded draft; a no-op in any non-loaded state (illegal by construction). */
|
||||
function editDraft(s: OrgTemplateState, f: (draft: OrgTemplate) => OrgTemplate): OrgTemplateState {
|
||||
return s.tag === 'loaded' ? { ...s, draft: f(s.draft), dirty: true } : s;
|
||||
}
|
||||
|
||||
export function reduce(s: OrgTemplateState, m: OrgTemplateMsg): OrgTemplateState {
|
||||
switch (m.tag) {
|
||||
case 'Loading':
|
||||
return { tag: 'loading' };
|
||||
case 'LoadFailed':
|
||||
return { tag: 'failed', reason: m.reason };
|
||||
case 'DraftLoaded':
|
||||
return {
|
||||
tag: 'loaded',
|
||||
subOrgId: m.view.draft.subOrgId,
|
||||
draft: m.view.draft,
|
||||
publishedVersion: m.view.publishedVersion,
|
||||
history: m.view.history,
|
||||
unsentBriefs: m.view.unsentBriefs,
|
||||
dirty: false,
|
||||
// Keep the loaded logo category across sub-org switches (it's the same
|
||||
// `org-logo` category, loaded once); drop only any in-flight/finished uploads.
|
||||
upload: s.tag === 'loaded' ? { ...s.upload, uploads: [], rejections: {} } : initialUpload,
|
||||
};
|
||||
case 'FieldEdited':
|
||||
return editDraft(s, (d) => ({ ...d, [m.field]: m.value }));
|
||||
case 'MarginEdited':
|
||||
return editDraft(s, (d) => ({ ...d, margins: { ...d.margins, [m.edge]: m.value } }));
|
||||
case 'DraftSaved':
|
||||
return s.tag === 'loaded' && s.draft === m.savedDraft ? { ...s, dirty: false } : s;
|
||||
case 'Upload': {
|
||||
if (s.tag !== 'loaded') return s;
|
||||
const upload = reduceUpload(s.upload, m.msg);
|
||||
// A completed/removed logo upload also updates the draft's logoDocumentId.
|
||||
if (m.msg.type === 'UploadComplete')
|
||||
return {
|
||||
...s,
|
||||
upload,
|
||||
draft: { ...s.draft, logoDocumentId: m.msg.documentId },
|
||||
dirty: true,
|
||||
};
|
||||
if (m.msg.type === 'UploadRemoved') {
|
||||
const { logoDocumentId: _dropped, ...rest } = s.draft;
|
||||
return { ...s, upload, draft: rest, dirty: true };
|
||||
}
|
||||
return { ...s, upload };
|
||||
}
|
||||
default:
|
||||
return assertNever(m);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* The organization template (Brief v2 PRD §3, WP-23/24): the SECOND template axis —
|
||||
* appearance/identity per sub-organization (letterhead, footer, signature, margins).
|
||||
* Orthogonal to the case-type template (sections + placeholders); the two only meet
|
||||
* at render time, on the letter canvas. Server-owned: the FE renders it verbatim,
|
||||
* never edits it here (the admin editor is WP-26).
|
||||
*/
|
||||
|
||||
export interface Margins {
|
||||
readonly topMm: number;
|
||||
readonly rightMm: number;
|
||||
readonly bottomMm: number;
|
||||
readonly leftMm: number;
|
||||
}
|
||||
|
||||
export interface OrgTemplate {
|
||||
readonly subOrgId: string;
|
||||
readonly orgName: string;
|
||||
/** Multiline; rendered above the envelope window. */
|
||||
readonly returnAddress: string;
|
||||
readonly logoDocumentId?: string;
|
||||
/** Multiline contact block in the footer. */
|
||||
readonly footerContact: string;
|
||||
readonly footerLegal: string;
|
||||
readonly signatureName: string;
|
||||
readonly signatureRole: string;
|
||||
readonly signatureClosing: string;
|
||||
readonly margins: Margins;
|
||||
/** 0 = draft; n>0 = the published snapshot this letter renders with. */
|
||||
readonly version: number;
|
||||
}
|
||||
|
||||
// --- admin editor (WP-26) ---
|
||||
|
||||
/** A published snapshot in the version history: who is faked, `publishedAt` is real. */
|
||||
export interface OrgTemplateVersion {
|
||||
readonly version: number;
|
||||
readonly publishedAt: string;
|
||||
readonly template: OrgTemplate;
|
||||
}
|
||||
|
||||
/** The admin editor's view of one sub-org: the editable draft plus publish metadata. */
|
||||
export interface OrgTemplateAdminView {
|
||||
readonly draft: OrgTemplate;
|
||||
readonly publishedVersion: number;
|
||||
readonly history: readonly OrgTemplateVersion[];
|
||||
/** How many not-yet-sent letters a publish would re-render (the impact count). */
|
||||
readonly unsentBriefs: number;
|
||||
}
|
||||
|
||||
/** One row in the sub-org switcher. */
|
||||
export interface SubOrgSummary {
|
||||
readonly subOrgId: string;
|
||||
readonly orgName: string;
|
||||
readonly publishedVersion: number;
|
||||
}
|
||||
|
||||
/** Publish outcome: the new version and how many unsent letters it touched. */
|
||||
export interface PublishResult {
|
||||
readonly version: number;
|
||||
readonly affectedUnsentBriefs: number;
|
||||
}
|
||||
|
||||
/** Margin bounds (server-owned, `OrgTemplateRules`): the FE mirrors them for instant
|
||||
feedback via `<input min max>`; the server re-validates and stays the authority. */
|
||||
export const MARGIN_MIN_MM = 10;
|
||||
export const MARGIN_MAX_MM = 50;
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { RichTextBlock } from '@shared/kernel/rich-text';
|
||||
import { PlaceholderDef, lintPlaceholders, severityOf } from './placeholders';
|
||||
|
||||
const valid: PlaceholderDef[] = [
|
||||
{ key: 'naam', label: 'Naam', autoResolvable: true }, // clean when used
|
||||
{ key: 'reden', label: 'Reden', autoResolvable: false }, // manual → unresolved-at-send
|
||||
{ key: 'oud_veld', label: 'Oud veld', autoResolvable: true, deprecated: true },
|
||||
{ key: 'niet_invulbaar', label: 'Niet invulbaar', autoResolvable: true, fillable: false },
|
||||
];
|
||||
|
||||
const withPlaceholder = (key: string): RichTextBlock => ({
|
||||
paragraphs: [{ nodes: [{ type: 'placeholder', key }] }],
|
||||
});
|
||||
const withText = (text: string): RichTextBlock => ({
|
||||
paragraphs: [{ nodes: [{ type: 'text', text }] }],
|
||||
});
|
||||
|
||||
describe('lintPlaceholders', () => {
|
||||
it('clean content (auto-resolvable, fillable, current key) yields no diagnostics', () => {
|
||||
expect(lintPlaceholders(withPlaceholder('naam'), valid, 'b1')).toEqual([]);
|
||||
expect(lintPlaceholders(withText('gewone tekst'), valid, 'b1')).toEqual([]);
|
||||
});
|
||||
|
||||
it('flags an unknown key as an error', () => {
|
||||
const [d] = lintPlaceholders(withPlaceholder('onbekend'), valid, 'b1');
|
||||
expect(d.code).toBe('unknown-placeholder');
|
||||
expect(d.severity).toBe('error');
|
||||
expect(d.placeholderKey).toBe('onbekend');
|
||||
expect(d.location).toEqual({ blockId: 'b1', paragraphIndex: 0, nodeIndex: 0 });
|
||||
});
|
||||
|
||||
it('flags a not-fillable key as an error', () => {
|
||||
const [d] = lintPlaceholders(withPlaceholder('niet_invulbaar'), valid, 'b1');
|
||||
expect(d.code).toBe('not-fillable');
|
||||
expect(d.severity).toBe('error');
|
||||
});
|
||||
|
||||
it('flags a deprecated key as a warning', () => {
|
||||
const [d] = lintPlaceholders(withPlaceholder('oud_veld'), valid, 'b1');
|
||||
expect(d.code).toBe('deprecated');
|
||||
expect(d.severity).toBe('warning');
|
||||
});
|
||||
|
||||
it('flags a manual placeholder as unresolved-at-send (warning)', () => {
|
||||
const [d] = lintPlaceholders(withPlaceholder('reden'), valid, 'b1');
|
||||
expect(d.code).toBe('unresolved-at-send');
|
||||
expect(d.severity).toBe('warning');
|
||||
});
|
||||
|
||||
it('flags raw braces in text as malformed (paste safety net)', () => {
|
||||
const [d] = lintPlaceholders(withText('Beste {{naam'), valid, 'b1');
|
||||
expect(d.code).toBe('malformed');
|
||||
expect(d.severity).toBe('error');
|
||||
expect(d.placeholderKey).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns diagnostics in document order across paragraphs/nodes', () => {
|
||||
const content: RichTextBlock = {
|
||||
paragraphs: [
|
||||
{ nodes: [{ type: 'placeholder', key: 'onbekend' }] },
|
||||
{
|
||||
nodes: [
|
||||
{ type: 'text', text: 'ok' },
|
||||
{ type: 'placeholder', key: 'reden' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const codes = lintPlaceholders(content, valid, 'b1').map(
|
||||
(d) => `${d.code}@${d.location.paragraphIndex}.${d.location.nodeIndex}`,
|
||||
);
|
||||
expect(codes).toEqual(['unknown-placeholder@0.0', 'unresolved-at-send@1.1']);
|
||||
});
|
||||
|
||||
it('severityOf maps each code to its policy', () => {
|
||||
expect(severityOf('malformed')).toBe('error');
|
||||
expect(severityOf('unknown-placeholder')).toBe('error');
|
||||
expect(severityOf('not-fillable')).toBe('error');
|
||||
expect(severityOf('deprecated')).toBe('warning');
|
||||
expect(severityOf('unresolved-at-send')).toBe('warning');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { RichTextBlock } from '@shared/kernel/rich-text';
|
||||
|
||||
/**
|
||||
* Placeholder fields and the PURE linter over them.
|
||||
*
|
||||
* `lintPlaceholders` is a total, effect-free function of (content, valid set). It
|
||||
* runs identically on the client (for live UX) and could run on the server (for
|
||||
* authority) — same rules, same config, so the two agree by construction. The FE
|
||||
* never STORES its output: diagnostics are a `computed()` over content (see
|
||||
* `brief.ts` selectors), the same "derive, don't store" discipline as wizard step
|
||||
* validity.
|
||||
*/
|
||||
|
||||
/** A placeholder field the template knows about. `fillable`/`deprecated` default to
|
||||
the healthy case; the seed flips them to exercise the not-fillable/deprecated rules. */
|
||||
export interface PlaceholderDef {
|
||||
readonly key: string; // e.g. 'naam_zorgverlener'
|
||||
readonly label: string; // human label for the insert menu, e.g. 'Naam zorgverlener'
|
||||
readonly autoResolvable: boolean; // server can fill from case data (name, date, …)
|
||||
readonly fillable?: boolean; // default true; false → not resolvable for this beroep/case type
|
||||
readonly deprecated?: boolean; // default false; true → retired but still referenceable in old snapshots
|
||||
}
|
||||
|
||||
export type DiagnosticSeverity = 'error' | 'warning';
|
||||
|
||||
export type DiagnosticCode =
|
||||
| 'malformed' // raw braces in a text node (a paste that should have been a chip)
|
||||
| 'unknown-placeholder' // well-formed key not in the valid set
|
||||
| 'not-fillable' // key exists but isn't resolvable for this case type / beroep
|
||||
| 'deprecated' // key was valid once but the template no longer offers it
|
||||
| 'unresolved-at-send'; // manual (non-auto-resolvable) placeholder still to be filled
|
||||
|
||||
export interface DiagnosticLocation {
|
||||
readonly blockId: string;
|
||||
readonly paragraphIndex: number;
|
||||
readonly nodeIndex: number;
|
||||
}
|
||||
|
||||
export interface Diagnostic {
|
||||
readonly severity: DiagnosticSeverity;
|
||||
readonly code: DiagnosticCode;
|
||||
readonly placeholderKey?: string;
|
||||
readonly location: DiagnosticLocation;
|
||||
readonly message: string; // human-readable, Dutch
|
||||
}
|
||||
|
||||
/** `error` blocks save (author time) and send (send time); `warning` is surfaced but allowed. */
|
||||
export function severityOf(code: DiagnosticCode): DiagnosticSeverity {
|
||||
switch (code) {
|
||||
case 'malformed':
|
||||
case 'unknown-placeholder':
|
||||
case 'not-fillable':
|
||||
return 'error';
|
||||
case 'deprecated':
|
||||
case 'unresolved-at-send':
|
||||
return 'warning';
|
||||
}
|
||||
}
|
||||
|
||||
// Raw `{{` or `}}` in text — the only way a malformed placeholder can exist, since
|
||||
// menu insertion always produces a proper placeholder NODE. Paste safety net.
|
||||
const RAW_BRACES = /\{\{|\}\}/;
|
||||
|
||||
function messageFor(code: DiagnosticCode, key?: string): string {
|
||||
switch (code) {
|
||||
case 'malformed':
|
||||
return $localize`:@@brief.lint.malformed:Deze tekst bevat losse accolades ({{ of }}). Voeg een veld toe via het menu in plaats van het te typen.`;
|
||||
case 'unknown-placeholder':
|
||||
return $localize`:@@brief.lint.unknown:Onbekend veld “${key}:key:”. Dit veld hoort niet bij dit sjabloon.`;
|
||||
case 'not-fillable':
|
||||
return $localize`:@@brief.lint.notFillable:Veld “${key}:key:” kan niet worden ingevuld voor dit beroep.`;
|
||||
case 'deprecated':
|
||||
return $localize`:@@brief.lint.deprecated:Veld “${key}:key:” is verouderd en wordt niet meer aangeboden.`;
|
||||
case 'unresolved-at-send':
|
||||
return $localize`:@@brief.lint.unresolved:Veld “${key}:key:” wordt handmatig ingevuld en is nog leeg.`;
|
||||
}
|
||||
}
|
||||
|
||||
function diag(code: DiagnosticCode, location: DiagnosticLocation, key?: string): Diagnostic {
|
||||
return {
|
||||
severity: severityOf(code),
|
||||
code,
|
||||
placeholderKey: key,
|
||||
location,
|
||||
message: messageFor(code, key),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Lint one block against the template's placeholder set. Pure: given the same
|
||||
* content + valid set + blockId it always returns the same diagnostics, in
|
||||
* document order. One diagnostic per node at most (most-severe wins).
|
||||
*/
|
||||
export function lintPlaceholders(
|
||||
content: RichTextBlock,
|
||||
valid: readonly PlaceholderDef[],
|
||||
blockId: string,
|
||||
): Diagnostic[] {
|
||||
const byKey = new Map(valid.map((p) => [p.key, p]));
|
||||
const out: Diagnostic[] = [];
|
||||
|
||||
content.paragraphs.forEach((p, paragraphIndex) => {
|
||||
p.nodes.forEach((n, nodeIndex) => {
|
||||
const location: DiagnosticLocation = { blockId, paragraphIndex, nodeIndex };
|
||||
if (n.type === 'text') {
|
||||
if (RAW_BRACES.test(n.text)) out.push(diag('malformed', location));
|
||||
return;
|
||||
}
|
||||
if (n.type !== 'placeholder') return; // lineBreak — nothing to check
|
||||
|
||||
const def = byKey.get(n.key);
|
||||
if (!def) out.push(diag('unknown-placeholder', location, n.key));
|
||||
else if (def.fillable === false) out.push(diag('not-fillable', location, n.key));
|
||||
else if (def.deprecated) out.push(diag('deprecated', location, n.key));
|
||||
else if (!def.autoResolvable) out.push(diag('unresolved-at-send', location, n.key));
|
||||
// auto-resolvable & fillable & not deprecated → clean (filled by the server at send)
|
||||
});
|
||||
});
|
||||
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { BriefViewDto } from '@shared/infrastructure/api-client';
|
||||
import {
|
||||
parseBrief,
|
||||
parseBriefView,
|
||||
parseNode,
|
||||
parseOrgTemplate,
|
||||
parseStatus,
|
||||
} from './brief.adapter';
|
||||
|
||||
const view: BriefViewDto = {
|
||||
brief: {
|
||||
briefId: 'b1',
|
||||
beroep: 'arts',
|
||||
templateId: 't1',
|
||||
drafterId: 'demo-drafter',
|
||||
status: { tag: 'submitted', submittedBy: 'demo-drafter', submittedAt: '2026-07-01' },
|
||||
placeholders: [
|
||||
{ key: 'naam', label: 'Naam', autoResolvable: true },
|
||||
{ key: 'code', label: 'Code', autoResolvable: true, fillable: false },
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
sectionKey: 'aanhef',
|
||||
title: 'Aanhef',
|
||||
required: true,
|
||||
blocks: [
|
||||
{
|
||||
type: 'passage',
|
||||
blockId: 'local-1',
|
||||
content: { paragraphs: [{ nodes: [{ type: 'placeholder', key: 'naam' }] }] },
|
||||
sourcePassageId: 'p1',
|
||||
sourceVersion: 2,
|
||||
edited: true,
|
||||
},
|
||||
{
|
||||
type: 'freeText',
|
||||
blockId: 'local-2',
|
||||
content: { paragraphs: [{ nodes: [{ type: 'text', text: 'hoi', marks: ['bold'] }] }] },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
availablePassages: [
|
||||
{
|
||||
passageId: 'p1',
|
||||
scope: 'global',
|
||||
sectionKey: 'aanhef',
|
||||
label: 'Aanhef',
|
||||
content: { paragraphs: [{ nodes: [] }] },
|
||||
version: 1,
|
||||
},
|
||||
{
|
||||
passageId: 'p-neg-scholing',
|
||||
scope: 'global',
|
||||
sectionKey: 'kern',
|
||||
label: 'Onvoldoende scholing',
|
||||
content: { paragraphs: [{ nodes: [{ type: 'text', text: 'x' }] }] },
|
||||
version: 1,
|
||||
besluit: 'negatief',
|
||||
reason: 'onvoldoende_scholing',
|
||||
},
|
||||
],
|
||||
decisions: {
|
||||
canEdit: false,
|
||||
canApprove: true,
|
||||
canReject: true,
|
||||
canSend: false,
|
||||
canRevealBigNummer: false,
|
||||
},
|
||||
orgTemplate: {
|
||||
subOrgId: 'cibg-registers',
|
||||
orgName: 'CIBG — Registers',
|
||||
returnAddress: 'BIG-register\nPostbus 00000\n2500 AA Den Haag',
|
||||
footerContact: 'www.bigregister.nl\ninfo@voorbeeld.example',
|
||||
footerLegal: 'KvK 00000000',
|
||||
signatureName: 'A. de Vries',
|
||||
signatureRole: 'Hoofd Registratie',
|
||||
signatureClosing: 'Met vriendelijke groet,',
|
||||
margins: { topMm: 25, rightMm: 25, bottomMm: 25, leftMm: 25 },
|
||||
version: 1,
|
||||
},
|
||||
caseContext: {
|
||||
zorgverlenerNaam: 'Dr. A. (Anna) de Vries',
|
||||
bigNummer: '19012345601',
|
||||
beroep: 'arts',
|
||||
aanvraagReferentie: 'HER-2026-000842',
|
||||
},
|
||||
};
|
||||
|
||||
describe('brief.adapter parse boundary', () => {
|
||||
it('parses a well-formed view into the domain unions', () => {
|
||||
const r = parseBriefView(view);
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) return;
|
||||
expect(r.value.brief.status).toEqual({
|
||||
tag: 'submitted',
|
||||
submittedBy: 'demo-drafter',
|
||||
submittedAt: '2026-07-01',
|
||||
});
|
||||
const [passage, free] = r.value.brief.sections[0].blocks;
|
||||
expect(passage.type === 'passage' && passage.edited).toBe(true);
|
||||
expect(free.type).toBe('freeText');
|
||||
expect(r.value.brief.placeholders[1]).toEqual({
|
||||
key: 'code',
|
||||
label: 'Code',
|
||||
autoResolvable: true,
|
||||
fillable: false,
|
||||
});
|
||||
expect(r.value.decisions).toEqual({
|
||||
canEdit: false,
|
||||
canApprove: true,
|
||||
canReject: true,
|
||||
canSend: false,
|
||||
canRevealBigNummer: false,
|
||||
});
|
||||
// Guided-drafting tags survive the boundary; the untagged passage has neither.
|
||||
expect(r.value.availablePassages[0].besluit).toBeUndefined();
|
||||
expect(r.value.availablePassages[1]).toMatchObject({
|
||||
besluit: 'negatief',
|
||||
reason: 'onvoldoende_scholing',
|
||||
});
|
||||
expect(r.value.caseContext).toEqual({
|
||||
zorgverlenerNaam: 'Dr. A. (Anna) de Vries',
|
||||
bigNummer: '19012345601',
|
||||
beroep: 'arts',
|
||||
aanvraagReferentie: 'HER-2026-000842',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a view whose case context is missing or malformed', () => {
|
||||
expect(parseBriefView({ ...view, caseContext: undefined }).ok).toBe(false);
|
||||
expect(
|
||||
parseBriefView({
|
||||
...view,
|
||||
caseContext: { ...view.caseContext!, bigNummer: undefined as never },
|
||||
}).ok,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('parses the org template and drops a null logoDocumentId', () => {
|
||||
const r = parseOrgTemplate(view.orgTemplate);
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) return;
|
||||
expect(r.value.orgName).toBe('CIBG — Registers');
|
||||
expect(r.value.margins).toEqual({ topMm: 25, rightMm: 25, bottomMm: 25, leftMm: 25 });
|
||||
expect('logoDocumentId' in r.value).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a view whose org template is missing or malformed', () => {
|
||||
expect(parseBriefView({ ...view, orgTemplate: undefined }).ok).toBe(false);
|
||||
expect(parseOrgTemplate({ ...view.orgTemplate, signatureName: undefined }).ok).toBe(false);
|
||||
expect(
|
||||
parseOrgTemplate({
|
||||
...view.orgTemplate,
|
||||
margins: { topMm: 25, rightMm: 25, bottomMm: 25 },
|
||||
}).ok,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a view whose decisions are missing or malformed', () => {
|
||||
expect(parseBriefView({ ...view, decisions: undefined as never }).ok).toBe(false);
|
||||
expect(
|
||||
parseBriefView({ ...view, decisions: { ...view.decisions, canSend: 'yes' as never } }).ok,
|
||||
).toBe(false);
|
||||
// The PII-reveal flag (PRD-0002 §5c) is required at the boundary too.
|
||||
expect(
|
||||
parseBriefView({
|
||||
...view,
|
||||
decisions: { ...view.decisions, canRevealBigNummer: undefined as never },
|
||||
}).ok,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('narrows node variants and rejects unknown ones', () => {
|
||||
expect(parseNode({ type: 'text', text: 'x' })).toEqual({
|
||||
ok: true,
|
||||
value: { type: 'text', text: 'x' },
|
||||
});
|
||||
expect(parseNode({ type: 'placeholder', key: 'k' })).toEqual({
|
||||
ok: true,
|
||||
value: { type: 'placeholder', key: 'k' },
|
||||
});
|
||||
expect(parseNode({ type: 'lineBreak' })).toEqual({ ok: true, value: { type: 'lineBreak' } });
|
||||
expect(parseNode({ type: 'text' }).ok).toBe(false); // missing text
|
||||
expect(parseNode({ type: 'bogus' } as never).ok).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a status DTO missing its required fields', () => {
|
||||
expect(parseStatus({ tag: 'submitted' }).ok).toBe(false); // no submittedBy/At
|
||||
expect(parseStatus({ tag: 'rejected', rejectedBy: 'x', rejectedAt: 't' }).ok).toBe(false); // no comments
|
||||
expect(parseStatus({ tag: 'draft' }).ok).toBe(true);
|
||||
});
|
||||
|
||||
it('reads section.locked (default false) and paragraph.list', () => {
|
||||
const r = parseBrief({
|
||||
...view.brief,
|
||||
sections: [
|
||||
{
|
||||
sectionKey: 'aanhef',
|
||||
title: 'Aanhef',
|
||||
required: true,
|
||||
locked: true,
|
||||
blocks: [
|
||||
{
|
||||
type: 'freeText',
|
||||
blockId: 'b1',
|
||||
content: {
|
||||
paragraphs: [
|
||||
{ nodes: [{ type: 'text', text: 'een' }], list: 'bullet' },
|
||||
{ nodes: [{ type: 'text', text: 'twee' }], list: 'number' },
|
||||
{ nodes: [{ type: 'text', text: 'plat' }] },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{ sectionKey: 'kern', title: 'Kern', required: true, blocks: [] }, // no `locked` → false
|
||||
],
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) return;
|
||||
const [aanhef, kern] = r.value.sections;
|
||||
expect(aanhef.locked).toBe(true);
|
||||
expect(kern.locked).toBe(false);
|
||||
expect(aanhef.blocks[0].content.paragraphs.map((p) => p.list)).toEqual([
|
||||
'bullet',
|
||||
'number',
|
||||
undefined,
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects a library passage with an unknown scope', () => {
|
||||
const r = parseBriefView({
|
||||
...view,
|
||||
availablePassages: [{ ...view.availablePassages![0], scope: 'bogus' as never }],
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a passage block missing provenance', () => {
|
||||
const r = parseBrief({
|
||||
...view.brief,
|
||||
sections: [
|
||||
{
|
||||
sectionKey: 's',
|
||||
title: 'S',
|
||||
required: false,
|
||||
blocks: [{ type: 'passage', blockId: 'b', content: { paragraphs: [] } }],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,437 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Result, ok, err } from '@shared/kernel/fp';
|
||||
import { runSubmit } from '@shared/application/submit';
|
||||
import {
|
||||
ApiClient,
|
||||
BriefDecisionsDto,
|
||||
BriefDto,
|
||||
BriefStatusDto,
|
||||
BriefViewDto,
|
||||
CaseContextDto,
|
||||
LetterBlockDto,
|
||||
LetterSectionDto,
|
||||
LibraryPassageDto,
|
||||
OrgTemplateDto,
|
||||
PlaceholderDefDto,
|
||||
RichTextBlockDto,
|
||||
RichTextNodeDto,
|
||||
} from '@shared/infrastructure/api-client';
|
||||
import {
|
||||
Brief,
|
||||
BriefDecisions,
|
||||
BriefStatus,
|
||||
CaseContext,
|
||||
LetterBlock,
|
||||
LetterSection,
|
||||
LibraryPassage,
|
||||
} from '@brief/domain/brief';
|
||||
import { OrgTemplate } from '@brief/domain/org-template';
|
||||
import { PlaceholderDef } from '@brief/domain/placeholders';
|
||||
import { Mark, Paragraph, RichTextBlock, RichTextNode } from '@shared/kernel/rich-text';
|
||||
|
||||
/**
|
||||
* The only place brief HTTP lives (ADR-0001 anti-corruption boundary). The wire
|
||||
* uses FLAT unions (a `type`/`tag` string + nullable fields, the repo convention);
|
||||
* the `parse*` boundary narrows them into the domain's proper discriminated unions
|
||||
* and rejects malformed shapes. Mutations go through `runSubmit` (ProblemDetails →
|
||||
* error string), then parse the returned brief.
|
||||
*/
|
||||
|
||||
export interface BriefView {
|
||||
readonly brief: Brief;
|
||||
readonly availablePassages: LibraryPassage[];
|
||||
readonly decisions: BriefDecisions;
|
||||
readonly orgTemplate: OrgTemplate;
|
||||
readonly caseContext: CaseContext;
|
||||
}
|
||||
|
||||
export const BRIEF_LOAD_FAILED = $localize`:@@brief.load.failed:De brief kon niet worden geladen.`;
|
||||
export const BRIEF_ACTION_FAILED = $localize`:@@brief.action.failed:De actie is niet gelukt. Probeer het later opnieuw.`;
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class BriefAdapter {
|
||||
private client = inject(ApiClient);
|
||||
|
||||
async load(): Promise<Result<string, BriefView>> {
|
||||
const r = await runSubmit(() => this.client.briefGET(), BRIEF_LOAD_FAILED);
|
||||
return r.ok ? parseBriefView(r.value) : r;
|
||||
}
|
||||
|
||||
async save(sections: readonly LetterSection[]): Promise<Result<string, BriefView>> {
|
||||
const r = await runSubmit(
|
||||
() => this.client.briefPUT({ sections: sections.map(sectionToDto) }),
|
||||
BRIEF_ACTION_FAILED,
|
||||
);
|
||||
return r.ok ? parseBriefView(r.value) : r;
|
||||
}
|
||||
|
||||
async submit(): Promise<Result<string, BriefView>> {
|
||||
const r = await runSubmit(() => this.client.briefSubmit(), BRIEF_ACTION_FAILED);
|
||||
return r.ok ? parseBriefView(r.value) : r;
|
||||
}
|
||||
|
||||
async approve(): Promise<Result<string, BriefView>> {
|
||||
const r = await runSubmit(() => this.client.approve(), BRIEF_ACTION_FAILED);
|
||||
return r.ok ? parseBriefView(r.value) : r;
|
||||
}
|
||||
|
||||
async reject(comments: string): Promise<Result<string, BriefView>> {
|
||||
const r = await runSubmit(() => this.client.reject({ comments }), BRIEF_ACTION_FAILED);
|
||||
return r.ok ? parseBriefView(r.value) : r;
|
||||
}
|
||||
|
||||
async send(): Promise<Result<string, BriefView>> {
|
||||
const r = await runSubmit(() => this.client.send(), BRIEF_ACTION_FAILED);
|
||||
return r.ok ? parseBriefView(r.value) : r;
|
||||
}
|
||||
|
||||
/** Demo "start over" — recreate a fresh brief server-side and return the new view. */
|
||||
async reset(): Promise<Result<string, BriefView>> {
|
||||
const r = await runSubmit(() => this.client.briefReset(), BRIEF_ACTION_FAILED);
|
||||
return r.ok ? parseBriefView(r.value) : r;
|
||||
}
|
||||
}
|
||||
|
||||
// --- parse: wire (flat) → domain (discriminated unions), validating at the boundary ---
|
||||
|
||||
const MARKS: readonly string[] = ['bold', 'italic', 'underline'];
|
||||
|
||||
export function parseNode(dto: RichTextNodeDto): Result<string, RichTextNode> {
|
||||
switch (dto.type) {
|
||||
case 'text': {
|
||||
if (typeof dto.text !== 'string') return err('node: text missing text');
|
||||
const marks = dto.marks?.filter((m): m is Mark => MARKS.includes(m));
|
||||
return ok(
|
||||
marks && marks.length
|
||||
? { type: 'text', text: dto.text, marks }
|
||||
: { type: 'text', text: dto.text },
|
||||
);
|
||||
}
|
||||
case 'placeholder':
|
||||
return typeof dto.key === 'string'
|
||||
? ok({ type: 'placeholder', key: dto.key })
|
||||
: err('node: placeholder missing key');
|
||||
case 'lineBreak':
|
||||
return ok({ type: 'lineBreak' });
|
||||
default:
|
||||
return err(`node: unknown type ${dto.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function parseBlockContent(
|
||||
dto: RichTextBlockDto | undefined,
|
||||
): Result<string, RichTextBlock> {
|
||||
if (!dto || !Array.isArray(dto.paragraphs)) return err('content: paragraphs not an array');
|
||||
const paragraphs: Paragraph[] = [];
|
||||
for (const p of dto.paragraphs) {
|
||||
const nodes: RichTextNode[] = [];
|
||||
for (const n of p.nodes ?? []) {
|
||||
const parsed = parseNode(n);
|
||||
if (!parsed.ok) return parsed;
|
||||
nodes.push(parsed.value);
|
||||
}
|
||||
const list = p.list === 'bullet' || p.list === 'number' ? p.list : undefined;
|
||||
paragraphs.push(list ? { nodes, list } : { nodes });
|
||||
}
|
||||
return ok({ paragraphs });
|
||||
}
|
||||
|
||||
function parseBlock(dto: LetterBlockDto): Result<string, LetterBlock> {
|
||||
if (typeof dto.blockId !== 'string') return err('block: missing blockId');
|
||||
const content = parseBlockContent(dto.content);
|
||||
if (!content.ok) return content;
|
||||
switch (dto.type) {
|
||||
case 'passage':
|
||||
if (typeof dto.sourcePassageId !== 'string' || typeof dto.sourceVersion !== 'number')
|
||||
return err('block: bad passage provenance');
|
||||
return ok({
|
||||
type: 'passage',
|
||||
blockId: dto.blockId,
|
||||
sourcePassageId: dto.sourcePassageId,
|
||||
sourceVersion: dto.sourceVersion,
|
||||
content: content.value,
|
||||
edited: dto.edited ?? false,
|
||||
});
|
||||
case 'freeText':
|
||||
return ok({ type: 'freeText', blockId: dto.blockId, content: content.value });
|
||||
default:
|
||||
return err(`block: unknown type ${dto.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseSection(dto: LetterSectionDto): Result<string, LetterSection> {
|
||||
if (
|
||||
typeof dto.sectionKey !== 'string' ||
|
||||
typeof dto.title !== 'string' ||
|
||||
typeof dto.required !== 'boolean'
|
||||
) {
|
||||
return err('section: bad shape');
|
||||
}
|
||||
const blocks: LetterBlock[] = [];
|
||||
for (const b of dto.blocks ?? []) {
|
||||
const parsed = parseBlock(b);
|
||||
if (!parsed.ok) return parsed;
|
||||
blocks.push(parsed.value);
|
||||
}
|
||||
return ok({
|
||||
sectionKey: dto.sectionKey,
|
||||
title: dto.title,
|
||||
required: dto.required,
|
||||
locked: dto.locked ?? false,
|
||||
blocks,
|
||||
});
|
||||
}
|
||||
|
||||
function parsePlaceholderDef(dto: PlaceholderDefDto): Result<string, PlaceholderDef> {
|
||||
if (
|
||||
typeof dto.key !== 'string' ||
|
||||
typeof dto.label !== 'string' ||
|
||||
typeof dto.autoResolvable !== 'boolean'
|
||||
) {
|
||||
return err('placeholder: bad shape');
|
||||
}
|
||||
return ok({
|
||||
key: dto.key,
|
||||
label: dto.label,
|
||||
autoResolvable: dto.autoResolvable,
|
||||
...(dto.fillable != null ? { fillable: dto.fillable } : {}),
|
||||
...(dto.deprecated != null ? { deprecated: dto.deprecated } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
export function parseStatus(dto: BriefStatusDto | undefined): Result<string, BriefStatus> {
|
||||
switch (dto?.tag) {
|
||||
case 'draft':
|
||||
return ok({ tag: 'draft' });
|
||||
case 'submitted':
|
||||
if (typeof dto.submittedBy !== 'string' || typeof dto.submittedAt !== 'string')
|
||||
return err('status: bad submitted');
|
||||
return ok({ tag: 'submitted', submittedBy: dto.submittedBy, submittedAt: dto.submittedAt });
|
||||
case 'approved':
|
||||
if (typeof dto.approvedBy !== 'string' || typeof dto.approvedAt !== 'string')
|
||||
return err('status: bad approved');
|
||||
return ok({ tag: 'approved', approvedBy: dto.approvedBy, approvedAt: dto.approvedAt });
|
||||
case 'rejected':
|
||||
if (
|
||||
typeof dto.rejectedBy !== 'string' ||
|
||||
typeof dto.rejectedAt !== 'string' ||
|
||||
typeof dto.comments !== 'string'
|
||||
)
|
||||
return err('status: bad rejected');
|
||||
return ok({
|
||||
tag: 'rejected',
|
||||
rejectedBy: dto.rejectedBy,
|
||||
rejectedAt: dto.rejectedAt,
|
||||
comments: dto.comments,
|
||||
});
|
||||
case 'sent':
|
||||
if (typeof dto.sentAt !== 'string') return err('status: bad sent');
|
||||
return ok({ tag: 'sent', sentAt: dto.sentAt });
|
||||
default:
|
||||
return err(`status: unknown tag ${dto?.tag}`);
|
||||
}
|
||||
}
|
||||
|
||||
function parsePassage(dto: LibraryPassageDto): Result<string, LibraryPassage> {
|
||||
if (typeof dto.passageId !== 'string') return err('passage: bad shape');
|
||||
if (dto.scope !== 'global' && dto.scope !== 'beroep')
|
||||
return err(`passage: unknown scope ${dto.scope}`);
|
||||
if (
|
||||
typeof dto.sectionKey !== 'string' ||
|
||||
typeof dto.label !== 'string' ||
|
||||
typeof dto.version !== 'number'
|
||||
)
|
||||
return err('passage: bad shape');
|
||||
const content = parseBlockContent(dto.content);
|
||||
if (!content.ok) return content;
|
||||
return ok({
|
||||
passageId: dto.passageId,
|
||||
scope: dto.scope,
|
||||
sectionKey: dto.sectionKey,
|
||||
label: dto.label,
|
||||
content: content.value,
|
||||
version: dto.version,
|
||||
...(dto.beroep != null ? { beroep: dto.beroep } : {}),
|
||||
...(dto.besluit === 'positief' || dto.besluit === 'negatief' ? { besluit: dto.besluit } : {}),
|
||||
...(dto.reason != null ? { reason: dto.reason } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function parseCaseContext(dto: CaseContextDto | undefined): Result<string, CaseContext> {
|
||||
if (
|
||||
typeof dto?.zorgverlenerNaam !== 'string' ||
|
||||
typeof dto.bigNummer !== 'string' ||
|
||||
typeof dto.beroep !== 'string' ||
|
||||
typeof dto.aanvraagReferentie !== 'string'
|
||||
) {
|
||||
return err('brief-view: missing/invalid case context');
|
||||
}
|
||||
return ok({
|
||||
zorgverlenerNaam: dto.zorgverlenerNaam,
|
||||
bigNummer: dto.bigNummer,
|
||||
beroep: dto.beroep,
|
||||
aanvraagReferentie: dto.aanvraagReferentie,
|
||||
});
|
||||
}
|
||||
|
||||
export function parseBrief(dto: BriefDto): Result<string, Brief> {
|
||||
if (
|
||||
typeof dto.briefId !== 'string' ||
|
||||
typeof dto.drafterId !== 'string' ||
|
||||
typeof dto.beroep !== 'string' ||
|
||||
typeof dto.templateId !== 'string'
|
||||
) {
|
||||
return err('brief: missing ids');
|
||||
}
|
||||
const status = parseStatus(dto.status);
|
||||
if (!status.ok) return status;
|
||||
|
||||
const placeholders: PlaceholderDef[] = [];
|
||||
for (const p of dto.placeholders ?? []) {
|
||||
const parsed = parsePlaceholderDef(p);
|
||||
if (!parsed.ok) return parsed;
|
||||
placeholders.push(parsed.value);
|
||||
}
|
||||
const sections: LetterSection[] = [];
|
||||
for (const s of dto.sections ?? []) {
|
||||
const parsed = parseSection(s);
|
||||
if (!parsed.ok) return parsed;
|
||||
sections.push(parsed.value);
|
||||
}
|
||||
return ok({
|
||||
briefId: dto.briefId,
|
||||
beroep: dto.beroep,
|
||||
templateId: dto.templateId,
|
||||
placeholders,
|
||||
sections,
|
||||
status: status.value,
|
||||
drafterId: dto.drafterId,
|
||||
});
|
||||
}
|
||||
|
||||
function parseDecisions(dto: BriefDecisionsDto | undefined): Result<string, BriefDecisions> {
|
||||
if (
|
||||
typeof dto?.canEdit !== 'boolean' ||
|
||||
typeof dto.canApprove !== 'boolean' ||
|
||||
typeof dto.canReject !== 'boolean' ||
|
||||
typeof dto.canSend !== 'boolean' ||
|
||||
typeof dto.canRevealBigNummer !== 'boolean'
|
||||
) {
|
||||
return err('brief-view: missing/invalid decisions');
|
||||
}
|
||||
return ok({
|
||||
canEdit: dto.canEdit,
|
||||
canApprove: dto.canApprove,
|
||||
canReject: dto.canReject,
|
||||
canSend: dto.canSend,
|
||||
canRevealBigNummer: dto.canRevealBigNummer,
|
||||
});
|
||||
}
|
||||
|
||||
export function parseOrgTemplate(dto: OrgTemplateDto | undefined): Result<string, OrgTemplate> {
|
||||
if (
|
||||
typeof dto?.subOrgId !== 'string' ||
|
||||
typeof dto.orgName !== 'string' ||
|
||||
typeof dto.returnAddress !== 'string' ||
|
||||
typeof dto.footerContact !== 'string' ||
|
||||
typeof dto.footerLegal !== 'string' ||
|
||||
typeof dto.signatureName !== 'string' ||
|
||||
typeof dto.signatureRole !== 'string' ||
|
||||
typeof dto.signatureClosing !== 'string' ||
|
||||
typeof dto.version !== 'number'
|
||||
) {
|
||||
return err('org-template: bad shape');
|
||||
}
|
||||
const m = dto.margins;
|
||||
if (
|
||||
typeof m?.topMm !== 'number' ||
|
||||
typeof m.rightMm !== 'number' ||
|
||||
typeof m.bottomMm !== 'number' ||
|
||||
typeof m.leftMm !== 'number'
|
||||
) {
|
||||
return err('org-template: bad margins');
|
||||
}
|
||||
return ok({
|
||||
subOrgId: dto.subOrgId,
|
||||
orgName: dto.orgName,
|
||||
returnAddress: dto.returnAddress,
|
||||
...(dto.logoDocumentId != null ? { logoDocumentId: dto.logoDocumentId } : {}),
|
||||
footerContact: dto.footerContact,
|
||||
footerLegal: dto.footerLegal,
|
||||
signatureName: dto.signatureName,
|
||||
signatureRole: dto.signatureRole,
|
||||
signatureClosing: dto.signatureClosing,
|
||||
margins: { topMm: m.topMm, rightMm: m.rightMm, bottomMm: m.bottomMm, leftMm: m.leftMm },
|
||||
version: dto.version,
|
||||
});
|
||||
}
|
||||
|
||||
export function parseBriefView(dto: BriefViewDto): Result<string, BriefView> {
|
||||
if (!dto.brief) return err('brief-view: missing brief');
|
||||
const brief = parseBrief(dto.brief);
|
||||
if (!brief.ok) return brief;
|
||||
const decisions = parseDecisions(dto.decisions);
|
||||
if (!decisions.ok) return decisions;
|
||||
const orgTemplate = parseOrgTemplate(dto.orgTemplate);
|
||||
if (!orgTemplate.ok) return orgTemplate;
|
||||
const caseContext = parseCaseContext(dto.caseContext);
|
||||
if (!caseContext.ok) return caseContext;
|
||||
const availablePassages: LibraryPassage[] = [];
|
||||
for (const p of dto.availablePassages ?? []) {
|
||||
const parsed = parsePassage(p);
|
||||
if (!parsed.ok) return parsed;
|
||||
availablePassages.push(parsed.value);
|
||||
}
|
||||
return ok({
|
||||
brief: brief.value,
|
||||
availablePassages,
|
||||
decisions: decisions.value,
|
||||
orgTemplate: orgTemplate.value,
|
||||
caseContext: caseContext.value,
|
||||
});
|
||||
}
|
||||
|
||||
// --- toDto: domain → wire, for save (collapses the union to the flat shape) ---
|
||||
|
||||
function nodeToDto(n: RichTextNode): RichTextNodeDto {
|
||||
switch (n.type) {
|
||||
case 'text':
|
||||
return { type: 'text', text: n.text, ...(n.marks ? { marks: [...n.marks] } : {}) };
|
||||
case 'placeholder':
|
||||
return { type: 'placeholder', key: n.key };
|
||||
case 'lineBreak':
|
||||
return { type: 'lineBreak' };
|
||||
}
|
||||
}
|
||||
|
||||
function contentToDto(content: RichTextBlock): RichTextBlockDto {
|
||||
return {
|
||||
paragraphs: content.paragraphs.map((p) => ({
|
||||
nodes: p.nodes.map(nodeToDto),
|
||||
...(p.list ? { list: p.list } : {}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function blockToDto(b: LetterBlock): LetterBlockDto {
|
||||
return b.type === 'passage'
|
||||
? {
|
||||
type: 'passage',
|
||||
blockId: b.blockId,
|
||||
content: contentToDto(b.content),
|
||||
sourcePassageId: b.sourcePassageId,
|
||||
sourceVersion: b.sourceVersion,
|
||||
edited: b.edited,
|
||||
}
|
||||
: { type: 'freeText', blockId: b.blockId, content: contentToDto(b.content) };
|
||||
}
|
||||
|
||||
function sectionToDto(s: LetterSection): LetterSectionDto {
|
||||
return {
|
||||
sectionKey: s.sectionKey,
|
||||
title: s.title,
|
||||
required: s.required,
|
||||
locked: s.locked,
|
||||
blocks: s.blocks.map(blockToDto),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Result, ok, err } from '@shared/kernel/fp';
|
||||
import { currentRole } from '@shared/infrastructure/role';
|
||||
import { problemDetail } from '@shared/infrastructure/api-error';
|
||||
import { environment } from '@shared/environments/environment';
|
||||
|
||||
const PREVIEW_FAILED = $localize`:@@brief.preview.failed:De voorvertoning kon niet worden geopend.`;
|
||||
|
||||
/**
|
||||
* `/brief/preview` returns `text/html`, not JSON, and is `.ExcludeFromDescription()`'d
|
||||
* to keep the NSwag-generated client JSON-only (same seam as uploads) — so this is a
|
||||
* hand-written fetch, not the `ApiClient`. That also means it bypasses `HttpClient`'s
|
||||
* `roleInterceptor`, so `X-Role` is set here explicitly.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class LetterPreviewAdapter {
|
||||
async preview(): Promise<Result<string, Blob>> {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${environment.apiBaseUrl}/api/v1/brief/preview`, {
|
||||
headers: { 'X-Role': currentRole() },
|
||||
});
|
||||
} catch {
|
||||
return err(PREVIEW_FAILED);
|
||||
}
|
||||
if (!res.ok) return err(await errorMessage(res));
|
||||
return ok(await res.blob());
|
||||
}
|
||||
}
|
||||
|
||||
async function errorMessage(res: Response): Promise<string> {
|
||||
try {
|
||||
return problemDetail(await res.json(), PREVIEW_FAILED);
|
||||
} catch {
|
||||
return PREVIEW_FAILED;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { OrgTemplateAdminViewDto, OrgTemplateDto } from '@shared/infrastructure/api-client';
|
||||
import { parseOrgTemplateAdminView } from './org-template.adapter';
|
||||
|
||||
const draft: OrgTemplateDto = {
|
||||
subOrgId: 'cibg-registers',
|
||||
orgName: 'CIBG',
|
||||
returnAddress: 'Postbus 1',
|
||||
footerContact: 'info@cibg.nl',
|
||||
footerLegal: 'onderdeel van VWS',
|
||||
signatureName: 'A. de Vries',
|
||||
signatureRole: 'Hoofd Registratie',
|
||||
signatureClosing: 'Met vriendelijke groet,',
|
||||
margins: { topMm: 25, rightMm: 20, bottomMm: 25, leftMm: 20 },
|
||||
version: 3,
|
||||
};
|
||||
|
||||
const view: OrgTemplateAdminViewDto = {
|
||||
draft,
|
||||
publishedVersion: 3,
|
||||
unsentBriefs: 2,
|
||||
history: [{ version: 2, publishedAt: '2026-06-01', template: draft }],
|
||||
};
|
||||
|
||||
describe('parseOrgTemplateAdminView', () => {
|
||||
it('parses a well-formed admin view', () => {
|
||||
const r = parseOrgTemplateAdminView(view);
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) return;
|
||||
expect(r.value.draft.orgName).toBe('CIBG');
|
||||
expect(r.value.publishedVersion).toBe(3);
|
||||
expect(r.value.unsentBriefs).toBe(2);
|
||||
expect(r.value.history).toHaveLength(1);
|
||||
expect(r.value.history[0].version).toBe(2);
|
||||
});
|
||||
|
||||
it('rejects a missing draft', () => {
|
||||
const r = parseOrgTemplateAdminView({ ...view, draft: undefined });
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a missing count field', () => {
|
||||
const r = parseOrgTemplateAdminView({ ...view, unsentBriefs: undefined });
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a malformed history entry', () => {
|
||||
const r = parseOrgTemplateAdminView({
|
||||
...view,
|
||||
history: [
|
||||
{ version: 2, publishedAt: '2026-06-01', template: { ...draft, orgName: undefined } },
|
||||
],
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Result, ok, err } from '@shared/kernel/fp';
|
||||
import { runSubmit } from '@shared/application/submit';
|
||||
import { currentRole } from '@shared/infrastructure/role';
|
||||
import { problemDetail } from '@shared/infrastructure/api-error';
|
||||
import { environment } from '@shared/environments/environment';
|
||||
import {
|
||||
ApiClient,
|
||||
OrgTemplateAdminViewDto,
|
||||
OrgTemplateDto,
|
||||
OrgTemplateVersionDto,
|
||||
PublishOrgTemplateResponse,
|
||||
SubOrgSummaryDto,
|
||||
} from '@shared/infrastructure/api-client';
|
||||
import {
|
||||
OrgTemplate,
|
||||
OrgTemplateAdminView,
|
||||
OrgTemplateVersion,
|
||||
PublishResult,
|
||||
SubOrgSummary,
|
||||
} from '@brief/domain/org-template';
|
||||
import { parseOrgTemplate } from '@brief/infrastructure/brief.adapter';
|
||||
|
||||
/**
|
||||
* The only place admin org-template HTTP lives (ADR-0001 boundary). CRUD/publish/
|
||||
* rollback go through the generated client (X-Role added by `roleInterceptor`);
|
||||
* `parse*` narrows the untrusted wire shape. The proefbrief is `text/html` and
|
||||
* `ExcludeFromDescription`'d — a hand-written fetch, same seam as `letter-preview.adapter`.
|
||||
*/
|
||||
|
||||
const FAILED = $localize`:@@orgTemplate.action.failed:De actie is niet gelukt. Probeer het later opnieuw.`;
|
||||
const PROEFBRIEF_FAILED = $localize`:@@orgTemplate.proefbrief.failed:De proefbrief kon niet worden geopend.`;
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class OrgTemplateAdapter {
|
||||
private client = inject(ApiClient);
|
||||
|
||||
async list(): Promise<Result<string, SubOrgSummary[]>> {
|
||||
const r = await runSubmit(() => this.client.orgTemplates(), FAILED);
|
||||
if (!r.ok) return r;
|
||||
const out: SubOrgSummary[] = [];
|
||||
for (const s of r.value ?? []) {
|
||||
const parsed = parseSubOrg(s);
|
||||
if (!parsed.ok) return parsed;
|
||||
out.push(parsed.value);
|
||||
}
|
||||
return ok(out);
|
||||
}
|
||||
|
||||
async load(subOrgId: string): Promise<Result<string, OrgTemplateAdminView>> {
|
||||
const r = await runSubmit(() => this.client.orgTemplateGET(subOrgId), FAILED);
|
||||
return r.ok ? parseAdminView(r.value) : r;
|
||||
}
|
||||
|
||||
async save(subOrgId: string, draft: OrgTemplate): Promise<Result<string, OrgTemplateAdminView>> {
|
||||
const r = await runSubmit(
|
||||
() => this.client.orgTemplatePUT(subOrgId, { draft: toDto(draft) }),
|
||||
FAILED,
|
||||
);
|
||||
return r.ok ? parseAdminView(r.value) : r;
|
||||
}
|
||||
|
||||
async publish(subOrgId: string): Promise<Result<string, PublishResult>> {
|
||||
const r = await runSubmit(() => this.client.orgTemplatePublish(subOrgId), FAILED);
|
||||
return r.ok ? parsePublish(r.value) : r;
|
||||
}
|
||||
|
||||
async rollback(subOrgId: string, version: number): Promise<Result<string, OrgTemplateAdminView>> {
|
||||
const r = await runSubmit(() => this.client.orgTemplateRollback(subOrgId, version), FAILED);
|
||||
return r.ok ? parseAdminView(r.value) : r;
|
||||
}
|
||||
|
||||
/** Proefbrief: the unpublished draft rendered over a fixture letter, opened as a Blob. */
|
||||
async proefbrief(subOrgId: string): Promise<Result<string, Blob>> {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(
|
||||
`${environment.apiBaseUrl}/api/v1/admin/org-template/${encodeURIComponent(subOrgId)}/preview`,
|
||||
{ headers: { 'X-Role': currentRole() } },
|
||||
);
|
||||
} catch {
|
||||
return err(PROEFBRIEF_FAILED);
|
||||
}
|
||||
if (!res.ok) {
|
||||
try {
|
||||
return err(problemDetail(await res.json(), PROEFBRIEF_FAILED));
|
||||
} catch {
|
||||
return err(PROEFBRIEF_FAILED);
|
||||
}
|
||||
}
|
||||
return ok(await res.blob());
|
||||
}
|
||||
}
|
||||
|
||||
// --- parse: wire → domain, validating at the boundary ---
|
||||
|
||||
function parseSubOrg(dto: SubOrgSummaryDto): Result<string, SubOrgSummary> {
|
||||
if (typeof dto.subOrgId !== 'string' || typeof dto.orgName !== 'string')
|
||||
return err('sub-org: bad shape');
|
||||
return ok({
|
||||
subOrgId: dto.subOrgId,
|
||||
orgName: dto.orgName,
|
||||
publishedVersion: dto.publishedVersion ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
function parseVersion(dto: OrgTemplateVersionDto): Result<string, OrgTemplateVersion> {
|
||||
if (typeof dto.version !== 'number' || typeof dto.publishedAt !== 'string')
|
||||
return err('version: bad shape');
|
||||
const template = parseOrgTemplate(dto.template);
|
||||
if (!template.ok) return template;
|
||||
return ok({ version: dto.version, publishedAt: dto.publishedAt, template: template.value });
|
||||
}
|
||||
|
||||
export function parseOrgTemplateAdminView(
|
||||
dto: OrgTemplateAdminViewDto,
|
||||
): Result<string, OrgTemplateAdminView> {
|
||||
const draft = parseOrgTemplate(dto.draft);
|
||||
if (!draft.ok) return draft;
|
||||
if (typeof dto.publishedVersion !== 'number' || typeof dto.unsentBriefs !== 'number')
|
||||
return err('admin-view: bad shape');
|
||||
const history: OrgTemplateVersion[] = [];
|
||||
for (const v of dto.history ?? []) {
|
||||
const parsed = parseVersion(v);
|
||||
if (!parsed.ok) return parsed;
|
||||
history.push(parsed.value);
|
||||
}
|
||||
return ok({
|
||||
draft: draft.value,
|
||||
publishedVersion: dto.publishedVersion,
|
||||
history,
|
||||
unsentBriefs: dto.unsentBriefs,
|
||||
});
|
||||
}
|
||||
|
||||
const parseAdminView = parseOrgTemplateAdminView;
|
||||
|
||||
function parsePublish(dto: PublishOrgTemplateResponse): Result<string, PublishResult> {
|
||||
if (typeof dto.version !== 'number' || typeof dto.affectedUnsentBriefs !== 'number')
|
||||
return err('publish: bad shape');
|
||||
return ok({ version: dto.version, affectedUnsentBriefs: dto.affectedUnsentBriefs });
|
||||
}
|
||||
|
||||
// --- toDto: domain → wire (for save) ---
|
||||
|
||||
function toDto(t: OrgTemplate): OrgTemplateDto {
|
||||
return {
|
||||
subOrgId: t.subOrgId,
|
||||
orgName: t.orgName,
|
||||
returnAddress: t.returnAddress,
|
||||
...(t.logoDocumentId != null ? { logoDocumentId: t.logoDocumentId } : {}),
|
||||
footerContact: t.footerContact,
|
||||
footerLegal: t.footerLegal,
|
||||
signatureName: t.signatureName,
|
||||
signatureRole: t.signatureRole,
|
||||
signatureClosing: t.signatureClosing,
|
||||
margins: { ...t.margins },
|
||||
version: t.version,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Result, ok, err } from '@shared/kernel/fp';
|
||||
import { currentRole } from '@shared/infrastructure/role';
|
||||
import { problemDetail } from '@shared/infrastructure/api-error';
|
||||
import { environment } from '@shared/environments/environment';
|
||||
|
||||
const REVEAL_FAILED = $localize`:@@brief.reveal.failed:Het BIG-nummer kon niet worden getoond.`;
|
||||
|
||||
/**
|
||||
* Field-level PII reveal (PRD-0002 §5c). The case screen ships the BIG-nummer masked;
|
||||
* this unmasks it, gated server-side by the reveal capability AND a step-up. The
|
||||
* step-up is stubbed as the `X-Step-Up` header — the caller sends it only after the
|
||||
* user's confirm gesture, so a plain call (or a role without the capability) 403s.
|
||||
*
|
||||
* Hand-written fetch (not the `ApiClient`) because the call needs a per-request header;
|
||||
* `.ExcludeFromDescription()` on the endpoint keeps the generated client JSON-only, the
|
||||
* same seam as `/brief/preview` and uploads — which also means `X-Role` is set here.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class RevealBigNummerAdapter {
|
||||
async reveal(): Promise<Result<string, string>> {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${environment.apiBaseUrl}/api/v1/brief/reveal-bignummer`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Role': currentRole(), 'X-Step-Up': 'true' },
|
||||
});
|
||||
} catch {
|
||||
return err(REVEAL_FAILED);
|
||||
}
|
||||
if (!res.ok) return err(await errorMessage(res));
|
||||
const body: unknown = await res.json().catch(() => null);
|
||||
// Trust boundary: validate the shape before handing back a plain string.
|
||||
if (
|
||||
typeof body === 'object' &&
|
||||
body !== null &&
|
||||
typeof (body as { bigNummer?: unknown }).bigNummer === 'string'
|
||||
) {
|
||||
return ok((body as { bigNummer: string }).bigNummer);
|
||||
}
|
||||
return err(REVEAL_FAILED);
|
||||
}
|
||||
}
|
||||
|
||||
async function errorMessage(res: Response): Promise<string> {
|
||||
try {
|
||||
return problemDetail(await res.json(), REVEAL_FAILED);
|
||||
} catch {
|
||||
return REVEAL_FAILED;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import { Component, ElementRef, computed, input, output, viewChild } from '@angular/core';
|
||||
import { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||
import { MaskedValueComponent } from '@shared/ui/masked-value/masked-value.component';
|
||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||
import { StepperComponent } from '@shared/ui/stepper/stepper.component';
|
||||
import { Besluit, Brief, CaseContext, LibraryPassage } from '@brief/domain/brief';
|
||||
import { besluitGuidance, inferSelection } from '@brief/domain/besluit';
|
||||
import { Diagnostic } from '@brief/domain/placeholders';
|
||||
import { OrgTemplate } from '@brief/domain/org-template';
|
||||
import { BriefMsg } from '@brief/domain/brief.machine';
|
||||
import { LetterCanvasComponent } from '@brief/ui/letter-canvas/letter-canvas.component';
|
||||
import { DiagnosticsPanelComponent } from '@brief/ui/diagnostics-panel/diagnostics-panel.component';
|
||||
import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejection-comments.component';
|
||||
import { LetterEditorComponent } from '@brief/ui/letter-editor/letter-editor.component';
|
||||
import { BesluitPanelComponent } from '@brief/ui/besluit-panel/besluit-panel.component';
|
||||
|
||||
/** Organism: the behandelaar's drafting step. Frames "Brief opstellen" as one step in
|
||||
the case workflow — a case-context header + stepper (Beoordelen → Brief opstellen →
|
||||
Indienen, neighbours stubbed) — with the besluit-driven guidance, the lean letter
|
||||
editor, and an on-demand full-letter preview in a modal. Only ever renders for an
|
||||
editable brief (draft/rejected); the approver's read-only flow stays in
|
||||
letter-composer. Presentational: emits edit/submit/preview intents. */
|
||||
@Component({
|
||||
selector: 'app-behandel-scherm',
|
||||
imports: [
|
||||
ButtonComponent,
|
||||
HeadingComponent,
|
||||
MaskedValueComponent,
|
||||
AlertComponent,
|
||||
StepperComponent,
|
||||
LetterCanvasComponent,
|
||||
DiagnosticsPanelComponent,
|
||||
RejectionCommentsComponent,
|
||||
LetterEditorComponent,
|
||||
BesluitPanelComponent,
|
||||
],
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.case-head {
|
||||
margin-block-end: var(--rhc-space-max-lg);
|
||||
padding: var(--rhc-space-max-md) var(--rhc-space-max-lg);
|
||||
border-inline-start: 4px solid var(--rhc-color-primary, var(--rhc-color-border-strong));
|
||||
background: var(--rhc-color-background-subtle, transparent);
|
||||
}
|
||||
.case-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--rhc-space-max-md);
|
||||
color: var(--rhc-color-foreground-subtle);
|
||||
}
|
||||
.step-body {
|
||||
display: grid;
|
||||
gap: var(--rhc-space-max-xl);
|
||||
margin-block-start: var(--rhc-space-max-lg);
|
||||
}
|
||||
.bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--rhc-space-max-md);
|
||||
align-items: center;
|
||||
margin-block-start: var(--rhc-space-max-xl);
|
||||
}
|
||||
dialog {
|
||||
border: none;
|
||||
border-radius: var(--rhc-radius-md, 4px);
|
||||
padding: 0;
|
||||
max-width: min(900px, 95vw);
|
||||
width: 100%;
|
||||
}
|
||||
dialog::backdrop {
|
||||
background: rgb(0 0 0 / 45%); /* token-ok: modal scrim, not a palette colour */
|
||||
}
|
||||
.modal-body {
|
||||
padding: var(--rhc-space-max-lg);
|
||||
max-height: 80vh;
|
||||
overflow: auto;
|
||||
}
|
||||
.modal-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--rhc-space-max-md);
|
||||
padding: var(--rhc-space-max-md) var(--rhc-space-max-lg);
|
||||
border-block-start: 1px solid var(--rhc-color-border);
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<div class="case-head">
|
||||
<app-heading [level]="2">{{ caseHeading() }}</app-heading>
|
||||
<div class="case-meta">
|
||||
<span>{{ caseContext().aanvraagReferentie }}</span>
|
||||
<span>{{ caseContext().zorgverlenerNaam }}</span>
|
||||
<span>
|
||||
{{ bigLabel() }}
|
||||
<app-masked-value
|
||||
[value]="caseContext().bigNummer"
|
||||
[canReveal]="canRevealBigNummer()"
|
||||
[revealLabel]="revealLabel()"
|
||||
(reveal)="onReveal()"
|
||||
/>
|
||||
</span>
|
||||
<span>{{ caseContext().beroep }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<app-stepper
|
||||
[steps]="steps()"
|
||||
[current]="1"
|
||||
[processName]="processName()"
|
||||
[stepTitle]="stepTitle()"
|
||||
/>
|
||||
|
||||
<div class="step-body">
|
||||
@if (status() === 'rejected') {
|
||||
<app-rejection-comments mode="show" [comments]="rejectComments()" />
|
||||
}
|
||||
|
||||
<app-besluit-panel
|
||||
[passages]="availablePassages()"
|
||||
[besluit]="selection().besluit"
|
||||
[initialRedenen]="selection().reasons"
|
||||
(selectionChange)="onSelection($event)"
|
||||
/>
|
||||
|
||||
@if (guidance(); as g) {
|
||||
@if (g.needsReason) {
|
||||
<app-alert type="warning">{{ needsReasonHint }}</app-alert>
|
||||
} @else {
|
||||
<app-alert type="info">{{ insertedHint(g.insertedCount) }}</app-alert>
|
||||
}
|
||||
}
|
||||
|
||||
<app-letter-editor [brief]="brief()" [placeholders]="menu()" (edit)="edit.emit($event)" />
|
||||
|
||||
<app-diagnostics-panel [diagnostics]="diagnostics()" (locate)="locate.emit($event)" />
|
||||
|
||||
<div class="bar">
|
||||
<app-button variant="subtle" (click)="openPreview()">{{ previewLabel() }}</app-button>
|
||||
<app-button variant="primary" [disabled]="!canSubmit() || busy()" (click)="submit.emit()">{{
|
||||
submitLabel()
|
||||
}}</app-button>
|
||||
@if (!canSubmit()) {
|
||||
<span class="app-text-subtle">{{ submitHint() }}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dialog #previewDialog>
|
||||
<div class="modal-body">
|
||||
<app-letter-canvas
|
||||
[brief]="brief()"
|
||||
[orgTemplate]="orgTemplate()"
|
||||
[logoUrl]="logoUrl()"
|
||||
[editableRegions]="'none'"
|
||||
[diagnostics]="diagnostics()"
|
||||
/>
|
||||
</div>
|
||||
<div class="modal-bar">
|
||||
<app-button variant="secondary" (click)="preview.emit()">{{
|
||||
openDocumentLabel()
|
||||
}}</app-button>
|
||||
<app-button variant="primary" (click)="closePreview()">{{ closeLabel() }}</app-button>
|
||||
</div>
|
||||
</dialog>
|
||||
`,
|
||||
})
|
||||
export class BehandelSchermComponent {
|
||||
brief = input.required<Brief>();
|
||||
orgTemplate = input.required<OrgTemplate>();
|
||||
logoUrl = input<string | null>(null);
|
||||
availablePassages = input<readonly LibraryPassage[]>([]);
|
||||
diagnostics = input<readonly Diagnostic[]>([]);
|
||||
caseContext = input.required<CaseContext>();
|
||||
canSubmit = input(false);
|
||||
busy = input(false);
|
||||
/** Server decision (PRD-0002 §5c): may this actor unmask the case BIG-nummer? */
|
||||
canRevealBigNummer = input(false);
|
||||
|
||||
edit = output<BriefMsg>();
|
||||
submit = output<void>();
|
||||
preview = output<void>();
|
||||
locate = output<Diagnostic>();
|
||||
revealBigNummer = output<void>();
|
||||
|
||||
/** Step-up (PRD-0002 §5d) stubbed as a native confirm — the extra verification gesture
|
||||
before an audited PII reveal. ponytail: real systems prompt MFA / recent re-auth. */
|
||||
protected onReveal() {
|
||||
if (confirm(this.stepUpPrompt())) this.revealBigNummer.emit();
|
||||
}
|
||||
|
||||
private previewDialog = viewChild<ElementRef<HTMLDialogElement>>('previewDialog');
|
||||
|
||||
protected status = computed(() => this.brief().status.tag);
|
||||
protected rejectComments = computed(() => {
|
||||
const s = this.brief().status;
|
||||
return s.tag === 'rejected' ? s.comments : '';
|
||||
});
|
||||
|
||||
/** The besluit + redenen the letter currently reflects, read back off the kern's
|
||||
passages — this seeds the panel so it survives reload/undo (no separate storage). */
|
||||
protected selection = computed(() => {
|
||||
const kern = this.brief().sections.find((s) => s.sectionKey === 'kern');
|
||||
return inferSelection(kern?.blocks ?? [], this.availablePassages());
|
||||
});
|
||||
|
||||
/** Visible guidance for the current selection — null until a besluit is chosen (the
|
||||
panel's own intro copy prompts that first step). */
|
||||
protected guidance = computed(() => {
|
||||
const s = this.selection();
|
||||
return s.besluit ? besluitGuidance(this.availablePassages(), s.besluit, s.reasons) : null;
|
||||
});
|
||||
protected needsReasonHint = $localize`:@@brief.guidance.needsReason:Kies een reden, zodat de juiste motivering aan de brief wordt toegevoegd.`;
|
||||
protected insertedHint = (n: number) =>
|
||||
$localize`:@@brief.guidance.inserted:${n}:count: standaardtekst(en) toegevoegd op basis van het besluit. Vul aan met vrije tekst waar nodig.`;
|
||||
|
||||
// Same insert menu as the composer: only valid, fillable, non-deprecated fields.
|
||||
protected menu = computed<PlaceholderOption[]>(() =>
|
||||
this.brief()
|
||||
.placeholders.filter((p) => p.fillable !== false && !p.deprecated)
|
||||
.map((p) => ({ key: p.key, label: p.label, autoResolvable: p.autoResolvable })),
|
||||
);
|
||||
|
||||
/** Besluit/redenen changed → recompose the kern as one edit (= one undo step). */
|
||||
protected onSelection(sel: { besluit: Besluit | null; reasons: string[] }) {
|
||||
this.edit.emit({ tag: 'BesluitSelected', besluit: sel.besluit, reasons: sel.reasons });
|
||||
}
|
||||
|
||||
protected openPreview() {
|
||||
this.previewDialog()?.nativeElement.showModal();
|
||||
}
|
||||
protected closePreview() {
|
||||
this.previewDialog()?.nativeElement.close();
|
||||
}
|
||||
|
||||
protected submitLabel = computed(() =>
|
||||
this.status() === 'rejected'
|
||||
? $localize`:@@brief.resubmit:Opnieuw indienen`
|
||||
: $localize`:@@brief.submit:Indienen ter beoordeling`,
|
||||
);
|
||||
|
||||
protected steps = input<string[]>([
|
||||
$localize`:@@brief.step.beoordelen:Beoordelen`,
|
||||
$localize`:@@brief.step.opstellen:Brief opstellen`,
|
||||
$localize`:@@brief.step.indienen:Indienen`,
|
||||
]);
|
||||
protected processName = input($localize`:@@brief.process:Herregistratie behandelen`);
|
||||
protected stepTitle = input($localize`:@@brief.step.opstellen:Brief opstellen`);
|
||||
protected caseHeading = input($localize`:@@brief.case.heading:Aanvraag herregistratie`);
|
||||
protected bigLabel = input($localize`:@@brief.case.big:BIG-nummer`);
|
||||
protected revealLabel = input($localize`:@@brief.case.reveal:Toon BIG-nummer`);
|
||||
protected stepUpPrompt = input(
|
||||
$localize`:@@brief.case.revealConfirm:Extra verificatie vereist. Het tonen van het BIG-nummer wordt vastgelegd. Doorgaan?`,
|
||||
);
|
||||
protected previewLabel = input($localize`:@@brief.preview.open:Voorbeeld`);
|
||||
protected openDocumentLabel = input(
|
||||
$localize`:@@brief.preview.openDocument:Openen als document (PDF)`,
|
||||
);
|
||||
protected closeLabel = input($localize`:@@common.close:Sluiten`);
|
||||
protected submitHint = input(
|
||||
$localize`:@@brief.submitHint:Vul eerst alle verplichte secties en los fouten op.`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import {
|
||||
Brief,
|
||||
BriefStatus,
|
||||
CaseContext,
|
||||
LibraryPassage,
|
||||
allDiagnostics,
|
||||
} from '@brief/domain/brief';
|
||||
import { OrgTemplate } from '@brief/domain/org-template';
|
||||
import { BehandelSchermComponent } from './behandel-scherm.component';
|
||||
|
||||
const text = (t: string): LibraryPassage['content'] => ({
|
||||
paragraphs: [{ nodes: [{ type: 'text', text: t }] }],
|
||||
});
|
||||
|
||||
const orgTemplate: OrgTemplate = {
|
||||
subOrgId: 'cibg-registers',
|
||||
orgName: 'CIBG — Registers',
|
||||
returnAddress: 'BIG-register\nPostbus 00000\n2500 AA Den Haag',
|
||||
footerContact: 'www.bigregister.nl',
|
||||
footerLegal: 'Ons kenmerk vermelden bij correspondentie.',
|
||||
signatureName: 'A. de Vries',
|
||||
signatureRole: 'Hoofd Registratie',
|
||||
signatureClosing: 'Met vriendelijke groet,',
|
||||
margins: { topMm: 25, rightMm: 25, bottomMm: 25, leftMm: 25 },
|
||||
version: 1,
|
||||
};
|
||||
|
||||
const caseContext: CaseContext = {
|
||||
zorgverlenerNaam: 'Dr. A. (Anna) de Vries',
|
||||
bigNummer: '19012345601',
|
||||
beroep: 'arts',
|
||||
aanvraagReferentie: 'HER-2026-000842',
|
||||
};
|
||||
|
||||
const passages: LibraryPassage[] = [
|
||||
{
|
||||
passageId: 'p-kern-positief',
|
||||
scope: 'global',
|
||||
sectionKey: 'kern',
|
||||
label: 'Toewijzing',
|
||||
version: 1,
|
||||
besluit: 'positief',
|
||||
content: text('Uw aanvraag is toegewezen.'),
|
||||
},
|
||||
{
|
||||
passageId: 'p-kern-negatief',
|
||||
scope: 'global',
|
||||
sectionKey: 'kern',
|
||||
label: 'Afwijzing',
|
||||
version: 1,
|
||||
besluit: 'negatief',
|
||||
content: text('Uw aanvraag is afgewezen.'),
|
||||
},
|
||||
{
|
||||
passageId: 'p-kern-scholing',
|
||||
scope: 'global',
|
||||
sectionKey: 'kern',
|
||||
label: 'Onvoldoende scholing',
|
||||
version: 1,
|
||||
besluit: 'negatief',
|
||||
reason: 'onvoldoende_scholing',
|
||||
content: text('Onvoldoende scholing.'),
|
||||
},
|
||||
];
|
||||
|
||||
function brief(status: BriefStatus, kernBlocks: Brief['sections'][number]['blocks'] = []): Brief {
|
||||
return {
|
||||
briefId: 'b1',
|
||||
beroep: 'arts',
|
||||
templateId: 't1',
|
||||
drafterId: 'demo-drafter',
|
||||
status,
|
||||
placeholders: [
|
||||
{ key: 'naam_zorgverlener', label: 'Naam zorgverlener', autoResolvable: true },
|
||||
{ key: 'reden_besluit', label: 'Reden besluit', autoResolvable: false },
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
sectionKey: 'aanhef',
|
||||
title: 'Aanhef',
|
||||
required: true,
|
||||
locked: true,
|
||||
blocks: [{ type: 'freeText', blockId: 'aanhef-1', content: text('Geachte heer/mevrouw,') }],
|
||||
},
|
||||
{
|
||||
sectionKey: 'kern',
|
||||
title: 'Kern van het besluit',
|
||||
required: true,
|
||||
locked: false,
|
||||
blocks: kernBlocks,
|
||||
},
|
||||
{
|
||||
sectionKey: 'slot',
|
||||
title: 'Slot',
|
||||
required: false,
|
||||
locked: true,
|
||||
blocks: [{ type: 'freeText', blockId: 'slot-1', content: text('Met vriendelijke groet,') }],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const meta: Meta<BehandelSchermComponent> = {
|
||||
title: 'Domein/Brief/Behandel Scherm',
|
||||
component: BehandelSchermComponent,
|
||||
args: { orgTemplate, caseContext, availablePassages: passages, busy: false },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<BehandelSchermComponent>;
|
||||
|
||||
/** Fresh case: no besluit chosen yet, so the kern is empty and only the editable
|
||||
body shows (aanhef/slot appear in the preview). */
|
||||
export const EmptyKern: Story = {
|
||||
args: { brief: brief({ tag: 'draft' }), diagnostics: [], canSubmit: false },
|
||||
};
|
||||
|
||||
// A besluit-sourced kern block (carries provenance), so the panel re-seeds itself from it.
|
||||
const negatiefScholingKern: Brief['sections'][number]['blocks'] = [
|
||||
{
|
||||
type: 'passage',
|
||||
blockId: 'local-1',
|
||||
sourcePassageId: 'p-kern-negatief',
|
||||
sourceVersion: 1,
|
||||
edited: false,
|
||||
content: text('Uw aanvraag is afgewezen.'),
|
||||
},
|
||||
{
|
||||
type: 'passage',
|
||||
blockId: 'local-2',
|
||||
sourcePassageId: 'p-kern-scholing',
|
||||
sourceVersion: 1,
|
||||
edited: false,
|
||||
content: text('Onvoldoende scholing.'),
|
||||
},
|
||||
];
|
||||
|
||||
/** Draft with a negatief besluit: the kern is filled from the selection and the besluit
|
||||
panel reflects it (negatief + "onvoldoende scholing" ticked), read back off the kern. */
|
||||
export const WithContent: Story = {
|
||||
render: (args) => {
|
||||
const b = brief({ tag: 'draft' }, negatiefScholingKern);
|
||||
return { props: { ...args, brief: b, diagnostics: allDiagnostics(b), canSubmit: true } };
|
||||
},
|
||||
};
|
||||
|
||||
/** Field-level PII (PRD-0002 §5c): the case BIG-nummer arrives MASKED, as the server
|
||||
ships it. The behandelaar holds the reveal capability, so the "Toon BIG-nummer"
|
||||
action shows — it runs a step-up confirm and an audited server call before unmasking. */
|
||||
export const MaskedBigNummer: Story = {
|
||||
args: {
|
||||
brief: brief({ tag: 'draft' }),
|
||||
diagnostics: [],
|
||||
canSubmit: false,
|
||||
caseContext: { ...caseContext, bigNummer: '********601' },
|
||||
canRevealBigNummer: true,
|
||||
},
|
||||
};
|
||||
|
||||
/** Rejected: the drafter reopens; the rejection comments show above the editor. */
|
||||
export const Rejected: Story = {
|
||||
render: (args) => {
|
||||
const b = brief(
|
||||
{
|
||||
tag: 'rejected',
|
||||
rejectedBy: 'demo-approver',
|
||||
rejectedAt: '2026-07-01',
|
||||
comments: 'Graag de reden concreter.',
|
||||
},
|
||||
negatiefScholingKern,
|
||||
);
|
||||
return { props: { ...args, brief: b, diagnostics: allDiagnostics(b), canSubmit: true } };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
import { Component, computed, input, linkedSignal, output } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { CheckboxComponent } from '@shared/ui/checkbox/checkbox.component';
|
||||
import { RadioGroupComponent, RadioOption } from '@shared/ui/radio-group/radio-group.component';
|
||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||
import { Besluit, LibraryPassage } from '@brief/domain/brief';
|
||||
import { redenenFor } from '@brief/domain/besluit';
|
||||
|
||||
/** Organism: the guided-drafting selector. The behandelaar picks the besluit
|
||||
(positief/negatief) and — for a negatief besluit — the reden(en); the kern's
|
||||
standaardteksten follow the selection LIVE (`selectionChange` → the store recomposes
|
||||
the kern). This is the "no detective work" step: which passages belong is decided by
|
||||
the besluit, not by the drafter hunting the library.
|
||||
|
||||
ponytail: view-state signals, not a form-machine — no validation/submission of its own;
|
||||
it just reports the selection. `besluit`/`redenen` inputs re-seed it (via linkedSignal)
|
||||
from the persisted letter on reload/undo, so it always reflects the letter's real state. */
|
||||
@Component({
|
||||
selector: 'app-besluit-panel',
|
||||
imports: [FormsModule, CheckboxComponent, RadioGroupComponent, HeadingComponent],
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
padding: var(--rhc-space-max-lg);
|
||||
border: 1px solid var(--rhc-color-border);
|
||||
border-radius: var(--rhc-radius-md, 4px);
|
||||
background: var(--rhc-color-background-subtle, transparent);
|
||||
}
|
||||
.redenen {
|
||||
display: grid;
|
||||
gap: var(--rhc-space-max-sm);
|
||||
margin-block-start: var(--rhc-space-max-md);
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<app-heading [level]="3">{{ heading() }}</app-heading>
|
||||
<p class="app-text-subtle">{{ intro() }}</p>
|
||||
|
||||
<app-radio-group
|
||||
name="besluit"
|
||||
[options]="besluitOptions()"
|
||||
[ngModel]="selected()"
|
||||
(ngModelChange)="onBesluit($event)"
|
||||
/>
|
||||
|
||||
@if (redenen().length > 0) {
|
||||
<div class="redenen" role="group" [attr.aria-label]="redenenLabel()">
|
||||
@for (r of redenen(); track r.code) {
|
||||
<app-checkbox
|
||||
[checkboxId]="'reden-' + r.code"
|
||||
[label]="r.label"
|
||||
[ngModel]="checked().has(r.code)"
|
||||
(ngModelChange)="toggle(r.code, $event)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class BesluitPanelComponent {
|
||||
/** The passage library — the redenen checkboxes are derived from its negatief tags. */
|
||||
passages = input<readonly LibraryPassage[]>([]);
|
||||
/** The letter's current selection, inferred from its kern passages — re-seeds the panel. */
|
||||
besluit = input<Besluit | null>(null);
|
||||
initialRedenen = input<readonly string[]>([]);
|
||||
|
||||
selectionChange = output<{ besluit: Besluit | null; reasons: string[] }>();
|
||||
|
||||
protected selected = linkedSignal<Besluit | ''>(() => this.besluit() ?? '');
|
||||
protected checked = linkedSignal<ReadonlySet<string>>(() => new Set(this.initialRedenen()));
|
||||
|
||||
/** The reden checkboxes for the chosen besluit — derived from the reason-tagged passages. */
|
||||
protected redenen = computed(() =>
|
||||
this.selected() === '' ? [] : redenenFor(this.passages(), this.selected() as Besluit),
|
||||
);
|
||||
|
||||
protected onBesluit(value: string) {
|
||||
this.selected.set(value === 'positief' || value === 'negatief' ? value : '');
|
||||
this.checked.set(new Set()); // redenen only apply to the chosen besluit
|
||||
this.emit();
|
||||
}
|
||||
|
||||
protected toggle(code: string, on: boolean) {
|
||||
const next = new Set(this.checked());
|
||||
if (on) next.add(code);
|
||||
else next.delete(code);
|
||||
this.checked.set(next);
|
||||
this.emit();
|
||||
}
|
||||
|
||||
private emit() {
|
||||
this.selectionChange.emit({
|
||||
besluit: this.selected() === '' ? null : (this.selected() as Besluit),
|
||||
reasons: [...this.checked()],
|
||||
});
|
||||
}
|
||||
|
||||
protected besluitOptions = input<RadioOption[]>([
|
||||
{ value: 'positief', label: $localize`:@@brief.besluit.positief:Positief besluit (toewijzen)` },
|
||||
{ value: 'negatief', label: $localize`:@@brief.besluit.negatief:Negatief besluit (afwijzen)` },
|
||||
]);
|
||||
protected heading = input($localize`:@@brief.besluit.heading:Besluit`);
|
||||
protected intro = input(
|
||||
$localize`:@@brief.besluit.intro:Kies het besluit; de juiste standaardteksten verschijnen meteen in de brief.`,
|
||||
);
|
||||
protected redenenLabel = input($localize`:@@brief.besluit.redenen:Reden(en) voor afwijzing`);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { LibraryPassage } from '@brief/domain/brief';
|
||||
import { BesluitPanelComponent } from './besluit-panel.component';
|
||||
|
||||
const text = (t: string): LibraryPassage['content'] => ({
|
||||
paragraphs: [{ nodes: [{ type: 'text', text: t }] }],
|
||||
});
|
||||
|
||||
const passages: LibraryPassage[] = [
|
||||
{
|
||||
passageId: 'p-kern-positief',
|
||||
scope: 'global',
|
||||
sectionKey: 'kern',
|
||||
label: 'Toewijzing',
|
||||
version: 1,
|
||||
besluit: 'positief',
|
||||
content: text('Toegewezen.'),
|
||||
},
|
||||
{
|
||||
passageId: 'p-kern-negatief',
|
||||
scope: 'global',
|
||||
sectionKey: 'kern',
|
||||
label: 'Afwijzing',
|
||||
version: 1,
|
||||
besluit: 'negatief',
|
||||
content: text('Afgewezen.'),
|
||||
},
|
||||
{
|
||||
passageId: 'p-kern-scholing',
|
||||
scope: 'global',
|
||||
sectionKey: 'kern',
|
||||
label: 'Onvoldoende scholing',
|
||||
version: 1,
|
||||
besluit: 'negatief',
|
||||
reason: 'onvoldoende_scholing',
|
||||
content: text('Onvoldoende scholing.'),
|
||||
},
|
||||
{
|
||||
passageId: 'p-kern-gegevens',
|
||||
scope: 'global',
|
||||
sectionKey: 'kern',
|
||||
label: 'Onjuiste gegevens',
|
||||
version: 1,
|
||||
besluit: 'negatief',
|
||||
reason: 'onjuiste_gegevens',
|
||||
content: text('Onjuiste gegevens.'),
|
||||
},
|
||||
];
|
||||
|
||||
const meta: Meta<BesluitPanelComponent> = {
|
||||
title: 'Domein/Brief/Besluit Panel',
|
||||
component: BesluitPanelComponent,
|
||||
args: { passages },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<BesluitPanelComponent>;
|
||||
|
||||
/** Pick a besluit; a negatief besluit reveals the reason checkboxes. Each change emits
|
||||
`selectionChange` and the kern's standaardteksten follow live — no generate button. */
|
||||
export const Default: Story = {};
|
||||
|
||||
/** Re-seeded from a persisted letter: a negatief besluit with a reden already ticked. */
|
||||
export const NegatiefMetReden: Story = {
|
||||
args: { besluit: 'negatief', initialRedenen: ['onvoldoende_scholing'] },
|
||||
};
|
||||
@@ -0,0 +1,193 @@
|
||||
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 { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
import { ASYNC } from '@shared/ui/async/async.component';
|
||||
import { BriefStore } from '@brief/application/brief.store';
|
||||
import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-composer.component';
|
||||
import { BehandelSchermComponent } from '@brief/ui/behandel-scherm/behandel-scherm.component';
|
||||
|
||||
/** Page: thin container. Injects the root store, kicks off the load, and passes its
|
||||
derived read-model to the composer. Business/UI logic lives below in pure pieces;
|
||||
this just wires signals to the organism and events back to store commands. */
|
||||
@Component({
|
||||
selector: 'app-brief-page',
|
||||
imports: [
|
||||
PageShellComponent,
|
||||
AlertComponent,
|
||||
ButtonComponent,
|
||||
...ASYNC,
|
||||
LetterComposerComponent,
|
||||
BehandelSchermComponent,
|
||||
],
|
||||
host: { '(document:keydown)': 'onKey($event)' },
|
||||
styles: [
|
||||
`
|
||||
.brief-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--rhc-space-max-md);
|
||||
margin-block-end: var(--rhc-space-max-lg);
|
||||
}
|
||||
.toolbar-start {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--rhc-space-max-md);
|
||||
}
|
||||
.save {
|
||||
color: var(--rhc-color-foreground-subtle);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<app-page-shell [heading]="heading" [intro]="intro" backLink="/dashboard">
|
||||
@if (lastError(); as err) {
|
||||
<app-alert type="error">{{ err }}</app-alert>
|
||||
}
|
||||
|
||||
<app-async [data]="store.remoteData()">
|
||||
<ng-template appAsyncError>
|
||||
<app-alert type="error">{{ failedText }}</app-alert>
|
||||
<app-button variant="secondary" (click)="reload()">{{ retryText }}</app-button>
|
||||
</ng-template>
|
||||
<ng-template appAsyncLoaded>
|
||||
@if (loaded(); as s) {
|
||||
@if (store.orgTemplate(); as orgTemplate) {
|
||||
<div class="brief-toolbar">
|
||||
<div class="toolbar-start">
|
||||
@if (store.canEdit()) {
|
||||
<app-button
|
||||
variant="subtle"
|
||||
[disabled]="!store.canUndo()"
|
||||
[attr.aria-label]="undoLabel"
|
||||
(click)="store.undo()"
|
||||
>{{ undoLabel }}</app-button
|
||||
>
|
||||
<app-button
|
||||
variant="subtle"
|
||||
[disabled]="!store.canRedo()"
|
||||
[attr.aria-label]="redoLabel"
|
||||
(click)="store.redo()"
|
||||
>{{ redoLabel }}</app-button
|
||||
>
|
||||
}
|
||||
<span class="save" role="status" aria-live="polite">{{ saveText() }}</span>
|
||||
@if (store.saveState().tag === 'Error') {
|
||||
<app-button variant="secondary" (click)="store.retrySave()">{{
|
||||
retrySaveLabel
|
||||
}}</app-button>
|
||||
}
|
||||
</div>
|
||||
<app-button variant="subtle" [disabled]="store.busy()" (click)="resetDemo()">{{
|
||||
resetLabel
|
||||
}}</app-button>
|
||||
</div>
|
||||
@if (store.canEdit() && store.caseContext(); as caseContext) {
|
||||
<!-- Drafter (behandelaar): guided drafting step in the case workflow. -->
|
||||
<app-behandel-scherm
|
||||
[brief]="s.brief"
|
||||
[orgTemplate]="orgTemplate"
|
||||
[logoUrl]="store.logoUrl()"
|
||||
[availablePassages]="s.availablePassages"
|
||||
[diagnostics]="store.diagnostics()"
|
||||
[caseContext]="caseContext"
|
||||
[canSubmit]="store.canSubmit()"
|
||||
[busy]="store.busy()"
|
||||
[canRevealBigNummer]="store.canRevealBigNummer()"
|
||||
(edit)="store.edit($event)"
|
||||
(submit)="store.submit()"
|
||||
(preview)="store.previewLetter()"
|
||||
(revealBigNummer)="store.revealBigNummer()"
|
||||
/>
|
||||
} @else {
|
||||
<!-- Approver / read-only: review + approve/reject/send. -->
|
||||
<app-letter-composer
|
||||
[brief]="s.brief"
|
||||
[orgTemplate]="orgTemplate"
|
||||
[logoUrl]="store.logoUrl()"
|
||||
[diagnostics]="store.diagnostics()"
|
||||
[blockDiffs]="store.blockDiffs()"
|
||||
[removedCount]="store.removedSinceReject()"
|
||||
[canApprove]="store.canApprove()"
|
||||
[canReject]="store.canReject()"
|
||||
[canSend]="store.canSend()"
|
||||
[busy]="store.busy()"
|
||||
(approve)="store.approve()"
|
||||
(reject)="store.reject($event)"
|
||||
(send)="store.send()"
|
||||
(preview)="store.previewLetter()"
|
||||
/>
|
||||
}
|
||||
}
|
||||
}
|
||||
</ng-template>
|
||||
</app-async>
|
||||
</app-page-shell>
|
||||
`,
|
||||
})
|
||||
export class BriefPage {
|
||||
protected store = inject(BriefStore);
|
||||
protected model = this.store.model;
|
||||
protected lastError = this.store.lastError;
|
||||
|
||||
protected heading = $localize`:@@brief.page.heading:Brief opstellen`;
|
||||
protected intro = $localize`:@@brief.page.intro:Stel de brief aan de zorgverlener samen uit standaardteksten en vrije tekst.`;
|
||||
protected failedText = $localize`:@@brief.page.failed:De brief kon niet worden geladen.`;
|
||||
protected retryText = $localize`:@@brief.page.retry:Opnieuw proberen`;
|
||||
protected resetLabel = $localize`:@@brief.page.reset:Opnieuw beginnen (demo)`;
|
||||
protected undoLabel = $localize`:@@brief.page.undo:Ongedaan maken`;
|
||||
protected redoLabel = $localize`:@@brief.page.redo:Opnieuw uitvoeren`;
|
||||
protected retrySaveLabel = $localize`:@@brief.page.retrySave:Opnieuw proberen`;
|
||||
|
||||
private savingText = $localize`:@@brief.page.saving:Concept opslaan…`;
|
||||
private savedText = $localize`:@@brief.page.saved:Concept opgeslagen`;
|
||||
private saveErrorText = $localize`:@@brief.page.saveError:Niet opgeslagen — opnieuw proberen`;
|
||||
|
||||
/** Debounced-save state, surfaced in a polite live region. */
|
||||
protected saveText = computed(() => {
|
||||
switch (this.store.saveState().tag) {
|
||||
case 'Saving':
|
||||
return this.savingText;
|
||||
case 'Saved':
|
||||
return this.savedText;
|
||||
case 'Error':
|
||||
return this.saveErrorText;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
});
|
||||
|
||||
constructor() {
|
||||
void this.store.load();
|
||||
}
|
||||
|
||||
protected resetDemo() {
|
||||
void this.store.resetDemo();
|
||||
}
|
||||
|
||||
/** Typed narrowing for the `<app-async>` loaded slot — see WP-06: a structural
|
||||
directive's context can't inherit a generic from a sibling host input, so the
|
||||
Success value is unwrapped here instead of through `let-`. */
|
||||
protected readonly loaded = computed(() => {
|
||||
const s = this.model();
|
||||
return s.tag === 'loaded' ? s : undefined;
|
||||
});
|
||||
|
||||
protected reload() {
|
||||
void this.store.load();
|
||||
}
|
||||
|
||||
/** Ctrl/Cmd+Z = undo, Ctrl/Cmd+Shift+Z = redo (WP-27). Ignored while focus is in the
|
||||
rich-text editor or a form control, so the browser's own text undo keeps working
|
||||
there — our shell-level undo is for structural edits (add/remove/reorder blocks). */
|
||||
protected onKey(e: KeyboardEvent) {
|
||||
if (!(e.ctrlKey || e.metaKey) || e.key.toLowerCase() !== 'z' || !this.store.canEdit()) return;
|
||||
const t = e.target as HTMLElement | null;
|
||||
if (t && (t.isContentEditable || t.tagName === 'INPUT' || t.tagName === 'TEXTAREA')) return;
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) this.store.redo();
|
||||
else this.store.undo();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Component, computed, input, output } from '@angular/core';
|
||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||
import { Diagnostic } from '@brief/domain/placeholders';
|
||||
|
||||
/** Molecule: lists all letter diagnostics grouped by severity. Errors block
|
||||
save/send; warnings (deprecated, unresolved-at-send) are surfaced but allowed.
|
||||
Fed by a `computed()` over the letter content — never stored. */
|
||||
@Component({
|
||||
selector: 'app-diagnostics-panel',
|
||||
imports: [AlertComponent],
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
ul {
|
||||
margin: 0;
|
||||
padding-inline-start: 1.1rem;
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
button {
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
color: var(--rhc-color-foreground-link);
|
||||
cursor: pointer;
|
||||
text-align: start;
|
||||
text-decoration: underline;
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
@if (errors().length) {
|
||||
<app-alert type="error">
|
||||
<strong>{{ errorsTitle() }}</strong>
|
||||
<ul>
|
||||
@for (d of errors(); track $index) {
|
||||
<li>
|
||||
<button type="button" (click)="locate.emit(d)">{{ d.message }}</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</app-alert>
|
||||
}
|
||||
@if (warnings().length) {
|
||||
<app-alert type="warning">
|
||||
<strong>{{ warningsTitle() }}</strong>
|
||||
<ul>
|
||||
@for (d of warnings(); track $index) {
|
||||
<li>
|
||||
<button type="button" (click)="locate.emit(d)">{{ d.message }}</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</app-alert>
|
||||
}
|
||||
@if (!errors().length && !warnings().length) {
|
||||
<app-alert type="ok">{{ cleanText() }}</app-alert>
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class DiagnosticsPanelComponent {
|
||||
diagnostics = input<readonly Diagnostic[]>([]);
|
||||
locate = output<Diagnostic>();
|
||||
|
||||
errorsTitle = input($localize`:@@brief.diag.errors:Op te lossen voor indienen/versturen:`);
|
||||
warningsTitle = input($localize`:@@brief.diag.warnings:Aandachtspunten:`);
|
||||
cleanText = input($localize`:@@brief.diag.clean:Geen problemen gevonden in de velden.`);
|
||||
|
||||
protected errors = computed(() => this.diagnostics().filter((d) => d.severity === 'error'));
|
||||
protected warnings = computed(() => this.diagnostics().filter((d) => d.severity === 'warning'));
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { Diagnostic } from '@brief/domain/placeholders';
|
||||
import { DiagnosticsPanelComponent } from './diagnostics-panel.component';
|
||||
|
||||
const location = { blockId: 'local-2', paragraphIndex: 0, nodeIndex: 1 };
|
||||
|
||||
const errorAndWarning: Diagnostic[] = [
|
||||
{
|
||||
severity: 'error',
|
||||
code: 'unknown-placeholder',
|
||||
placeholderKey: 'onbekend_veld',
|
||||
location,
|
||||
message: 'Onbekend veld "onbekend_veld" — controleer de spelling.',
|
||||
},
|
||||
{
|
||||
severity: 'warning',
|
||||
code: 'unresolved-at-send',
|
||||
placeholderKey: 'reden_besluit',
|
||||
location,
|
||||
message: '"Reden besluit" is nog niet ingevuld.',
|
||||
},
|
||||
];
|
||||
|
||||
const meta: Meta<DiagnosticsPanelComponent> = {
|
||||
title: 'Domein/Brief/Diagnostics Panel',
|
||||
component: DiagnosticsPanelComponent,
|
||||
args: { locate: () => {} },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<DiagnosticsPanelComponent>;
|
||||
|
||||
export const Findings: Story = { args: { diagnostics: errorAndWarning } };
|
||||
export const Clean: Story = { args: { diagnostics: [] } };
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Component, computed, input, output } from '@angular/core';
|
||||
import { RichTextBlock } from '@shared/kernel/rich-text';
|
||||
import {
|
||||
RichTextEditorComponent,
|
||||
PlaceholderOption,
|
||||
} from '@shared/ui/rich-text-editor/rich-text-editor.component';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
import { LetterBlock } from '@brief/domain/brief';
|
||||
|
||||
/** Molecule: one block in a section — its editor plus provenance + block controls.
|
||||
Presentational: emits content/remove/move events; the section maps them to messages. */
|
||||
@Component({
|
||||
selector: 'app-letter-block',
|
||||
imports: [RichTextEditorComponent, ButtonComponent],
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.block {
|
||||
border-inline-start: var(--rhc-border-width-lg) solid var(--rhc-color-border-subtle);
|
||||
padding-inline-start: var(--rhc-space-max-md);
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--rhc-space-max-md);
|
||||
margin-block-end: var(--rhc-space-max-sm);
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: var(--rhc-space-max-sm);
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<div class="block">
|
||||
<div class="meta">
|
||||
<span class="app-text-subtle">{{ provenance() }}</span>
|
||||
@if (editable()) {
|
||||
<span class="controls">
|
||||
<app-button variant="subtle" (click)="moved.emit(-1)" i18n="@@brief.block.moveUp"
|
||||
>Omhoog</app-button
|
||||
>
|
||||
<app-button variant="subtle" (click)="moved.emit(1)" i18n="@@brief.block.moveDown"
|
||||
>Omlaag</app-button
|
||||
>
|
||||
<app-button variant="subtle" (click)="removed.emit()" i18n="@@brief.block.remove"
|
||||
>Verwijderen</app-button
|
||||
>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<app-rich-text-editor
|
||||
[content]="block().content"
|
||||
[placeholders]="placeholders()"
|
||||
[editable]="editable()"
|
||||
(contentChanged)="contentChanged.emit($event)"
|
||||
/>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class LetterBlockComponent {
|
||||
block = input.required<LetterBlock>();
|
||||
placeholders = input<readonly PlaceholderOption[]>([]);
|
||||
editable = input(false);
|
||||
contentChanged = output<RichTextBlock>();
|
||||
removed = output<void>();
|
||||
moved = output<-1 | 1>();
|
||||
|
||||
protected provenance = computed(() => {
|
||||
const b = this.block();
|
||||
if (b.type === 'freeText') return $localize`:@@brief.provenance.free:Vrije tekst`;
|
||||
return b.edited
|
||||
? $localize`:@@brief.provenance.edited:Aangepaste standaardtekst`
|
||||
: $localize`:@@brief.provenance.standard:Standaardtekst`;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { LetterBlock } from '@brief/domain/brief';
|
||||
import { LetterBlockComponent } from './letter-block.component';
|
||||
|
||||
const passageBlock: LetterBlock = {
|
||||
type: 'passage',
|
||||
blockId: 'local-1',
|
||||
sourcePassageId: 'p1',
|
||||
sourceVersion: 1,
|
||||
edited: false,
|
||||
content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Geachte heer/mevrouw,' }] }] },
|
||||
};
|
||||
|
||||
const freeTextBlock: LetterBlock = {
|
||||
type: 'freeText',
|
||||
blockId: 'local-2',
|
||||
content: {
|
||||
paragraphs: [
|
||||
{
|
||||
nodes: [
|
||||
{ type: 'text', text: 'Wij hebben besloten om reden ' },
|
||||
{ type: 'placeholder', key: 'reden_besluit' },
|
||||
{ type: 'text', text: '.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const placeholders = [{ key: 'reden_besluit', label: 'Reden besluit' }];
|
||||
|
||||
const meta: Meta<LetterBlockComponent> = {
|
||||
title: 'Domein/Brief/Letter Block',
|
||||
component: LetterBlockComponent,
|
||||
args: {
|
||||
block: passageBlock,
|
||||
placeholders,
|
||||
contentChanged: () => {},
|
||||
removed: () => {},
|
||||
moved: () => {},
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<LetterBlockComponent>;
|
||||
|
||||
export const ReadOnlyPassage: Story = { args: { editable: false } };
|
||||
export const EditableFreeText: Story = { args: { block: freeTextBlock, editable: true } };
|
||||
@@ -0,0 +1,463 @@
|
||||
import {
|
||||
Component,
|
||||
DestroyRef,
|
||||
ElementRef,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
linkedSignal,
|
||||
output,
|
||||
signal,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { NgTemplateOutlet } from '@angular/common';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
import { PlaceholderChipComponent } from '@shared/ui/placeholder-chip/placeholder-chip.component';
|
||||
import { formatDatumNl } from '@shared/kernel/datum';
|
||||
import { Paragraph } from '@shared/kernel/rich-text';
|
||||
import { Brief, LetterBlock } from '@brief/domain/brief';
|
||||
import { OrgTemplate } from '@brief/domain/org-template';
|
||||
import { OrgTemplateTextField } from '@brief/domain/org-template.machine';
|
||||
import { Diagnostic } from '@brief/domain/placeholders';
|
||||
import { BlockDiffKind } from '@brief/domain/brief-diff';
|
||||
|
||||
/** A run of consecutive lines to render together: a list (bullet/number) or a single plain line. */
|
||||
type PreviewSegment = {
|
||||
readonly list: 'bullet' | 'number' | null;
|
||||
readonly items: readonly Paragraph[];
|
||||
};
|
||||
|
||||
function groupParagraphs(paras: readonly Paragraph[]): PreviewSegment[] {
|
||||
const out: { list: 'bullet' | 'number' | null; items: Paragraph[] }[] = [];
|
||||
for (const para of paras) {
|
||||
const kind = para.list ?? null;
|
||||
const last = out[out.length - 1];
|
||||
if (kind && last && last.list === kind) last.items.push(para);
|
||||
else out.push({ list: kind, items: [para] });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Illustrative values for the "Voorbeeld" toggle — what send resolves server-side.
|
||||
const SAMPLE_VALUES: Record<string, string> = {
|
||||
naam_zorgverlener: 'J. Jansen',
|
||||
big_nummer: '12345678901',
|
||||
};
|
||||
|
||||
/** A4 height in CSS px (1in = 96px = 25.4mm) — for the approximate page-break marks. */
|
||||
const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
||||
|
||||
/** Organism: the letter as one surface — the org template's letterhead, signature and
|
||||
footer around the case-type template's sections. `editableRegions` picks who edits
|
||||
what: `'content'` hosts the editable letter-sections in place (drafter), `'none'`
|
||||
renders everything read-only (approver/locked, absorbs the old letter-preview),
|
||||
`'template'` reserves the org-identity regions for the admin editor (WP-26).
|
||||
Letter typography/geometry come from the shared `public/letter.css` contract —
|
||||
the same file the backend preview renderer inlines (WP-25). */
|
||||
@Component({
|
||||
selector: 'app-letter-canvas',
|
||||
imports: [NgTemplateOutlet, ButtonComponent, PlaceholderChipComponent],
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--rhc-space-max-sm);
|
||||
margin-block-end: var(--rhc-space-max-sm);
|
||||
}
|
||||
.zoom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--rhc-space-max-sm);
|
||||
}
|
||||
.zoom-pct {
|
||||
min-width: 3.5ch;
|
||||
text-align: center;
|
||||
color: var(--rhc-color-foreground-subtle);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
/* Rejection-diff badge (WP-27): a small pill above a changed/added block. */
|
||||
.diff-block.diff-changed {
|
||||
border-inline-start: 3px solid var(--rhc-color-oranje-500);
|
||||
padding-inline-start: var(--rhc-space-max-sm);
|
||||
}
|
||||
.diff-badge {
|
||||
display: inline-block;
|
||||
margin-block-end: 1mm;
|
||||
padding: 0 1.5mm;
|
||||
border-radius: var(--rhc-border-radius-sm);
|
||||
font-size: 7.5pt;
|
||||
/* changed = dark text on oranje-500 (4.79:1); white on oranje-500 fails (3.23:1). */
|
||||
color: var(--rhc-color-foreground-default);
|
||||
background: var(--rhc-color-oranje-500);
|
||||
}
|
||||
.diff-badge.added {
|
||||
/* added = white on groen-700 (6.4:1); dark text on any green fails 4.5:1 (WP-29 axe). */
|
||||
color: var(--rhc-color-wit);
|
||||
background: var(--rhc-color-groen-700);
|
||||
}
|
||||
/* Portal-side chrome around the letter surface (not part of the contract file). */
|
||||
.surface {
|
||||
background: var(--rhc-color-grijs-100);
|
||||
border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);
|
||||
border-radius: var(--rhc-border-radius-md);
|
||||
padding: var(--rhc-space-max-lg);
|
||||
overflow-x: auto;
|
||||
}
|
||||
.surface .letter {
|
||||
box-shadow: 0 1px 4px rgb(0 0 0 / 0.15); /* token-ok: paper drop-shadow, not a palette colour */
|
||||
}
|
||||
/* Admin edit-in-place (editableRegions='template'): the org-identity fields
|
||||
become controls styled to sit in the letter, with a visible editable affordance. */
|
||||
.tmpl-input,
|
||||
.tmpl-textarea {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
background: var(--rhc-color-geel-100);
|
||||
border: var(--rhc-border-width-sm) dashed var(--rhc-color-border-strong);
|
||||
border-radius: var(--rhc-border-radius-sm);
|
||||
padding: 0.5mm 1mm;
|
||||
}
|
||||
.tmpl-textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
.org-logo {
|
||||
max-height: 20mm;
|
||||
max-width: 60mm;
|
||||
margin-block-end: 3mm;
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<ng-template #line let-nodes>
|
||||
@for (node of nodes; track $index) {
|
||||
@switch (node.type) {
|
||||
@case ('text') {
|
||||
<span>{{ node.text }}</span>
|
||||
}
|
||||
@case ('lineBreak') {
|
||||
<br />
|
||||
}
|
||||
@case ('placeholder') {
|
||||
@if (showSample() && autoFor(node.key)) {
|
||||
<span>{{ sampleFor(node.key) }}</span>
|
||||
} @else {
|
||||
<app-placeholder-chip
|
||||
[label]="labelFor(node.key)"
|
||||
[autoResolvable]="autoFor(node.key)"
|
||||
[state]="stateFor(node.key)"
|
||||
/>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
@if (editableRegions() !== 'template') {
|
||||
<div class="toolbar">
|
||||
<div class="zoom" role="group" [attr.aria-label]="zoomGroupLabel()">
|
||||
<app-button
|
||||
variant="subtle"
|
||||
[disabled]="zoomLevel() <= 0.5"
|
||||
[attr.aria-label]="zoomOutLabel()"
|
||||
(click)="zoomBy(-0.1)"
|
||||
>−</app-button
|
||||
>
|
||||
<span class="zoom-pct">{{ zoomPct() }}</span>
|
||||
<app-button
|
||||
variant="subtle"
|
||||
[disabled]="zoomLevel() >= 1.5"
|
||||
[attr.aria-label]="zoomInLabel()"
|
||||
(click)="zoomBy(0.1)"
|
||||
>+</app-button
|
||||
>
|
||||
<app-button variant="subtle" (click)="zoomLevel.set(1)">{{
|
||||
zoomResetLabel()
|
||||
}}</app-button>
|
||||
</div>
|
||||
@if (editableRegions() === 'none') {
|
||||
<app-button
|
||||
variant="subtle"
|
||||
(click)="showSample.set(!showSample())"
|
||||
[attr.aria-pressed]="showSample()"
|
||||
>
|
||||
{{ showSample() ? hideSampleLabel() : showSampleLabel() }}
|
||||
</app-button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="surface">
|
||||
<div class="letter" #page [style]="marginStyle()" [style.zoom]="zoomLevel()">
|
||||
<!-- div, not <header>/<footer>: the CIBG huisstijl styles those bare elements
|
||||
(robijn footer background) — the letter surface must stay letter.css-only. -->
|
||||
<div class="letter__letterhead">
|
||||
@if (logoUrl()) {
|
||||
<img class="org-logo" [src]="logoUrl()" [alt]="logoAlt()" />
|
||||
}
|
||||
@if (editing()) {
|
||||
<input
|
||||
class="tmpl-input org-wordmark"
|
||||
[value]="orgTemplate().orgName"
|
||||
[attr.aria-label]="orgNameLabel()"
|
||||
(input)="emitEdit('orgName', $event)"
|
||||
/>
|
||||
<textarea
|
||||
class="tmpl-textarea return-address"
|
||||
rows="2"
|
||||
[value]="orgTemplate().returnAddress"
|
||||
[attr.aria-label]="returnAddressLabel()"
|
||||
(input)="emitEdit('returnAddress', $event)"
|
||||
></textarea>
|
||||
} @else {
|
||||
<p class="org-wordmark">{{ orgTemplate().orgName }}</p>
|
||||
<address class="return-address">{{ orgTemplate().returnAddress }}</address>
|
||||
}
|
||||
<address class="address-window">{{ recipientText() }}</address>
|
||||
<dl class="reference">
|
||||
<div>
|
||||
<dt>{{ referenceLabel() }}</dt>
|
||||
<dd>{{ brief().briefId }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ dateLabel() }}</dt>
|
||||
<dd>{{ letterDate }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="letter__body">
|
||||
@for (section of brief().sections; track section.sectionKey) {
|
||||
<section>
|
||||
<h3>{{ section.title }}</h3>
|
||||
@for (block of section.blocks; track block.blockId) {
|
||||
@let diffKind = showDiff() ? blockDiffs().get(block.blockId) : undefined;
|
||||
<div class="diff-block" [class.diff-changed]="!!diffKind">
|
||||
@if (diffKind) {
|
||||
<span class="diff-badge" [class.added]="diffKind === 'added'">{{
|
||||
diffLabel(diffKind)
|
||||
}}</span>
|
||||
}
|
||||
@for (seg of segmentsOf(block); track $index) {
|
||||
@if (seg.list === 'bullet') {
|
||||
<ul>
|
||||
@for (para of seg.items; track $index) {
|
||||
<li>
|
||||
<ng-container
|
||||
[ngTemplateOutlet]="line"
|
||||
[ngTemplateOutletContext]="{ $implicit: para.nodes }"
|
||||
/>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
} @else if (seg.list === 'number') {
|
||||
<ol>
|
||||
@for (para of seg.items; track $index) {
|
||||
<li>
|
||||
<ng-container
|
||||
[ngTemplateOutlet]="line"
|
||||
[ngTemplateOutletContext]="{ $implicit: para.nodes }"
|
||||
/>
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
} @else {
|
||||
<p>
|
||||
<ng-container
|
||||
[ngTemplateOutlet]="line"
|
||||
[ngTemplateOutletContext]="{ $implicit: seg.items[0].nodes }"
|
||||
/>
|
||||
</p>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="letter__signature">
|
||||
@if (editing()) {
|
||||
<input
|
||||
class="tmpl-input"
|
||||
[value]="orgTemplate().signatureClosing"
|
||||
[attr.aria-label]="signatureClosingLabel()"
|
||||
(input)="emitEdit('signatureClosing', $event)"
|
||||
/>
|
||||
<input
|
||||
class="tmpl-input signature-name"
|
||||
[value]="orgTemplate().signatureName"
|
||||
[attr.aria-label]="signatureNameLabel()"
|
||||
(input)="emitEdit('signatureName', $event)"
|
||||
/>
|
||||
<input
|
||||
class="tmpl-input"
|
||||
[value]="orgTemplate().signatureRole"
|
||||
[attr.aria-label]="signatureRoleLabel()"
|
||||
(input)="emitEdit('signatureRole', $event)"
|
||||
/>
|
||||
} @else {
|
||||
<p>{{ orgTemplate().signatureClosing }}</p>
|
||||
<p class="signature-name">{{ orgTemplate().signatureName }}</p>
|
||||
<p>{{ orgTemplate().signatureRole }}</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="letter__footer">
|
||||
@if (editing()) {
|
||||
<textarea
|
||||
class="tmpl-textarea footer-contact"
|
||||
rows="2"
|
||||
[value]="orgTemplate().footerContact"
|
||||
[attr.aria-label]="footerContactLabel()"
|
||||
(input)="emitEdit('footerContact', $event)"
|
||||
></textarea>
|
||||
<input
|
||||
class="tmpl-input footer-legal"
|
||||
[value]="orgTemplate().footerLegal"
|
||||
[attr.aria-label]="footerLegalLabel()"
|
||||
(input)="emitEdit('footerLegal', $event)"
|
||||
/>
|
||||
} @else {
|
||||
<div class="footer-contact">{{ orgTemplate().footerContact }}</div>
|
||||
<div class="footer-legal">{{ orgTemplate().footerLegal }}</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@for (top of pageBreaks(); track $index) {
|
||||
<div class="letter__page-break" [style.top.px]="top" aria-hidden="true">
|
||||
<span>{{ pageBreakCaption() }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class LetterCanvasComponent {
|
||||
brief = input.required<Brief>();
|
||||
orgTemplate = input.required<OrgTemplate>();
|
||||
/** Who edits what on the surface: read-only ('none', the drafter preview + approver
|
||||
view) or admin editor ('template', WP-26). Authoring moved to letter-editor. */
|
||||
editableRegions = input<'template' | 'none'>('none');
|
||||
diagnostics = input<readonly Diagnostic[]>([]);
|
||||
/** Initial zoom; the in-canvas controls take over from here (WP-27). */
|
||||
zoom = input(1);
|
||||
/** Blocks changed/added/removed since the letter was rejected (WP-27); badged when
|
||||
`showDiff` is on. Removed blocks aren't in the map's rendered set — they no longer
|
||||
exist in the letter — the composer surfaces them as a count. */
|
||||
blockDiffs = input<ReadonlyMap<string, BlockDiffKind>>(new Map());
|
||||
showDiff = input(false);
|
||||
/** The org logo's content URL (letterhead), or null when none is set. */
|
||||
logoUrl = input<string | null>(null);
|
||||
/** An in-place edit to an org-identity field (only in `editableRegions='template'`). */
|
||||
templateEdit = output<{ field: OrgTemplateTextField; value: string }>();
|
||||
|
||||
showSampleLabel = input($localize`:@@brief.preview.showSample:Voorbeeld met testwaarden`);
|
||||
hideSampleLabel = input($localize`:@@brief.preview.hideSample:Testwaarden verbergen`);
|
||||
pageBreakCaption = input(
|
||||
$localize`:@@brief.canvas.pageBreak:±pagina-einde — afdrukvoorbeeld is leidend`,
|
||||
);
|
||||
recipientText = input(
|
||||
$localize`:@@brief.canvas.recipient:Adres van de geadresseerde\n(wordt ingevuld bij verzending)`,
|
||||
);
|
||||
referenceLabel = input($localize`:@@brief.canvas.reference:Ons kenmerk`);
|
||||
dateLabel = input($localize`:@@brief.canvas.date:Datum`);
|
||||
logoAlt = input($localize`:@@brief.canvas.logoAlt:Logo van de organisatie`);
|
||||
orgNameLabel = input($localize`:@@brief.canvas.orgName:Organisatienaam`);
|
||||
returnAddressLabel = input($localize`:@@brief.canvas.returnAddress:Retouradres`);
|
||||
signatureClosingLabel = input($localize`:@@brief.canvas.signatureClosing:Afsluiting`);
|
||||
signatureNameLabel = input($localize`:@@brief.canvas.signatureName:Naam ondertekenaar`);
|
||||
signatureRoleLabel = input($localize`:@@brief.canvas.signatureRole:Functie ondertekenaar`);
|
||||
footerContactLabel = input($localize`:@@brief.canvas.footerContact:Contactgegevens (voettekst)`);
|
||||
footerLegalLabel = input($localize`:@@brief.canvas.footerLegal:Juridische voettekst`);
|
||||
zoomGroupLabel = input($localize`:@@brief.canvas.zoom:Zoomniveau`);
|
||||
zoomInLabel = input($localize`:@@brief.canvas.zoomIn:Inzoomen`);
|
||||
zoomOutLabel = input($localize`:@@brief.canvas.zoomOut:Uitzoomen`);
|
||||
zoomResetLabel = input($localize`:@@brief.canvas.zoomReset:100%`);
|
||||
addedLabel = input($localize`:@@brief.diff.added:nieuw`);
|
||||
changedLabel = input($localize`:@@brief.diff.changed:gewijzigd sinds afwijzing`);
|
||||
|
||||
protected showSample = signal(false);
|
||||
protected letterDate = formatDatumNl(new Date());
|
||||
|
||||
/** Zoom seeded from the input; the +/−/reset controls drive it from there. */
|
||||
protected zoomLevel = linkedSignal(() => this.zoom());
|
||||
protected zoomPct = computed(() => `${Math.round(this.zoomLevel() * 100)}%`);
|
||||
protected zoomBy(delta: number) {
|
||||
// clamp 0.5–1.5; round to avoid float drift accumulating on repeated clicks.
|
||||
this.zoomLevel.update((z) => Math.round(Math.min(1.5, Math.max(0.5, z + delta)) * 10) / 10);
|
||||
}
|
||||
protected diffLabel = (kind: BlockDiffKind) =>
|
||||
kind === 'added' ? this.addedLabel() : this.changedLabel();
|
||||
|
||||
/** Admin edit-in-place: the org-identity regions render as controls. */
|
||||
protected editing = computed(() => this.editableRegions() === 'template');
|
||||
|
||||
protected emitEdit(field: OrgTemplateTextField, event: Event) {
|
||||
this.templateEdit.emit({
|
||||
field,
|
||||
value: (event.target as HTMLInputElement | HTMLTextAreaElement).value,
|
||||
});
|
||||
}
|
||||
|
||||
protected marginStyle = computed(() => {
|
||||
const m = this.orgTemplate().margins;
|
||||
return {
|
||||
'--letter-margin-top': `${m.topMm}mm`,
|
||||
'--letter-margin-right': `${m.rightMm}mm`,
|
||||
'--letter-margin-bottom': `${m.bottomMm}mm`,
|
||||
'--letter-margin-left': `${m.leftMm}mm`,
|
||||
};
|
||||
});
|
||||
|
||||
// --- read-only rendering helpers (migrated from the superseded letter-preview) ---
|
||||
|
||||
private defs = computed(() => new Map(this.brief().placeholders.map((p) => [p.key, p])));
|
||||
private worst = computed(() => {
|
||||
const m = new Map<string, 'error' | 'warning'>();
|
||||
for (const d of this.diagnostics()) {
|
||||
if (!d.placeholderKey) continue;
|
||||
if (d.severity === 'error') m.set(d.placeholderKey, 'error');
|
||||
else if (!m.has(d.placeholderKey)) m.set(d.placeholderKey, 'warning');
|
||||
}
|
||||
return m;
|
||||
});
|
||||
|
||||
protected segmentsOf = (block: LetterBlock) => groupParagraphs(block.content.paragraphs);
|
||||
protected labelFor = (key: string) => this.defs().get(key)?.label ?? key;
|
||||
protected autoFor = (key: string) => this.defs().get(key)?.autoResolvable ?? false;
|
||||
protected stateFor = (key: string): 'ok' | 'warning' | 'error' => this.worst().get(key) ?? 'ok';
|
||||
protected sampleFor = (key: string) =>
|
||||
SAMPLE_VALUES[key] ?? (key === 'datum' ? this.letterDate : this.labelFor(key));
|
||||
|
||||
// --- approximate page-break marks (PRD §2b: honest "±", print preview is leading) ---
|
||||
|
||||
private page = viewChild<ElementRef<HTMLElement>>('page');
|
||||
protected pageBreaks = signal<readonly number[]>([]);
|
||||
|
||||
constructor() {
|
||||
// ponytail: whole-surface height / A4-interval — ignores that a break never truly
|
||||
// falls mid-line; the caption says "±" and WP-25's server preview is authoritative.
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
// ~1cm tolerance so a letter ending on a page boundary gets no edge-hugging mark.
|
||||
const pages = Math.ceil((entry.target.scrollHeight - 40) / A4_HEIGHT_PX);
|
||||
this.pageBreaks.set(
|
||||
Array.from({ length: Math.max(0, pages - 1) }, (_, i) => (i + 1) * A4_HEIGHT_PX),
|
||||
);
|
||||
});
|
||||
effect((onCleanup) => {
|
||||
const el = this.page()?.nativeElement;
|
||||
if (!el) return;
|
||||
observer.observe(el);
|
||||
onCleanup(() => observer.unobserve(el));
|
||||
});
|
||||
inject(DestroyRef).onDestroy(() => observer.disconnect());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { Brief, allDiagnostics } from '@brief/domain/brief';
|
||||
import { OrgTemplate } from '@brief/domain/org-template';
|
||||
import { LetterCanvasComponent } from './letter-canvas.component';
|
||||
|
||||
const orgTemplate: OrgTemplate = {
|
||||
subOrgId: 'cibg-registers',
|
||||
orgName: 'CIBG — Registers',
|
||||
returnAddress: 'BIG-register\nPostbus 00000\n2500 AA Den Haag',
|
||||
footerContact: 'www.bigregister.nl\ninfo@voorbeeld.example\n070 000 00 00',
|
||||
footerLegal: 'Ons kenmerk vermelden bij correspondentie.',
|
||||
signatureName: 'A. de Vries',
|
||||
signatureRole: 'Hoofd Registratie',
|
||||
signatureClosing: 'Met vriendelijke groet,',
|
||||
margins: { topMm: 25, rightMm: 25, bottomMm: 25, leftMm: 25 },
|
||||
version: 1,
|
||||
};
|
||||
|
||||
const brief: Brief = {
|
||||
briefId: 'b1',
|
||||
beroep: 'arts',
|
||||
templateId: 't1',
|
||||
drafterId: 'demo-drafter',
|
||||
status: { tag: 'draft' },
|
||||
placeholders: [
|
||||
{ key: 'naam_zorgverlener', label: 'Naam zorgverlener', autoResolvable: true },
|
||||
{ key: 'reden_besluit', label: 'Reden besluit', autoResolvable: false },
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
sectionKey: 'aanhef',
|
||||
title: 'Aanhef',
|
||||
required: true,
|
||||
locked: true,
|
||||
blocks: [
|
||||
{
|
||||
type: 'passage',
|
||||
blockId: 'local-1',
|
||||
sourcePassageId: 'p1',
|
||||
sourceVersion: 1,
|
||||
edited: false,
|
||||
content: {
|
||||
paragraphs: [
|
||||
{
|
||||
nodes: [
|
||||
{ type: 'text', text: 'Geachte heer/mevrouw ' },
|
||||
{ type: 'placeholder', key: 'naam_zorgverlener' },
|
||||
{ type: 'text', text: ',' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
sectionKey: 'kern',
|
||||
title: 'Kern van het besluit',
|
||||
required: true,
|
||||
locked: false,
|
||||
blocks: [
|
||||
{
|
||||
type: 'freeText',
|
||||
blockId: 'local-2',
|
||||
content: {
|
||||
paragraphs: [
|
||||
{
|
||||
nodes: [
|
||||
{ type: 'text', text: 'Wij hebben besloten om reden ' },
|
||||
{ type: 'placeholder', key: 'reden_besluit' },
|
||||
{ type: 'text', text: '.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/** Enough repeated body text to push the surface past one A4 page. */
|
||||
const longBrief: Brief = {
|
||||
...brief,
|
||||
sections: brief.sections.map((s) =>
|
||||
s.sectionKey === 'kern'
|
||||
? {
|
||||
...s,
|
||||
blocks: [
|
||||
{
|
||||
type: 'freeText',
|
||||
blockId: 'local-long',
|
||||
content: {
|
||||
paragraphs: Array.from({ length: 40 }, (_, i) => ({
|
||||
nodes: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Alinea ${i + 1}: de beoordeling van uw aanvraag is uitgevoerd volgens de geldende regels voor herregistratie in het BIG-register.`,
|
||||
},
|
||||
],
|
||||
})),
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
: s,
|
||||
),
|
||||
};
|
||||
|
||||
const meta: Meta<LetterCanvasComponent> = {
|
||||
title: 'Domein/Brief/Letter Canvas',
|
||||
component: LetterCanvasComponent,
|
||||
args: {
|
||||
brief,
|
||||
orgTemplate,
|
||||
diagnostics: allDiagnostics(brief),
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<LetterCanvasComponent>;
|
||||
|
||||
/** Read-only rendered letter: the drafter's preview modal and the approver view, with the
|
||||
sample-values toggle and diagnostic placeholder chips (absorbs the old Letter Preview). */
|
||||
export const ReadOnly: Story = { args: { editableRegions: 'none' } };
|
||||
|
||||
export const ReadOnlyZonderBevindingen: Story = {
|
||||
args: { editableRegions: 'none', diagnostics: [] },
|
||||
};
|
||||
|
||||
/** Admin editor focus (consumer arrives in WP-26): body read-only, no "not yours" tint. */
|
||||
export const TemplateMode: Story = { args: { editableRegions: 'template' } };
|
||||
|
||||
export const Zoomed: Story = { args: { editableRegions: 'none', zoom: 0.6 } };
|
||||
|
||||
/** Approver's "Toon wijzigingen": blocks changed/added since rejection are badged (WP-27). */
|
||||
export const WithDiff: Story = {
|
||||
args: {
|
||||
editableRegions: 'none',
|
||||
diagnostics: [],
|
||||
showDiff: true,
|
||||
blockDiffs: new Map([
|
||||
['local-1', 'added'],
|
||||
['local-2', 'changed'],
|
||||
]),
|
||||
},
|
||||
};
|
||||
|
||||
/** Long letter: the approximate ±page-break marks appear per A4 interval. */
|
||||
export const PageBreak: Story = {
|
||||
args: { editableRegions: 'none', brief: longBrief, diagnostics: [] },
|
||||
};
|
||||
|
||||
// Inline SVG so the story needs no backend/upload round-trip (WP-26 logo upload).
|
||||
const sampleLogo =
|
||||
'data:image/svg+xml;utf8,' +
|
||||
encodeURIComponent(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="120" height="40"><rect width="120" height="40" fill="#003366"/><text x="60" y="25" font-size="14" fill="white" text-anchor="middle">CIBG</text></svg>',
|
||||
);
|
||||
|
||||
/** Published org logo (WP-26 AC2): the letterhead shows it above the org name. */
|
||||
export const MetLogo: Story = {
|
||||
args: { editableRegions: 'none', diagnostics: [], logoUrl: sampleLogo },
|
||||
};
|
||||
@@ -0,0 +1,210 @@
|
||||
import { Component, computed, input, output, signal } from '@angular/core';
|
||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||
import { StatusBadgeComponent } from '@shared/ui/status-badge/status-badge.component';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||
import { Brief } from '@brief/domain/brief';
|
||||
import { OrgTemplate } from '@brief/domain/org-template';
|
||||
import { Diagnostic } from '@brief/domain/placeholders';
|
||||
import { BlockDiffKind } from '@brief/domain/brief-diff';
|
||||
import { LetterCanvasComponent } from '@brief/ui/letter-canvas/letter-canvas.component';
|
||||
import { DiagnosticsPanelComponent } from '@brief/ui/diagnostics-panel/diagnostics-panel.component';
|
||||
import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejection-comments.component';
|
||||
|
||||
/** Organism: the whole letter flow — status badge, the letter canvas (editable in
|
||||
place for the drafter, read-only for approver/locked), the diagnostics panel, and
|
||||
the action bar appropriate to status × permission. Presentational: emits edit +
|
||||
transition intents; the canEdit/canApprove/canReject/canSend inputs are
|
||||
server-computed decision flags (PRD-0002 phase P1) — this component never derives them. */
|
||||
@Component({
|
||||
selector: 'app-letter-composer',
|
||||
imports: [
|
||||
HeadingComponent,
|
||||
StatusBadgeComponent,
|
||||
ButtonComponent,
|
||||
AlertComponent,
|
||||
LetterCanvasComponent,
|
||||
DiagnosticsPanelComponent,
|
||||
RejectionCommentsComponent,
|
||||
],
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--rhc-space-max-md);
|
||||
flex-wrap: wrap;
|
||||
margin-block-end: var(--rhc-space-max-lg);
|
||||
}
|
||||
.head-end {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--rhc-space-max-md);
|
||||
}
|
||||
.panel {
|
||||
margin-block: var(--rhc-space-max-xl);
|
||||
}
|
||||
.bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--rhc-space-max-md);
|
||||
align-items: center;
|
||||
margin-block-start: var(--rhc-space-max-xl);
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<div class="head">
|
||||
<app-heading [level]="2">{{ title() }}</app-heading>
|
||||
<div class="head-end">
|
||||
@if (hasRejectionDiff()) {
|
||||
<app-button
|
||||
variant="subtle"
|
||||
[attr.aria-pressed]="showDiff()"
|
||||
(click)="showDiff.set(!showDiff())"
|
||||
>{{ showDiff() ? hideDiffLabel() : showDiffLabel() }}</app-button
|
||||
>
|
||||
}
|
||||
<app-button variant="subtle" [disabled]="busy()" (click)="preview.emit()">{{
|
||||
previewLabel()
|
||||
}}</app-button>
|
||||
<app-status-badge [label]="statusLabel()" [color]="statusColor()" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (status() === 'rejected') {
|
||||
<app-rejection-comments mode="show" [comments]="rejectComments()" />
|
||||
}
|
||||
|
||||
@if (pureViewer()) {
|
||||
<app-alert type="info">{{ readonlyNotice() }}</app-alert>
|
||||
}
|
||||
|
||||
@if (showDiff() && removedCount() > 0) {
|
||||
<app-alert type="info">{{ removedText() }}</app-alert>
|
||||
}
|
||||
|
||||
<app-letter-canvas
|
||||
[brief]="brief()"
|
||||
[orgTemplate]="orgTemplate()"
|
||||
[logoUrl]="logoUrl()"
|
||||
[editableRegions]="'none'"
|
||||
[diagnostics]="diagnostics()"
|
||||
[blockDiffs]="blockDiffs()"
|
||||
[showDiff]="showDiff()"
|
||||
/>
|
||||
|
||||
<div class="panel">
|
||||
<app-diagnostics-panel [diagnostics]="diagnostics()" (locate)="locate.emit($event)" />
|
||||
</div>
|
||||
|
||||
<div class="bar">
|
||||
@switch (status()) {
|
||||
@case ('submitted') {
|
||||
@if (canApprove() || canReject()) {
|
||||
<app-button variant="primary" [disabled]="busy()" (click)="approve.emit()">{{
|
||||
approveLabel()
|
||||
}}</app-button>
|
||||
<app-rejection-comments mode="entry" [busy]="busy()" (reject)="reject.emit($event)" />
|
||||
} @else {
|
||||
<app-alert type="info">{{ awaitingText() }}</app-alert>
|
||||
}
|
||||
}
|
||||
@case ('approved') {
|
||||
@if (canSend()) {
|
||||
<app-button variant="primary" [disabled]="busy()" (click)="send.emit()">{{
|
||||
sendLabel()
|
||||
}}</app-button>
|
||||
}
|
||||
}
|
||||
@case ('sent') {
|
||||
<app-alert type="ok">{{ sentText() }}</app-alert>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class LetterComposerComponent {
|
||||
brief = input.required<Brief>();
|
||||
orgTemplate = input.required<OrgTemplate>();
|
||||
logoUrl = input<string | null>(null);
|
||||
diagnostics = input<readonly Diagnostic[]>([]);
|
||||
canApprove = input(false);
|
||||
canReject = input(false);
|
||||
canSend = input(false);
|
||||
busy = input(false);
|
||||
/** Rejection diff (WP-27): the changed/added/removed blocks and their count. The
|
||||
"Toon wijzigingen" toggle only appears when there's something to show. */
|
||||
blockDiffs = input<ReadonlyMap<string, BlockDiffKind>>(new Map());
|
||||
removedCount = input(0);
|
||||
protected hasRejectionDiff = computed(() => this.blockDiffs().size > 0);
|
||||
protected showDiff = signal(false);
|
||||
|
||||
approve = output<void>();
|
||||
reject = output<string>();
|
||||
send = output<void>();
|
||||
preview = output<void>();
|
||||
locate = output<Diagnostic>();
|
||||
|
||||
title = input($localize`:@@brief.title:Brief aan de zorgverlener`);
|
||||
previewLabel = input($localize`:@@brief.preview.open:Voorbeeld`);
|
||||
showDiffLabel = input($localize`:@@brief.diff.show:Toon wijzigingen`);
|
||||
hideDiffLabel = input($localize`:@@brief.diff.hide:Verberg wijzigingen`);
|
||||
removedText = computed(
|
||||
() =>
|
||||
$localize`:@@brief.diff.removed:${this.removedCount()}:count: blok(ken) verwijderd sinds afwijzing.`,
|
||||
);
|
||||
approveLabel = input($localize`:@@brief.approve:Goedkeuren`);
|
||||
sendLabel = input($localize`:@@brief.send:Versturen`);
|
||||
awaitingText = input(
|
||||
$localize`:@@brief.awaiting:De brief wacht op beoordeling door een collega.`,
|
||||
);
|
||||
sentText = input($localize`:@@brief.sent:De brief is verzonden.`);
|
||||
|
||||
protected status = computed(() => this.brief().status.tag);
|
||||
|
||||
/** A pure viewer has no action on this letter (not the behandelaar, not an approver with
|
||||
approve/reject/send) — e.g. an admin. Show a notice so the read-only letter isn't
|
||||
mistaken for a broken editor. */
|
||||
protected pureViewer = computed(() => !this.canApprove() && !this.canReject() && !this.canSend());
|
||||
readonlyNotice = input(
|
||||
$localize`:@@brief.readonlyNotice:Alleen-lezen weergave. De behandelaar stelt de brief op.`,
|
||||
);
|
||||
protected rejectComments = computed(() => {
|
||||
const s = this.brief().status;
|
||||
return s.tag === 'rejected' ? s.comments : '';
|
||||
});
|
||||
|
||||
protected statusLabel = computed(() => {
|
||||
switch (this.status()) {
|
||||
case 'draft':
|
||||
return $localize`:@@brief.status.draft:Concept`;
|
||||
case 'submitted':
|
||||
return $localize`:@@brief.status.submitted:Ter beoordeling`;
|
||||
case 'approved':
|
||||
return $localize`:@@brief.status.approved:Goedgekeurd`;
|
||||
case 'rejected':
|
||||
return $localize`:@@brief.status.rejected:Afgewezen`;
|
||||
case 'sent':
|
||||
return $localize`:@@brief.status.sent:Verzonden`;
|
||||
}
|
||||
});
|
||||
|
||||
protected statusColor = computed(() => {
|
||||
switch (this.status()) {
|
||||
case 'draft':
|
||||
return 'var(--rhc-color-border-strong)';
|
||||
case 'submitted':
|
||||
return 'var(--rhc-color-oranje-500)';
|
||||
case 'approved':
|
||||
case 'sent':
|
||||
return 'var(--rhc-color-groen-500)';
|
||||
case 'rejected':
|
||||
return 'var(--rhc-color-rood-500)';
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { LetterComposerComponent } from './letter-composer.component';
|
||||
import { Brief, BriefDecisions, BriefStatus, LibraryPassage } from '@brief/domain/brief';
|
||||
import { allDiagnostics } from '@brief/domain/brief';
|
||||
import { OrgTemplate } from '@brief/domain/org-template';
|
||||
import { BlockDiffKind } from '@brief/domain/brief-diff';
|
||||
|
||||
const orgTemplate: OrgTemplate = {
|
||||
subOrgId: 'cibg-registers',
|
||||
orgName: 'CIBG — Registers',
|
||||
returnAddress: 'BIG-register\nPostbus 00000\n2500 AA Den Haag',
|
||||
footerContact: 'www.bigregister.nl\ninfo@voorbeeld.example',
|
||||
footerLegal: 'Ons kenmerk vermelden bij correspondentie.',
|
||||
signatureName: 'A. de Vries',
|
||||
signatureRole: 'Hoofd Registratie',
|
||||
signatureClosing: 'Met vriendelijke groet,',
|
||||
margins: { topMm: 25, rightMm: 25, bottomMm: 25, leftMm: 25 },
|
||||
version: 1,
|
||||
};
|
||||
|
||||
const passages: LibraryPassage[] = [
|
||||
{
|
||||
passageId: 'p1',
|
||||
scope: 'global',
|
||||
sectionKey: 'aanhef',
|
||||
label: 'Standaard aanhef',
|
||||
version: 1,
|
||||
content: {
|
||||
paragraphs: [
|
||||
{
|
||||
nodes: [
|
||||
{ type: 'text', text: 'Geachte heer/mevrouw ' },
|
||||
{ type: 'placeholder', key: 'naam_zorgverlener' },
|
||||
{ type: 'text', text: ',' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
passageId: 'p2',
|
||||
scope: 'beroep',
|
||||
beroep: 'arts',
|
||||
sectionKey: 'kern',
|
||||
label: 'Toelichting arts',
|
||||
version: 1,
|
||||
content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Als arts ...' }] }] },
|
||||
},
|
||||
];
|
||||
|
||||
function brief(status: BriefStatus): Brief {
|
||||
return {
|
||||
briefId: 'b1',
|
||||
beroep: 'arts',
|
||||
templateId: 't1',
|
||||
drafterId: 'demo-drafter',
|
||||
status,
|
||||
placeholders: [
|
||||
{ key: 'naam_zorgverlener', label: 'Naam zorgverlener', autoResolvable: true },
|
||||
{ key: 'reden_besluit', label: 'Reden besluit', autoResolvable: false },
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
sectionKey: 'aanhef',
|
||||
title: 'Aanhef',
|
||||
required: true,
|
||||
locked: true,
|
||||
blocks: [
|
||||
{
|
||||
type: 'passage',
|
||||
blockId: 'local-1',
|
||||
sourcePassageId: 'p1',
|
||||
sourceVersion: 1,
|
||||
edited: false,
|
||||
content: passages[0].content,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
sectionKey: 'kern',
|
||||
title: 'Kern van het besluit',
|
||||
required: true,
|
||||
locked: false,
|
||||
blocks: [
|
||||
{
|
||||
type: 'freeText',
|
||||
blockId: 'local-2',
|
||||
content: {
|
||||
paragraphs: [
|
||||
{
|
||||
nodes: [
|
||||
{ type: 'text', text: 'Wij hebben besloten om reden ' },
|
||||
{ type: 'placeholder', key: 'reden_besluit' },
|
||||
{ type: 'text', text: '.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
sectionKey: 'slot',
|
||||
title: 'Slot',
|
||||
required: false,
|
||||
locked: true,
|
||||
blocks: [
|
||||
{
|
||||
type: 'freeText',
|
||||
blockId: 'local-3',
|
||||
content: {
|
||||
paragraphs: [{ nodes: [{ type: 'text', text: 'Met vriendelijke groet,' }] }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const render = (
|
||||
b: Brief,
|
||||
decisions: BriefDecisions,
|
||||
extra: {
|
||||
blockDiffs?: ReadonlyMap<string, BlockDiffKind>;
|
||||
removedCount?: number;
|
||||
logoUrl?: string | null;
|
||||
} = {},
|
||||
) => ({
|
||||
props: {
|
||||
brief: b,
|
||||
orgTemplate,
|
||||
diagnostics: allDiagnostics(b),
|
||||
...decisions,
|
||||
busy: false,
|
||||
blockDiffs: extra.blockDiffs ?? new Map<string, BlockDiffKind>(),
|
||||
removedCount: extra.removedCount ?? 0,
|
||||
logoUrl: extra.logoUrl ?? null,
|
||||
},
|
||||
template: `<app-letter-composer [brief]="brief" [orgTemplate]="orgTemplate" [logoUrl]="logoUrl"
|
||||
[diagnostics]="diagnostics" [canApprove]="canApprove" [canReject]="canReject"
|
||||
[canSend]="canSend" [busy]="busy" [blockDiffs]="blockDiffs"
|
||||
[removedCount]="removedCount"></app-letter-composer>`,
|
||||
});
|
||||
|
||||
const meta: Meta<LetterComposerComponent> = {
|
||||
title: 'Domein/Brief/Letter Composer',
|
||||
component: LetterComposerComponent,
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<LetterComposerComponent>;
|
||||
|
||||
export const SubmittedApprover: Story = {
|
||||
render: () =>
|
||||
render(brief({ tag: 'submitted', submittedBy: 'demo-drafter', submittedAt: '2026-07-01' }), {
|
||||
canEdit: false,
|
||||
canApprove: true,
|
||||
canReject: true,
|
||||
canSend: false,
|
||||
canRevealBigNummer: false,
|
||||
}),
|
||||
};
|
||||
export const ApprovedSender: Story = {
|
||||
render: () =>
|
||||
render(brief({ tag: 'approved', approvedBy: 'demo-approver', approvedAt: '2026-07-01' }), {
|
||||
canEdit: false,
|
||||
canApprove: false,
|
||||
canReject: false,
|
||||
canSend: true,
|
||||
canRevealBigNummer: false,
|
||||
}),
|
||||
};
|
||||
export const Sent: Story = {
|
||||
render: () =>
|
||||
render(brief({ tag: 'sent', sentAt: '2026-07-01' }), {
|
||||
canEdit: false,
|
||||
canApprove: false,
|
||||
canReject: false,
|
||||
canSend: false,
|
||||
canRevealBigNummer: false,
|
||||
}),
|
||||
};
|
||||
|
||||
/** Approver's "Toon wijzigingen" (WP-27): a resubmitted letter with blocks changed,
|
||||
added and removed since the last rejection. */
|
||||
export const RejectionDiff: Story = {
|
||||
render: () =>
|
||||
render(
|
||||
brief({
|
||||
tag: 'submitted',
|
||||
submittedBy: 'demo-drafter',
|
||||
submittedAt: '2026-07-02',
|
||||
}),
|
||||
{
|
||||
canEdit: false,
|
||||
canApprove: true,
|
||||
canReject: true,
|
||||
canSend: false,
|
||||
canRevealBigNummer: false,
|
||||
},
|
||||
{
|
||||
blockDiffs: new Map<string, BlockDiffKind>([
|
||||
['local-2', 'changed'],
|
||||
['local-3', 'added'],
|
||||
]),
|
||||
removedCount: 1,
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
/** A pure viewer (e.g. admin) has no approve/reject/send capability on this letter —
|
||||
the read-only notice, not a broken-looking editor. */
|
||||
export const AlleenLezen: Story = {
|
||||
render: () =>
|
||||
render(brief({ tag: 'submitted', submittedBy: 'demo-drafter', submittedAt: '2026-07-01' }), {
|
||||
canEdit: false,
|
||||
canApprove: false,
|
||||
canReject: false,
|
||||
canSend: false,
|
||||
canRevealBigNummer: false,
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Component, computed, input, output } from '@angular/core';
|
||||
import { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component';
|
||||
import { Brief } from '@brief/domain/brief';
|
||||
import { BriefMsg } from '@brief/domain/brief.machine';
|
||||
import { LetterSectionComponent } from '@brief/ui/letter-section/letter-section.component';
|
||||
|
||||
/** Organism: the lean authoring surface — just the editable letter sections and their
|
||||
add/edit controls, no letterhead/signature/footer/zoom. The full rendered letter
|
||||
(including the locked aanhef/slot) lives in the preview modal (see behandel-scherm),
|
||||
so the drafter stays focused on composing the body they actually own. */
|
||||
@Component({
|
||||
selector: 'app-letter-editor',
|
||||
imports: [LetterSectionComponent],
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: grid;
|
||||
gap: var(--rhc-space-max-xl);
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
@for (section of editableSections(); track section.sectionKey) {
|
||||
<app-letter-section
|
||||
[section]="section"
|
||||
[placeholders]="placeholders()"
|
||||
[editable]="true"
|
||||
(edit)="edit.emit($event)"
|
||||
/>
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class LetterEditorComponent {
|
||||
brief = input.required<Brief>();
|
||||
placeholders = input<readonly PlaceholderOption[]>([]);
|
||||
edit = output<BriefMsg>();
|
||||
|
||||
/** Only unlocked sections are authored here; locked aanhef/slot appear in the preview. */
|
||||
protected editableSections = computed(() => this.brief().sections.filter((s) => !s.locked));
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { Brief } from '@brief/domain/brief';
|
||||
import { LetterEditorComponent } from './letter-editor.component';
|
||||
|
||||
const brief: Brief = {
|
||||
briefId: 'b1',
|
||||
beroep: 'arts',
|
||||
templateId: 't1',
|
||||
drafterId: 'demo-drafter',
|
||||
status: { tag: 'draft' },
|
||||
placeholders: [{ key: 'reden_besluit', label: 'Reden besluit', autoResolvable: false }],
|
||||
sections: [
|
||||
{
|
||||
sectionKey: 'aanhef',
|
||||
title: 'Aanhef',
|
||||
required: true,
|
||||
locked: true,
|
||||
blocks: [
|
||||
{
|
||||
type: 'freeText',
|
||||
blockId: 'aanhef-1',
|
||||
content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Geachte heer/mevrouw,' }] }] },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
sectionKey: 'kern',
|
||||
title: 'Kern van het besluit',
|
||||
required: true,
|
||||
locked: false,
|
||||
blocks: [
|
||||
{
|
||||
type: 'freeText',
|
||||
blockId: 'kern-1',
|
||||
content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Wij hebben besloten...' }] }] },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const meta: Meta<LetterEditorComponent> = {
|
||||
title: 'Domein/Brief/Letter Editor',
|
||||
component: LetterEditorComponent,
|
||||
args: { brief, placeholders: [] },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<LetterEditorComponent>;
|
||||
|
||||
/** The lean authoring surface: only the editable sections (the kern); the locked
|
||||
aanhef/slot are hidden here and appear only in the preview. */
|
||||
export const Default: Story = {};
|
||||
|
||||
/** Empty kern: shows the empty-state hint plus the free-text action, no starter content. */
|
||||
export const EmptyKern: Story = {
|
||||
args: {
|
||||
brief: {
|
||||
...brief,
|
||||
sections: brief.sections.map((s) => (s.sectionKey === 'kern' ? { ...s, blocks: [] } : s)),
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Component, input, output } from '@angular/core';
|
||||
import { RichTextBlock } from '@shared/kernel/rich-text';
|
||||
import { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||
import { LetterSection } from '@brief/domain/brief';
|
||||
import { BriefMsg } from '@brief/domain/brief.machine';
|
||||
import { LetterBlockComponent } from '@brief/ui/letter-block/letter-block.component';
|
||||
|
||||
/** Organism: one template section — its ordered blocks plus (when editable) the
|
||||
add-free-text action. Standaardteksten enter the kern via the besluit panel, not a
|
||||
per-section picker, so this only offers free text. Maps child events to `BriefMsg`s;
|
||||
sections themselves can never be added/removed/reordered (no message exists for it). */
|
||||
@Component({
|
||||
selector: 'app-letter-section',
|
||||
imports: [ButtonComponent, HeadingComponent, LetterBlockComponent],
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.blocks {
|
||||
display: grid;
|
||||
gap: var(--rhc-space-max-lg);
|
||||
margin-block: var(--rhc-space-max-md);
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--rhc-space-max-sm);
|
||||
}
|
||||
.required {
|
||||
color: var(--rhc-color-foreground-subtle);
|
||||
}
|
||||
.empty {
|
||||
color: var(--rhc-color-foreground-subtle);
|
||||
font-style: italic;
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<app-heading [level]="3">
|
||||
{{ section().title }}
|
||||
@if (section().required) {
|
||||
<span class="required">· {{ requiredLabel() }}</span>
|
||||
}
|
||||
</app-heading>
|
||||
|
||||
<div class="blocks">
|
||||
@for (block of section().blocks; track block.blockId) {
|
||||
<app-letter-block
|
||||
[block]="block"
|
||||
[placeholders]="placeholders()"
|
||||
[editable]="editable()"
|
||||
(contentChanged)="onContent(block.blockId, $event)"
|
||||
(removed)="edit.emit({ tag: 'BlockRemoved', blockId: block.blockId })"
|
||||
(moved)="onMove(block.blockId, $event)"
|
||||
/>
|
||||
} @empty {
|
||||
<p class="empty">{{ emptyLabel() }}</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (editable()) {
|
||||
<div class="actions">
|
||||
<app-button
|
||||
variant="subtle"
|
||||
(click)="edit.emit({ tag: 'FreeTextBlockAdded', sectionKey: section().sectionKey })"
|
||||
>{{ addFreeLabel() }}</app-button
|
||||
>
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class LetterSectionComponent {
|
||||
section = input.required<LetterSection>();
|
||||
placeholders = input<readonly PlaceholderOption[]>([]);
|
||||
editable = input(false);
|
||||
edit = output<BriefMsg>();
|
||||
|
||||
requiredLabel = input($localize`:@@brief.section.required:verplicht`);
|
||||
emptyLabel = input($localize`:@@brief.section.empty:Nog geen tekst in deze sectie.`);
|
||||
addFreeLabel = input($localize`:@@brief.section.addFree:Vrije tekst toevoegen`);
|
||||
|
||||
protected onContent(blockId: string, content: RichTextBlock) {
|
||||
this.edit.emit({ tag: 'BlockContentEdited', blockId, content });
|
||||
}
|
||||
|
||||
protected onMove(blockId: string, direction: -1 | 1) {
|
||||
const i = this.section().blocks.findIndex((b) => b.blockId === blockId);
|
||||
this.edit.emit({ tag: 'BlockMovedWithinSection', blockId, toIndex: i + direction });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { LetterSection } from '@brief/domain/brief';
|
||||
import { LetterSectionComponent } from './letter-section.component';
|
||||
|
||||
const section: LetterSection = {
|
||||
sectionKey: 'kern',
|
||||
title: 'Kern van het besluit',
|
||||
required: true,
|
||||
locked: false,
|
||||
blocks: [
|
||||
{
|
||||
type: 'freeText',
|
||||
blockId: 'local-2',
|
||||
content: {
|
||||
paragraphs: [
|
||||
{
|
||||
nodes: [
|
||||
{ type: 'text', text: 'Wij hebben besloten om reden ' },
|
||||
{ type: 'placeholder', key: 'reden_besluit' },
|
||||
{ type: 'text', text: '.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const emptySection: LetterSection = { ...section, blocks: [] };
|
||||
|
||||
const placeholders = [{ key: 'reden_besluit', label: 'Reden besluit' }];
|
||||
|
||||
const meta: Meta<LetterSectionComponent> = {
|
||||
title: 'Domein/Brief/Letter Section',
|
||||
component: LetterSectionComponent,
|
||||
args: { section, placeholders, edit: () => {} },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<LetterSectionComponent>;
|
||||
|
||||
export const ReadOnly: Story = { args: { editable: false } };
|
||||
export const Editable: Story = { args: { editable: true } };
|
||||
/** Empty section: shows the empty-state hint plus the free-text action. */
|
||||
export const EditableEmpty: Story = { args: { section: emptySection, editable: true } };
|
||||
@@ -0,0 +1,353 @@
|
||||
import { Component, computed, input, output } from '@angular/core';
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||
import { FileInputComponent } from '@shared/ui/upload/file-input/file-input.component';
|
||||
import { SingleUploadComponent } from '@shared/ui/upload/single-upload/single-upload.component';
|
||||
import { UploadState } from '@shared/upload/upload.machine';
|
||||
import { Brief } from '@brief/domain/brief';
|
||||
import {
|
||||
MARGIN_MAX_MM,
|
||||
MARGIN_MIN_MM,
|
||||
Margins,
|
||||
OrgTemplate,
|
||||
OrgTemplateVersion,
|
||||
SubOrgSummary,
|
||||
} from '@brief/domain/org-template';
|
||||
import { OrgTemplateTextField } from '@brief/domain/org-template.machine';
|
||||
import { LetterCanvasComponent } from '@brief/ui/letter-canvas/letter-canvas.component';
|
||||
|
||||
const LOGO_CATEGORY = 'org-logo';
|
||||
const EDGES: readonly (keyof Margins)[] = ['topMm', 'rightMm', 'bottomMm', 'leftMm'];
|
||||
|
||||
/** A minimal read-only sample letter, so the admin sees the org identity in context
|
||||
while editing (content itself is not the admin's to change). */
|
||||
export const SAMPLE_LETTER_BRIEF: Brief = {
|
||||
briefId: 'VOORBEELD-0001',
|
||||
beroep: 'arts',
|
||||
templateId: 'sample',
|
||||
drafterId: 'sample',
|
||||
status: { tag: 'draft' },
|
||||
placeholders: [
|
||||
{ key: 'naam_zorgverlener', label: 'Naam zorgverlener', autoResolvable: true },
|
||||
{ key: 'datum', label: 'Datum', autoResolvable: true },
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
sectionKey: 'body',
|
||||
title: 'Voorbeeldinhoud',
|
||||
required: true,
|
||||
locked: true,
|
||||
blocks: [
|
||||
{
|
||||
type: 'freeText',
|
||||
blockId: 'sample-1',
|
||||
content: {
|
||||
paragraphs: [
|
||||
{
|
||||
nodes: [
|
||||
{ type: 'text', text: 'Geachte ' },
|
||||
{ type: 'placeholder', key: 'naam_zorgverlener' },
|
||||
{ type: 'text', text: ',' },
|
||||
],
|
||||
},
|
||||
{
|
||||
nodes: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Dit is voorbeeldinhoud. Alleen de huisstijl-onderdelen (logo, afzender, ondertekening en voettekst) zijn hier bewerkbaar.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Organism (WP-26): the admin org-template editor. The mirror of the drafter's
|
||||
* composer — the letter canvas runs in `editableRegions='template'` so the
|
||||
* letterhead/signature/footer are edited in place, while the content is a read-only
|
||||
* sample. Margins, logo upload, version history and the publish bar sit around it.
|
||||
* Presentational: every mutation is an output the store turns into a command.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-org-template-editor',
|
||||
imports: [
|
||||
DatePipe,
|
||||
HeadingComponent,
|
||||
ButtonComponent,
|
||||
AlertComponent,
|
||||
FileInputComponent,
|
||||
SingleUploadComponent,
|
||||
LetterCanvasComponent,
|
||||
],
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: end;
|
||||
gap: var(--rhc-space-max-md);
|
||||
flex-wrap: wrap;
|
||||
margin-block-end: var(--rhc-space-max-lg);
|
||||
}
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--rhc-space-max-2xs);
|
||||
}
|
||||
.save {
|
||||
color: var(--rhc-color-foreground-subtle);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.section {
|
||||
margin-block-start: var(--rhc-space-max-xl);
|
||||
}
|
||||
.margins {
|
||||
display: flex;
|
||||
gap: var(--rhc-space-max-md);
|
||||
flex-wrap: wrap;
|
||||
border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);
|
||||
border-radius: var(--rhc-border-radius-md);
|
||||
padding: var(--rhc-space-max-md);
|
||||
}
|
||||
.margins input {
|
||||
width: 6rem;
|
||||
}
|
||||
.history-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--rhc-space-max-sm);
|
||||
}
|
||||
.history-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--rhc-space-max-md);
|
||||
border-block-end: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);
|
||||
padding-block-end: var(--rhc-space-max-sm);
|
||||
}
|
||||
.bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--rhc-space-max-md);
|
||||
align-items: center;
|
||||
margin-block-start: var(--rhc-space-max-xl);
|
||||
}
|
||||
.published {
|
||||
color: var(--rhc-color-foreground-subtle);
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<div class="toolbar">
|
||||
<label class="field">
|
||||
<span>{{ subOrgLabel() }}</span>
|
||||
<select class="form-select" (change)="onSelectSubOrg($event)">
|
||||
@for (o of subOrgs(); track o.subOrgId) {
|
||||
<option [value]="o.subOrgId" [selected]="o.subOrgId === selectedSubOrgId()">
|
||||
{{ o.orgName }}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<span class="save" role="status" aria-live="polite">{{ saveText() }}</span>
|
||||
</div>
|
||||
|
||||
<app-letter-canvas
|
||||
[brief]="sampleBrief()"
|
||||
[orgTemplate]="draft()"
|
||||
[logoUrl]="logoUrl()"
|
||||
editableRegions="template"
|
||||
(templateEdit)="templateEdit.emit($event)"
|
||||
/>
|
||||
|
||||
<fieldset class="section margins">
|
||||
<legend>{{ marginsLegend() }}</legend>
|
||||
@for (edge of edges; track edge) {
|
||||
<label class="field">
|
||||
<span>{{ edgeLabel(edge) }}</span>
|
||||
<input
|
||||
class="form-control"
|
||||
type="number"
|
||||
[min]="MIN"
|
||||
[max]="MAX"
|
||||
[value]="draft().margins[edge]"
|
||||
(input)="onMargin(edge, $event)"
|
||||
/>
|
||||
</label>
|
||||
}
|
||||
</fieldset>
|
||||
|
||||
<section class="section">
|
||||
<app-heading [level]="3">{{ logoHeading() }}</app-heading>
|
||||
@if (logoCategory()) {
|
||||
<app-file-input
|
||||
inputId="org-logo-input"
|
||||
[accept]="logoCategory()!.acceptedTypes"
|
||||
[maxSizeMb]="logoCategory()!.maxSizeMb"
|
||||
[label]="logoHeading()"
|
||||
(filesSelected)="logoSelected.emit($event)"
|
||||
/>
|
||||
}
|
||||
@if (logoRejection()) {
|
||||
<app-alert type="error">{{ logoRejection() }}</app-alert>
|
||||
}
|
||||
@if (logoUploads().length) {
|
||||
<ul class="file-list">
|
||||
@for (u of logoUploads(); track u.localId) {
|
||||
<li
|
||||
app-single-upload
|
||||
[upload]="u"
|
||||
[previewUrlFor]="previewUrlFor()"
|
||||
(remove)="logoRemoved.emit(u.localId)"
|
||||
(retry)="logoRetry.emit(u.localId)"
|
||||
></li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<app-heading [level]="3">{{ historyHeading() }}</app-heading>
|
||||
@if (history().length === 0) {
|
||||
<p class="published">{{ noHistory() }}</p>
|
||||
} @else {
|
||||
<ul class="history-list">
|
||||
@for (v of history(); track v.version) {
|
||||
<li class="history-row">
|
||||
<span
|
||||
>{{ versionLabel() }} {{ v.version }} · {{ v.publishedAt | date: 'longDate' }}</span
|
||||
>
|
||||
<app-button variant="subtle" [disabled]="busy()" (click)="rollback.emit(v.version)">
|
||||
{{ rollbackLabel() }}
|
||||
</app-button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</section>
|
||||
|
||||
<div class="bar">
|
||||
<span class="published">{{ publishedLabel() }} {{ publishedVersion() }}</span>
|
||||
@if (pendingPublish()) {
|
||||
<app-alert type="warning">{{ impactText() }}</app-alert>
|
||||
<app-button variant="primary" [disabled]="busy()" (click)="confirmPublish.emit()">
|
||||
{{ confirmLabel() }}
|
||||
</app-button>
|
||||
<app-button variant="subtle" [disabled]="busy()" (click)="cancelPublish.emit()">
|
||||
{{ cancelLabel() }}
|
||||
</app-button>
|
||||
} @else {
|
||||
<app-button
|
||||
variant="primary"
|
||||
[disabled]="!draftValid() || busy()"
|
||||
(click)="requestPublish.emit()"
|
||||
>
|
||||
{{ publishLabel() }}
|
||||
</app-button>
|
||||
@if (!draftValid()) {
|
||||
<span class="published">{{ invalidHint() }}</span>
|
||||
}
|
||||
}
|
||||
<app-button variant="secondary" [disabled]="busy()" (click)="proefbrief.emit()">
|
||||
{{ proefbriefLabel() }}
|
||||
</app-button>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class OrgTemplateEditorComponent {
|
||||
draft = input.required<OrgTemplate>();
|
||||
logoUrl = input<string | null>(null);
|
||||
uploadState = input.required<UploadState>();
|
||||
subOrgs = input<readonly SubOrgSummary[]>([]);
|
||||
selectedSubOrgId = input<string | null>(null);
|
||||
history = input<readonly OrgTemplateVersion[]>([]);
|
||||
publishedVersion = input(0);
|
||||
unsentBriefs = input(0);
|
||||
draftValid = input(false);
|
||||
busy = input(false);
|
||||
pendingPublish = input(false);
|
||||
saveText = input('');
|
||||
sampleBrief = input<Brief>(SAMPLE_LETTER_BRIEF);
|
||||
previewUrlFor = input<(documentId: string) => string | undefined>();
|
||||
|
||||
selectSubOrg = output<string>();
|
||||
templateEdit = output<{ field: OrgTemplateTextField; value: string }>();
|
||||
marginEdit = output<{ edge: keyof Margins; value: number }>();
|
||||
logoSelected = output<File[]>();
|
||||
logoRemoved = output<string>();
|
||||
logoRetry = output<string>();
|
||||
requestPublish = output<void>();
|
||||
confirmPublish = output<void>();
|
||||
cancelPublish = output<void>();
|
||||
rollback = output<number>();
|
||||
proefbrief = output<void>();
|
||||
|
||||
protected readonly edges = EDGES;
|
||||
protected readonly MIN = MARGIN_MIN_MM;
|
||||
protected readonly MAX = MARGIN_MAX_MM;
|
||||
|
||||
protected logoCategory = computed(() =>
|
||||
this.uploadState().categories.find((c) => c.categoryId === LOGO_CATEGORY),
|
||||
);
|
||||
protected logoUploads = computed(() =>
|
||||
this.uploadState().uploads.filter((u) => u.categoryId === LOGO_CATEGORY),
|
||||
);
|
||||
protected logoRejection = computed(() => this.uploadState().rejections[LOGO_CATEGORY]);
|
||||
|
||||
protected onSelectSubOrg(event: Event) {
|
||||
this.selectSubOrg.emit((event.target as HTMLSelectElement).value);
|
||||
}
|
||||
protected onMargin(edge: keyof Margins, event: Event) {
|
||||
const value = (event.target as HTMLInputElement).valueAsNumber;
|
||||
if (Number.isFinite(value)) this.marginEdit.emit({ edge, value });
|
||||
}
|
||||
|
||||
protected edgeLabel(edge: keyof Margins): string {
|
||||
switch (edge) {
|
||||
case 'topMm':
|
||||
return $localize`:@@orgTemplate.margin.top:Boven (mm)`;
|
||||
case 'rightMm':
|
||||
return $localize`:@@orgTemplate.margin.right:Rechts (mm)`;
|
||||
case 'bottomMm':
|
||||
return $localize`:@@orgTemplate.margin.bottom:Onder (mm)`;
|
||||
case 'leftMm':
|
||||
return $localize`:@@orgTemplate.margin.left:Links (mm)`;
|
||||
}
|
||||
}
|
||||
|
||||
protected impactText = computed(
|
||||
() =>
|
||||
$localize`:@@orgTemplate.publish.impact:Dit raakt ${this.unsentBriefs()}:count: nog niet verzonden brieven. Publiceren?`,
|
||||
);
|
||||
|
||||
protected subOrgLabel = input($localize`:@@orgTemplate.subOrg:Organisatieonderdeel`);
|
||||
protected marginsLegend = input(
|
||||
$localize`:@@orgTemplate.margins:Marges (mm, tussen ${MARGIN_MIN_MM}:min: en ${MARGIN_MAX_MM}:max:)`,
|
||||
);
|
||||
protected logoHeading = input($localize`:@@orgTemplate.logo:Logo`);
|
||||
protected historyHeading = input($localize`:@@orgTemplate.history:Versiegeschiedenis`);
|
||||
protected noHistory = input($localize`:@@orgTemplate.history.none:Nog niets gepubliceerd.`);
|
||||
protected versionLabel = input($localize`:@@orgTemplate.version:Versie`);
|
||||
protected rollbackLabel = input($localize`:@@orgTemplate.rollback:Terugzetten in concept`);
|
||||
protected publishedLabel = input($localize`:@@orgTemplate.published:Gepubliceerde versie:`);
|
||||
protected publishLabel = input($localize`:@@orgTemplate.publish:Publiceren`);
|
||||
protected confirmLabel = input($localize`:@@orgTemplate.publish.confirm:Bevestigen`);
|
||||
protected cancelLabel = input($localize`:@@orgTemplate.publish.cancel:Annuleren`);
|
||||
protected proefbriefLabel = input($localize`:@@orgTemplate.proefbrief:Proefbrief`);
|
||||
protected invalidHint = input(
|
||||
$localize`:@@orgTemplate.invalid:Vul organisatienaam en ondertekenaar in; marges tussen ${MARGIN_MIN_MM}:min: en ${MARGIN_MAX_MM}:max: mm.`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { OrgTemplateEditorComponent } from './org-template-editor.component';
|
||||
import { OrgTemplate, OrgTemplateVersion, SubOrgSummary } from '@brief/domain/org-template';
|
||||
import { UploadState, initialUpload } from '@shared/upload/upload.machine';
|
||||
|
||||
const draft: OrgTemplate = {
|
||||
subOrgId: 'cibg-registers',
|
||||
orgName: 'CIBG — Registers',
|
||||
returnAddress: 'BIG-register\nPostbus 00000\n2500 AA Den Haag',
|
||||
footerContact: 'www.bigregister.nl\ninfo@voorbeeld.example',
|
||||
footerLegal: 'Ons kenmerk vermelden bij correspondentie.',
|
||||
signatureName: 'A. de Vries',
|
||||
signatureRole: 'Hoofd Registratie',
|
||||
signatureClosing: 'Met vriendelijke groet,',
|
||||
margins: { topMm: 25, rightMm: 25, bottomMm: 25, leftMm: 25 },
|
||||
version: 3,
|
||||
};
|
||||
|
||||
const subOrgs: SubOrgSummary[] = [
|
||||
{ subOrgId: 'cibg-registers', orgName: 'CIBG — Registers', publishedVersion: 3 },
|
||||
{ subOrgId: 'cibg-vakbekwaamheid', orgName: 'CIBG — Vakbekwaamheid', publishedVersion: 1 },
|
||||
];
|
||||
|
||||
const history: OrgTemplateVersion[] = [
|
||||
{ version: 3, publishedAt: '2026-06-20', template: draft },
|
||||
{ version: 2, publishedAt: '2026-05-11', template: draft },
|
||||
];
|
||||
|
||||
const uploadWithCategory: UploadState = {
|
||||
...initialUpload,
|
||||
categories: [
|
||||
{
|
||||
categoryId: 'org-logo',
|
||||
label: 'Logo',
|
||||
description: 'Logo van de organisatie',
|
||||
required: false,
|
||||
acceptedTypes: ['image/png', 'image/jpeg'],
|
||||
maxSizeMb: 2,
|
||||
multiple: false,
|
||||
allowPostDelivery: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const meta: Meta<OrgTemplateEditorComponent> = {
|
||||
title: 'Domein/Brief/Org Template Editor',
|
||||
component: OrgTemplateEditorComponent,
|
||||
args: {
|
||||
draft,
|
||||
logoUrl: null,
|
||||
uploadState: uploadWithCategory,
|
||||
subOrgs,
|
||||
selectedSubOrgId: 'cibg-registers',
|
||||
history,
|
||||
publishedVersion: 3,
|
||||
unsentBriefs: 4,
|
||||
draftValid: true,
|
||||
busy: false,
|
||||
pendingPublish: false,
|
||||
saveText: '',
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<OrgTemplateEditorComponent>;
|
||||
|
||||
export const Editing: Story = {};
|
||||
|
||||
export const PublishConfirm: Story = {
|
||||
args: { pendingPublish: true },
|
||||
};
|
||||
|
||||
export const Invalid: Story = {
|
||||
args: {
|
||||
draft: { ...draft, orgName: '', margins: { ...draft.margins, topMm: 5 } },
|
||||
draftValid: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const NoHistory: Story = {
|
||||
args: { history: [], publishedVersion: 0 },
|
||||
};
|
||||
|
||||
// Inline SVG so the story needs no backend/upload round-trip.
|
||||
const sampleLogo =
|
||||
'data:image/svg+xml;utf8,' +
|
||||
encodeURIComponent(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="120" height="40"><rect width="120" height="40" fill="#003366"/><text x="60" y="25" font-size="14" fill="white" text-anchor="middle">CIBG</text></svg>',
|
||||
);
|
||||
|
||||
/** Published logo (WP-26 AC2): the letterhead canvas shows it above the org name. */
|
||||
export const MetLogo: Story = {
|
||||
args: { logoUrl: sampleLogo },
|
||||
};
|
||||
|
||||
/** Client-side upload rejection (existing `rejectReason`, WP-26 AC5) — type/size caught
|
||||
before the file ever reaches the backend. */
|
||||
export const LogoUploadFout: Story = {
|
||||
args: {
|
||||
uploadState: {
|
||||
...uploadWithCategory,
|
||||
rejections: { 'org-logo': 'Alleen PNG of JPEG, maximaal 2 MB.' },
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,132 @@
|
||||
import { Component, computed, effect, inject } from '@angular/core';
|
||||
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
import { ASYNC } from '@shared/ui/async/async.component';
|
||||
import { AccessStore } from '@shared/application/access.store';
|
||||
import { UploadAdapter } from '@shared/upload/upload.adapter';
|
||||
import { OrgTemplateStore } from '@brief/application/org-template.store';
|
||||
import { OrgTemplateEditorComponent } from '@brief/ui/org-template-editor/org-template-editor.component';
|
||||
|
||||
/** Page: thin container for the admin org-template editor (WP-26). Deny-by-default
|
||||
capability gate (`orgtemplate:edit`) — a denial alert for non-admins, the editor
|
||||
for admins. Loads once the capability resolves; wires store commands to the organism. */
|
||||
@Component({
|
||||
selector: 'app-org-template-page',
|
||||
imports: [
|
||||
PageShellComponent,
|
||||
AlertComponent,
|
||||
ButtonComponent,
|
||||
...ASYNC,
|
||||
OrgTemplateEditorComponent,
|
||||
],
|
||||
styles: [
|
||||
`
|
||||
.save {
|
||||
color: var(--rhc-color-foreground-subtle);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<app-page-shell [heading]="heading" [intro]="intro" backLink="/brief">
|
||||
@if (store.lastError(); as err) {
|
||||
<app-alert type="error">{{ err }}</app-alert>
|
||||
}
|
||||
|
||||
@if (!access.ready()) {
|
||||
<!-- wait for /me before deciding — avoids flashing the denial to an admin -->
|
||||
} @else if (!canEdit()) {
|
||||
<app-alert type="error">{{ deniedText }}</app-alert>
|
||||
} @else {
|
||||
<app-async [data]="store.remoteData()">
|
||||
<ng-template appAsyncError>
|
||||
<app-alert type="error">{{ failedText }}</app-alert>
|
||||
<app-button variant="secondary" (click)="reload()">{{ retryText }}</app-button>
|
||||
</ng-template>
|
||||
<ng-template appAsyncLoaded>
|
||||
@if (store.draft(); as draft) {
|
||||
<app-org-template-editor
|
||||
[draft]="draft"
|
||||
[logoUrl]="store.logoUrl()"
|
||||
[uploadState]="store.uploadState()"
|
||||
[subOrgs]="store.subOrgs()"
|
||||
[selectedSubOrgId]="store.selectedSubOrgId()"
|
||||
[history]="store.history()"
|
||||
[publishedVersion]="store.publishedVersion()"
|
||||
[unsentBriefs]="store.unsentBriefs()"
|
||||
[draftValid]="store.draftValid()"
|
||||
[busy]="store.busy()"
|
||||
[pendingPublish]="store.pendingPublish()"
|
||||
[saveText]="saveText()"
|
||||
[previewUrlFor]="previewUrlFor"
|
||||
(selectSubOrg)="store.selectSubOrg($event)"
|
||||
(templateEdit)="
|
||||
store.edit({ tag: 'FieldEdited', field: $event.field, value: $event.value })
|
||||
"
|
||||
(marginEdit)="
|
||||
store.edit({ tag: 'MarginEdited', edge: $event.edge, value: $event.value })
|
||||
"
|
||||
(logoSelected)="store.onLogoSelected($event)"
|
||||
(logoRemoved)="store.onLogoRemoved($event)"
|
||||
(logoRetry)="store.onLogoRetry($event)"
|
||||
(requestPublish)="store.requestPublish()"
|
||||
(confirmPublish)="store.confirmPublish()"
|
||||
(cancelPublish)="store.cancelPublish()"
|
||||
(rollback)="store.rollback($event)"
|
||||
(proefbrief)="store.proefbrief()"
|
||||
/>
|
||||
}
|
||||
</ng-template>
|
||||
</app-async>
|
||||
}
|
||||
</app-page-shell>
|
||||
`,
|
||||
})
|
||||
export class OrgTemplatePage {
|
||||
protected store = inject(OrgTemplateStore);
|
||||
protected access = inject(AccessStore);
|
||||
private uploadAdapter = inject(UploadAdapter);
|
||||
|
||||
protected canEdit = computed(() => this.access.can('orgtemplate:edit'));
|
||||
protected previewUrlFor = (documentId: string) => this.uploadAdapter.contentUrl(documentId);
|
||||
|
||||
protected heading = $localize`:@@orgTemplate.page.heading:Huisstijl beheren`;
|
||||
protected intro = $localize`:@@orgTemplate.page.intro:Beheer per organisatieonderdeel het uiterlijk van de brief: logo, afzender, ondertekening, voettekst en marges.`;
|
||||
protected deniedText = $localize`:@@orgTemplate.page.denied:U hebt geen rechten om organisatiesjablonen te beheren.`;
|
||||
protected failedText = $localize`:@@orgTemplate.page.failed:Het sjabloon kon niet worden geladen.`;
|
||||
protected retryText = $localize`:@@orgTemplate.page.retry:Opnieuw proberen`;
|
||||
|
||||
private savingText = $localize`:@@orgTemplate.page.saving:Concept opslaan…`;
|
||||
private savedText = $localize`:@@orgTemplate.page.saved:Concept opgeslagen`;
|
||||
private saveErrorText = $localize`:@@orgTemplate.page.saveError:Opslaan mislukt`;
|
||||
protected saveText = computed(() => {
|
||||
switch (this.store.saveState().tag) {
|
||||
case 'Saving':
|
||||
return this.savingText;
|
||||
case 'Saved':
|
||||
return this.savedText;
|
||||
case 'Error':
|
||||
return this.saveErrorText;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
});
|
||||
|
||||
private loadRequested = false;
|
||||
constructor() {
|
||||
// Load once the capability resolves to `allowed` (a 403 GET would be wasted
|
||||
// otherwise). Depends only on `canEdit()` + a plain flag — never on the store
|
||||
// model, so dispatching `Loading` inside `load()` can't retrigger this effect.
|
||||
effect(() => {
|
||||
if (this.canEdit() && !this.loadRequested) {
|
||||
this.loadRequested = true;
|
||||
void this.store.load();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected reload() {
|
||||
void this.store.load();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { Component, computed, input, output, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { CheckboxComponent } from '@shared/ui/checkbox/checkbox.component';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
|
||||
import { textOf } from '@shared/kernel/rich-text';
|
||||
import { LibraryPassage } from '@brief/domain/brief';
|
||||
|
||||
/** Molecule: multi-select list of the section's library passages. One "Voeg toe"
|
||||
inserts ALL checked passages at once (a single message upstream) — there is no
|
||||
single-insert path. Presentational: emits the chosen passages in list order.
|
||||
|
||||
Superseded by `besluit-panel` (WP-27's guided drafting): no consumer left in
|
||||
`src/app` outside its own story (WP-28 audit). Kept for now rather than deleted
|
||||
in-flight of an unrelated WP; a future cleanup can remove it. */
|
||||
@Component({
|
||||
selector: 'app-passage-picker',
|
||||
imports: [FormsModule, CheckboxComponent, ButtonComponent, TextInputComponent],
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
background: var(--rhc-color-wit);
|
||||
border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);
|
||||
border-radius: var(--rhc-border-radius-md);
|
||||
padding: var(--rhc-space-max-md);
|
||||
}
|
||||
.search {
|
||||
margin-block-end: var(--rhc-space-max-md);
|
||||
}
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 0 0 var(--rhc-space-max-md);
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: var(--rhc-space-max-sm);
|
||||
}
|
||||
.scope {
|
||||
color: var(--rhc-color-foreground-subtle);
|
||||
}
|
||||
.empty {
|
||||
color: var(--rhc-color-foreground-subtle);
|
||||
font-style: italic;
|
||||
margin: 0 0 var(--rhc-space-max-md);
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<div class="search">
|
||||
<app-text-input
|
||||
[placeholder]="searchLabel()"
|
||||
[attr.aria-label]="searchLabel()"
|
||||
[ngModel]="query()"
|
||||
(ngModelChange)="query.set($event)"
|
||||
/>
|
||||
</div>
|
||||
<ul>
|
||||
@for (p of filtered(); track p.passageId) {
|
||||
<li>
|
||||
<app-checkbox
|
||||
[checkboxId]="'passage-' + p.passageId"
|
||||
[label]="p.label"
|
||||
[ngModel]="!!checked()[p.passageId]"
|
||||
(ngModelChange)="set(p.passageId, $event)"
|
||||
/>
|
||||
<span class="scope"> · {{ p.scope === 'beroep' ? beroepLabel() : globalLabel() }}</span>
|
||||
</li>
|
||||
} @empty {
|
||||
<li class="empty">{{ noMatchLabel() }}</li>
|
||||
}
|
||||
</ul>
|
||||
<app-button variant="secondary" [disabled]="count() === 0" (click)="add()"
|
||||
>{{ addLabel() }} ({{ count() }})</app-button
|
||||
>
|
||||
`,
|
||||
})
|
||||
export class PassagePickerComponent {
|
||||
passages = input.required<readonly LibraryPassage[]>();
|
||||
insert = output<LibraryPassage[]>();
|
||||
|
||||
addLabel = input($localize`:@@brief.picker.add:Voeg toe`);
|
||||
globalLabel = input($localize`:@@brief.picker.global:algemeen`);
|
||||
beroepLabel = input($localize`:@@brief.picker.beroep:beroepsspecifiek`);
|
||||
searchLabel = input($localize`:@@brief.picker.search:Zoek in standaardteksten…`);
|
||||
noMatchLabel = input($localize`:@@brief.picker.noMatch:Geen standaardteksten gevonden.`);
|
||||
|
||||
protected checked = signal<Record<string, boolean>>({});
|
||||
protected query = signal('');
|
||||
/** Client-side filter on label + rendered content text — the library is small, so no
|
||||
server search (WP-27). Placeholder keys are searchable too (see `textOf`). */
|
||||
protected filtered = computed(() => {
|
||||
const q = this.query().trim().toLowerCase();
|
||||
if (!q) return this.passages();
|
||||
return this.passages().filter(
|
||||
(p) => p.label.toLowerCase().includes(q) || textOf(p.content).includes(q),
|
||||
);
|
||||
});
|
||||
protected count = () => Object.values(this.checked()).filter(Boolean).length;
|
||||
|
||||
protected set(id: string, on: boolean) {
|
||||
this.checked.update((c) => ({ ...c, [id]: on }));
|
||||
}
|
||||
|
||||
protected add() {
|
||||
const chosen = this.passages().filter((p) => this.checked()[p.passageId]);
|
||||
if (chosen.length) {
|
||||
this.insert.emit(chosen);
|
||||
this.checked.set({});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { LibraryPassage } from '@brief/domain/brief';
|
||||
import { PassagePickerComponent } from './passage-picker.component';
|
||||
|
||||
const passages: LibraryPassage[] = [
|
||||
{
|
||||
passageId: 'p1',
|
||||
scope: 'global',
|
||||
sectionKey: 'aanhef',
|
||||
label: 'Standaard aanhef',
|
||||
version: 1,
|
||||
content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Geachte heer/mevrouw,' }] }] },
|
||||
},
|
||||
{
|
||||
passageId: 'p2',
|
||||
scope: 'beroep',
|
||||
beroep: 'arts',
|
||||
sectionKey: 'aanhef',
|
||||
label: 'Aanhef, arts-specifiek',
|
||||
version: 1,
|
||||
content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Geachte collega,' }] }] },
|
||||
},
|
||||
];
|
||||
|
||||
const meta: Meta<PassagePickerComponent> = {
|
||||
title: 'Domein/Brief/Passage Picker',
|
||||
component: PassagePickerComponent,
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<app-passage-picker [passages]="passages" (insert)="insert($event)" />`,
|
||||
}),
|
||||
args: { passages, insert: () => {} },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<PassagePickerComponent>;
|
||||
|
||||
export const Default: Story = {};
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Component, input, output, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
|
||||
/** Molecule: shows the rejection comments (drafter view) or collects them from the
|
||||
approver. The approver rejects WITH comments; they never edit the letter. */
|
||||
@Component({
|
||||
selector: 'app-rejection-comments',
|
||||
imports: [FormsModule, AlertComponent, ButtonComponent],
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
textarea {
|
||||
inline-size: 100%;
|
||||
box-sizing: border-box;
|
||||
min-block-size: 4rem;
|
||||
margin-block: var(--rhc-space-max-sm);
|
||||
}
|
||||
label {
|
||||
font-weight: 600;
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
@if (mode() === 'show') {
|
||||
<app-alert type="warning"
|
||||
><strong>{{ rejectedTitle() }}</strong> {{ comments() }}</app-alert
|
||||
>
|
||||
} @else {
|
||||
<label for="reject-comments">{{ entryLabel() }}</label>
|
||||
<textarea id="reject-comments" [(ngModel)]="draft"></textarea>
|
||||
<app-button variant="danger" [disabled]="!draft().trim() || busy()" (click)="submit()">{{
|
||||
rejectLabel()
|
||||
}}</app-button>
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class RejectionCommentsComponent {
|
||||
mode = input<'show' | 'entry'>('show');
|
||||
comments = input('');
|
||||
busy = input(false);
|
||||
reject = output<string>();
|
||||
|
||||
rejectedTitle = input($localize`:@@brief.reject.title:Afgewezen:`);
|
||||
entryLabel = input($localize`:@@brief.reject.entryLabel:Reden van afwijzing`);
|
||||
rejectLabel = input($localize`:@@brief.reject.button:Afwijzen`);
|
||||
|
||||
protected draft = signal('');
|
||||
|
||||
protected submit() {
|
||||
const c = this.draft().trim();
|
||||
if (c) this.reject.emit(c);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { RejectionCommentsComponent } from './rejection-comments.component';
|
||||
|
||||
const meta: Meta<RejectionCommentsComponent> = {
|
||||
title: 'Domein/Brief/Rejection Comments',
|
||||
component: RejectionCommentsComponent,
|
||||
args: { reject: () => {} },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<RejectionCommentsComponent>;
|
||||
|
||||
export const Show: Story = {
|
||||
args: { mode: 'show', comments: 'Graag de aanhef formeler.' },
|
||||
};
|
||||
export const Entry: Story = { args: { mode: 'entry' } };
|
||||
export const EntryBusy: Story = { args: { mode: 'entry', busy: true } };
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Injectable, computed, inject } from '@angular/core';
|
||||
import { SCHOLING_THRESHOLD_DEFAULT } from '../domain/intake.machine';
|
||||
import { IntakePolicyAdapter } from '../infrastructure/intake-policy.adapter';
|
||||
|
||||
/**
|
||||
* Application-layer facade for the server-owned intake policy (the scholing
|
||||
* threshold config value). It owns the httpResource (created here, in the required
|
||||
* injection context) and exposes the threshold as a derived signal, falling back to
|
||||
* the domain default until the backend answers. The UI reaches the network through
|
||||
* application/, never infrastructure/ directly (CLAUDE.md §1). The backend stays the
|
||||
* authority and re-validates on submit.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class IntakePolicyStore {
|
||||
private policy = inject(IntakePolicyAdapter);
|
||||
private policyRes = this.policy.policyResource();
|
||||
|
||||
readonly scholingThreshold = computed(
|
||||
() => this.policyRes.value()?.scholingThreshold ?? SCHOLING_THRESHOLD_DEFAULT,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { hasProgress, initial, WizardState } from './herregistratie.machine';
|
||||
|
||||
const editing = initial as Extract<WizardState, { tag: 'Editing' }>;
|
||||
|
||||
describe('herregistratie hasProgress', () => {
|
||||
it('is false for a fresh form', () => {
|
||||
expect(hasProgress(editing)).toBe(false);
|
||||
});
|
||||
|
||||
it('is true once a field is filled or the user advances', () => {
|
||||
expect(hasProgress({ ...editing, draft: { uren: '40', jaren: '', punten: '' } })).toBe(true);
|
||||
expect(hasProgress({ ...editing, step: 2 })).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ok, err } from '@shared/kernel/fp';
|
||||
import { initialUpload } from '@shared/upload/upload.machine';
|
||||
import {
|
||||
initial,
|
||||
next,
|
||||
back,
|
||||
gaNaarStap,
|
||||
submit,
|
||||
resolve,
|
||||
reduce,
|
||||
WizardState,
|
||||
} from './herregistratie.machine';
|
||||
|
||||
const editing1 = (uren: string, jaren = '5', punten = ''): WizardState => ({
|
||||
tag: 'Editing',
|
||||
step: 1,
|
||||
draft: { uren, jaren, punten },
|
||||
errors: {},
|
||||
upload: initialUpload,
|
||||
});
|
||||
const editing2 = (uren: string, punten: string, jaren = '5'): WizardState => ({
|
||||
tag: 'Editing',
|
||||
step: 2,
|
||||
draft: { uren, jaren, punten },
|
||||
errors: {},
|
||||
upload: initialUpload,
|
||||
});
|
||||
const editing3 = (uren: string, punten: string, jaren = '5'): WizardState => ({
|
||||
tag: 'Editing',
|
||||
step: 3,
|
||||
draft: { uren, jaren, punten },
|
||||
errors: {},
|
||||
upload: initialUpload,
|
||||
});
|
||||
|
||||
describe('wizard.machine', () => {
|
||||
it('next advances only when step 1 parses', () => {
|
||||
expect(next(initial).tag).toBe('Editing'); // empty uren -> stays, with error
|
||||
expect((next(initial) as any).errors.uren).toBeTruthy();
|
||||
expect((next(editing1('4160')) as any).step).toBe(2);
|
||||
});
|
||||
|
||||
it('next advances step 2 → 3 only when punten parses', () => {
|
||||
expect((next(editing2('4160', 'x')) as any).step).toBe(2); // invalid punten -> stays
|
||||
expect((next(editing2('4160', 'x')) as any).errors.punten).toBeTruthy();
|
||||
expect((next(editing2('4160', '200')) as any).step).toBe(3);
|
||||
});
|
||||
|
||||
it('submit reaches Submitting ONLY from step 3 with fully valid data', () => {
|
||||
expect(submit(editing2('4160', '200')).tag).toBe('Editing'); // not on step 3 -> no Submitting
|
||||
expect(submit(editing3('4160', 'x')).tag).toBe('Editing'); // invalid punten
|
||||
const good = submit(editing3('4160', '200'));
|
||||
expect(good.tag).toBe('Submitting');
|
||||
expect((good as any).data).toEqual({ uren: 4160, jaren: 5, punten: 200, documents: [] });
|
||||
});
|
||||
|
||||
it('next requires BOTH step-1 fields (uren and jaren)', () => {
|
||||
expect((next(editing1('4160', '')) as any).errors.jaren).toBeTruthy(); // jaren empty -> stays
|
||||
expect((next(editing1('4160', '')) as any).step).toBe(1);
|
||||
expect((next(editing1('4160', '5')) as any).step).toBe(2); // both valid -> advance
|
||||
});
|
||||
|
||||
it('back steps down one (3 → 2 → 1) and is a no-op from step 1', () => {
|
||||
expect(back(initial)).toBe(initial); // step 1, nothing to go back to
|
||||
expect((back(editing3('1', '2')) as any).step).toBe(2);
|
||||
expect((back(editing2('1', '2')) as any).step).toBe(1);
|
||||
expect(resolve(initial, ok(undefined))).toBe(initial); // not Submitting
|
||||
});
|
||||
|
||||
it('resolve maps Submitting to Submitted / Failed', () => {
|
||||
const submitting = submit(editing3('4160', '200'));
|
||||
expect(resolve(submitting, ok(undefined)).tag).toBe('Submitted');
|
||||
expect(resolve(submitting, err('boom')).tag).toBe('Failed');
|
||||
});
|
||||
|
||||
it('gaNaarStap jumps back to an earlier step, clearing errors', () => {
|
||||
expect((gaNaarStap(editing3('4160', '200'), 1) as any).step).toBe(1);
|
||||
});
|
||||
|
||||
it('gaNaarStap ignores a same/forward jump and jumps outside Editing', () => {
|
||||
const e3 = editing3('4160', '200');
|
||||
expect(gaNaarStap(e3, 3)).toBe(e3); // same step -> no-op
|
||||
const submitting = submit(e3);
|
||||
expect(gaNaarStap(submitting, 1)).toBe(submitting); // not Editing -> no-op
|
||||
});
|
||||
});
|
||||
|
||||
describe('reduce (message-driven)', () => {
|
||||
it('drives the full happy path via messages', () => {
|
||||
let s: WizardState = initial;
|
||||
s = reduce(s, { tag: 'SetField', key: 'uren', value: '4160' });
|
||||
s = reduce(s, { tag: 'SetField', key: 'jaren', value: '5' });
|
||||
s = reduce(s, { tag: 'Next' });
|
||||
expect(s.tag === 'Editing' && s.step).toBe(2);
|
||||
s = reduce(s, { tag: 'SetField', key: 'punten', value: '200' });
|
||||
s = reduce(s, { tag: 'Next' });
|
||||
expect(s.tag === 'Editing' && s.step).toBe(3);
|
||||
s = reduce(s, { tag: 'Submit' });
|
||||
expect(s.tag).toBe('Submitting');
|
||||
s = reduce(s, { tag: 'SubmitConfirmed' });
|
||||
expect(s.tag).toBe('Submitted');
|
||||
});
|
||||
|
||||
it('blocks submit until required documents are satisfied', () => {
|
||||
const cat = {
|
||||
categoryId: 'bewijs',
|
||||
label: 'Bewijs',
|
||||
description: '',
|
||||
required: true,
|
||||
acceptedTypes: [],
|
||||
maxSizeMb: 10,
|
||||
multiple: false,
|
||||
allowPostDelivery: true,
|
||||
};
|
||||
let s = reduce(editing3('4160', '200'), {
|
||||
tag: 'Upload',
|
||||
msg: { type: 'CategoriesLoaded', categories: [cat] },
|
||||
});
|
||||
s = reduce(s, { tag: 'Submit' });
|
||||
expect(s.tag).toBe('Editing');
|
||||
expect((s as any).errors.documenten).toBeTruthy();
|
||||
s = reduce(s, {
|
||||
tag: 'Upload',
|
||||
msg: { type: 'DeliveryChannelChanged', categoryId: 'bewijs', channel: 'post' },
|
||||
});
|
||||
s = reduce(s, { tag: 'Submit' });
|
||||
expect(s.tag).toBe('Submitting');
|
||||
expect((s as any).data.documents).toEqual([{ categoryId: 'bewijs', channel: 'post' }]);
|
||||
});
|
||||
|
||||
it('SubmitFailed then Retry returns to Submitting with the same data', () => {
|
||||
let s = reduce(reduce(editing3('4160', '200'), { tag: 'Submit' }), {
|
||||
tag: 'SubmitFailed',
|
||||
error: 'boom',
|
||||
});
|
||||
expect(s.tag).toBe('Failed');
|
||||
s = reduce(s, { tag: 'Retry' });
|
||||
expect(s.tag).toBe('Submitting');
|
||||
expect((s as any).data).toEqual({ uren: 4160, jaren: 5, punten: 200, documents: [] });
|
||||
});
|
||||
|
||||
it('Seed mounts an arbitrary state', () => {
|
||||
expect(reduce(initial, { tag: 'Seed', state: editing2('1', '2') }).tag).toBe('Editing');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import { Result, assertNever } from '@shared/kernel/fp';
|
||||
import { Uren, parseUren } from '@registratie/domain/value-objects/uren';
|
||||
import {
|
||||
UploadState,
|
||||
UploadMsg,
|
||||
initialUpload,
|
||||
reduceUpload,
|
||||
requiredCategoriesSatisfied,
|
||||
deliveryRefs,
|
||||
} from '@shared/upload/upload.machine';
|
||||
|
||||
/** What the user is typing (raw, possibly invalid). */
|
||||
export interface Draft {
|
||||
uren: string;
|
||||
jaren: string;
|
||||
punten: string;
|
||||
}
|
||||
|
||||
export type StepErrors = Partial<Record<keyof Draft | 'documenten', string>>;
|
||||
|
||||
/** What we have AFTER parsing — branded/typed, guaranteed valid. */
|
||||
export interface Valid {
|
||||
uren: Uren;
|
||||
jaren: number;
|
||||
punten: number;
|
||||
documents: Array<{ categoryId: string; channel: 'digital' | 'post'; documentId?: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole wizard as one tagged union. `step` and `errors` exist ONLY while
|
||||
* Editing; Submitting/Submitted/Failed carry a `Valid` payload and nothing else.
|
||||
* So "submitting while a field is invalid" or "showing the success screen with
|
||||
* errors set" are unrepresentable — the bug class is gone by construction.
|
||||
*/
|
||||
export type WizardState =
|
||||
| { tag: 'Editing'; step: 1 | 2 | 3; draft: Draft; errors: StepErrors; upload: UploadState }
|
||||
| { tag: 'Submitting'; data: Valid }
|
||||
| { tag: 'Submitted'; data: Valid }
|
||||
| { tag: 'Failed'; data: Valid; error: string };
|
||||
|
||||
export const initial: WizardState = {
|
||||
tag: 'Editing',
|
||||
step: 1,
|
||||
draft: { uren: '', jaren: '', punten: '' },
|
||||
errors: {},
|
||||
upload: initialUpload,
|
||||
};
|
||||
|
||||
/** Has the user meaningfully started, so it's worth persisting as a Concept? */
|
||||
export function hasProgress(s: Extract<WizardState, { tag: 'Editing' }>): boolean {
|
||||
return (
|
||||
s.step > 1 ||
|
||||
!!s.draft.uren ||
|
||||
!!s.draft.jaren ||
|
||||
!!s.draft.punten ||
|
||||
deliveryRefs(s.upload).some((r) => r.channel === 'digital' && !!r.documentId)
|
||||
);
|
||||
}
|
||||
|
||||
/** Parse every field; on success hand back a Valid, else the per-field errors. */
|
||||
function validate(draft: Draft, upload: UploadState): Result<StepErrors, Valid> {
|
||||
const uren = parseUren(draft.uren);
|
||||
const jaren = parseUren(draft.jaren);
|
||||
const punten = parseUren(draft.punten);
|
||||
const errors: StepErrors = {};
|
||||
if (!uren.ok) errors.uren = uren.error;
|
||||
if (!jaren.ok) errors.jaren = jaren.error;
|
||||
if (!punten.ok) errors.punten = punten.error;
|
||||
if (!requiredCategoriesSatisfied(upload)) {
|
||||
errors.documenten = $localize`:@@validation.documenten:Lever de verplichte documenten aan (upload of kies "per post nasturen").`;
|
||||
}
|
||||
if (uren.ok && jaren.ok && punten.ok && !errors.documenten) {
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
uren: uren.value,
|
||||
jaren: jaren.value,
|
||||
punten: punten.value,
|
||||
documents: deliveryRefs(upload),
|
||||
},
|
||||
};
|
||||
}
|
||||
return { ok: false, error: errors };
|
||||
}
|
||||
|
||||
/** Advance one step, gating on that step's fields. Illegal elsewhere = no-op. */
|
||||
export function next(s: WizardState): WizardState {
|
||||
if (s.tag !== 'Editing') return s;
|
||||
const errors: StepErrors = {};
|
||||
if (s.step === 1) {
|
||||
const uren = parseUren(s.draft.uren);
|
||||
const jaren = parseUren(s.draft.jaren);
|
||||
if (!uren.ok) errors.uren = uren.error;
|
||||
if (!jaren.ok) errors.jaren = jaren.error;
|
||||
return Object.keys(errors).length === 0 ? { ...s, step: 2, errors: {} } : { ...s, errors };
|
||||
}
|
||||
if (s.step === 2) {
|
||||
const punten = parseUren(s.draft.punten);
|
||||
if (!punten.ok) errors.punten = punten.error;
|
||||
return punten.ok ? { ...s, step: 3, errors: {} } : { ...s, errors };
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
export function back(s: WizardState): WizardState {
|
||||
if (s.tag !== 'Editing' || s.step === 1) return s;
|
||||
return { ...s, step: (s.step - 1) as 1 | 2, errors: {} };
|
||||
}
|
||||
|
||||
/** Jump back to an earlier step to correct data (controle → step N). Forward
|
||||
jumps are not allowed (would skip validation). */
|
||||
export function gaNaarStap(s: WizardState, step: 1 | 2 | 3): WizardState {
|
||||
if (s.tag !== 'Editing' || step >= s.step) return s;
|
||||
return { ...s, step, errors: {} };
|
||||
}
|
||||
|
||||
/** Step 3 submit: parse everything + require documents; Submitting only with Valid. */
|
||||
export function submit(s: WizardState): WizardState {
|
||||
if (s.tag !== 'Editing' || s.step !== 3) return s;
|
||||
const result = validate(s.draft, s.upload);
|
||||
return result.ok ? { tag: 'Submitting', data: result.value } : { ...s, errors: result.error };
|
||||
}
|
||||
|
||||
/** Route an upload sub-message through the pure upload reducer (Editing only). */
|
||||
export function upload(s: WizardState, msg: UploadMsg): WizardState {
|
||||
if (s.tag !== 'Editing') return s;
|
||||
return { ...s, upload: reduceUpload(s.upload, msg) };
|
||||
}
|
||||
|
||||
/** Resolve the async submit. Only meaningful while Submitting. */
|
||||
export function resolve(s: WizardState, r: Result<string, void>): WizardState {
|
||||
if (s.tag !== 'Submitting') return s;
|
||||
return r.ok
|
||||
? { tag: 'Submitted', data: s.data }
|
||||
: { tag: 'Failed', data: s.data, error: r.error };
|
||||
}
|
||||
|
||||
/** Update one draft field while editing; ignored in any other state. */
|
||||
export function setField(s: WizardState, key: keyof Draft, value: string): WizardState {
|
||||
if (s.tag !== 'Editing') return s;
|
||||
return { ...s, draft: { ...s.draft, [key]: value } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Every event that can happen to the wizard, as one message type. The component
|
||||
* sends a WizardMsg; `reduce` decides the next state. This is the Elm
|
||||
* Model+Msg+update pattern: ONE pure function describes all state changes.
|
||||
*/
|
||||
export type WizardMsg =
|
||||
| { tag: 'SetField'; key: keyof Draft; value: string }
|
||||
| { tag: 'Next' }
|
||||
| { tag: 'Back' }
|
||||
| { tag: 'GaNaarStap'; step: 1 | 2 | 3 }
|
||||
| { tag: 'Submit' }
|
||||
| { tag: 'Retry' }
|
||||
| { tag: 'SubmitConfirmed' }
|
||||
| { tag: 'SubmitFailed'; error: string }
|
||||
| { tag: 'Upload'; msg: UploadMsg }
|
||||
| { tag: 'Seed'; state: WizardState }; // mount a specific state (stories/showcase)
|
||||
|
||||
export function reduce(s: WizardState, m: WizardMsg): WizardState {
|
||||
switch (m.tag) {
|
||||
case 'SetField':
|
||||
return setField(s, m.key, m.value);
|
||||
case 'Next':
|
||||
return next(s);
|
||||
case 'Back':
|
||||
return back(s);
|
||||
case 'GaNaarStap':
|
||||
return gaNaarStap(s, m.step);
|
||||
case 'Submit':
|
||||
return submit(s);
|
||||
case 'Retry':
|
||||
return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s;
|
||||
case 'SubmitConfirmed':
|
||||
return s.tag === 'Submitting' ? { tag: 'Submitted', data: s.data } : s;
|
||||
case 'SubmitFailed':
|
||||
return s.tag === 'Submitting' ? { tag: 'Failed', data: s.data, error: m.error } : s;
|
||||
case 'Upload':
|
||||
return upload(s, m.msg);
|
||||
case 'Seed':
|
||||
return m.state;
|
||||
default:
|
||||
return assertNever(m);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { hasProgress, initial, IntakeState } from './intake.machine';
|
||||
|
||||
const answering = initial as Extract<IntakeState, { tag: 'Answering' }>;
|
||||
|
||||
describe('intake hasProgress', () => {
|
||||
it('is false for a fresh questionnaire', () => {
|
||||
expect(hasProgress(answering)).toBe(false);
|
||||
});
|
||||
|
||||
it('is true once an answer is given or the user advances', () => {
|
||||
expect(hasProgress({ ...answering, answers: { buitenlandGewerkt: 'ja' } })).toBe(true);
|
||||
expect(hasProgress({ ...answering, cursor: 1 })).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ok, err } from '@shared/kernel/fp';
|
||||
import {
|
||||
Answers,
|
||||
initial,
|
||||
STEPS,
|
||||
lageUren,
|
||||
currentStep,
|
||||
next,
|
||||
back,
|
||||
gaNaarStap,
|
||||
submit,
|
||||
resolve,
|
||||
reduce,
|
||||
IntakeState,
|
||||
} from './intake.machine';
|
||||
|
||||
const answering = (answers: Answers, cursor = 0, scholingThreshold = 1000): IntakeState => ({
|
||||
tag: 'Answering',
|
||||
answers,
|
||||
cursor,
|
||||
errors: {},
|
||||
scholingThreshold,
|
||||
});
|
||||
|
||||
describe('STEPS (fixed) and inline questions', () => {
|
||||
it('always has the same three steps', () => {
|
||||
expect(STEPS).toEqual(['buitenland', 'werk', 'review']);
|
||||
});
|
||||
|
||||
it('reveals the buitenland detail questions inline only when worked abroad', () => {
|
||||
// No new step; instead these fields become required within the buitenland step.
|
||||
expect(next(answering({ buitenlandGewerkt: 'ja' })).tag).toBe('Answering'); // land/uren missing -> blocked
|
||||
expect((next(answering({ buitenlandGewerkt: 'ja' })) as any).errors.land).toBeTruthy();
|
||||
expect(next(answering({ buitenlandGewerkt: 'nee' })).tag).toBe('Answering'); // valid, advances (cursor moves)
|
||||
expect((next(answering({ buitenlandGewerkt: 'nee' })) as any).cursor).toBe(1);
|
||||
});
|
||||
|
||||
it('reveals the scholing question only when NL-hours are below the threshold', () => {
|
||||
expect(lageUren({ uren: '500' })).toBe(true);
|
||||
expect(lageUren({ uren: '4160' })).toBe(false);
|
||||
});
|
||||
|
||||
it('uses the (server-owned) threshold passed in, not a hardcoded constant', () => {
|
||||
// Same hours, different threshold → different visibility. Proves de-hardcoding.
|
||||
expect(lageUren({ uren: '1500' }, 1000)).toBe(false);
|
||||
expect(lageUren({ uren: '1500' }, 2000)).toBe(true);
|
||||
// And the threshold from state flows through submit:
|
||||
const lowThreshold = submit(
|
||||
answering({ buitenlandGewerkt: 'nee', uren: '1500', punten: '200' }, 0, 2000),
|
||||
);
|
||||
expect(lowThreshold.tag).toBe('Answering'); // scholing now required (1500 < 2000), unanswered → blocked
|
||||
expect((lowThreshold as any).errors.scholingGevolgd).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('navigation', () => {
|
||||
it('Next is a no-op (sets an error) when the current step is invalid', () => {
|
||||
const s = next(initial); // buitenland unanswered
|
||||
expect(s.tag).toBe('Answering');
|
||||
expect((s as any).cursor).toBe(0);
|
||||
expect((s as any).errors.buitenlandGewerkt).toBeTruthy();
|
||||
});
|
||||
|
||||
it('Next advances once the step is valid', () => {
|
||||
const s = next(answering({ buitenlandGewerkt: 'nee' }));
|
||||
expect((s as any).cursor).toBe(1);
|
||||
expect(currentStep(s as any)).toBe('werk');
|
||||
});
|
||||
|
||||
it('editing an answer leaves the cursor fixed (steps never collapse)', () => {
|
||||
const edited = reduce(answering({ buitenlandGewerkt: 'ja' }, 1), {
|
||||
tag: 'SetAnswer',
|
||||
key: 'buitenlandGewerkt',
|
||||
value: 'nee',
|
||||
});
|
||||
expect((edited as any).cursor).toBe(1); // cursor untouched; only inline questions change
|
||||
});
|
||||
|
||||
it('Back never goes below the first step', () => {
|
||||
expect(back(initial)).toBe(initial);
|
||||
});
|
||||
|
||||
it('gaNaarStap jumps back to an earlier step, clearing errors', () => {
|
||||
const s = answering({ buitenlandGewerkt: 'nee' }, 2);
|
||||
expect((gaNaarStap(s, 0) as any).cursor).toBe(0);
|
||||
});
|
||||
|
||||
it('gaNaarStap ignores a same/forward jump and jumps outside Answering', () => {
|
||||
const s = answering({ buitenlandGewerkt: 'nee' }, 1);
|
||||
expect(gaNaarStap(s, 1)).toBe(s); // same step -> no-op
|
||||
expect(gaNaarStap(s, 2)).toBe(s); // forward -> no-op
|
||||
const submitting = submit(answering({ buitenlandGewerkt: 'nee', uren: '4160' }, 2));
|
||||
expect(gaNaarStap(submitting, 0)).toBe(submitting); // not Answering -> no-op
|
||||
});
|
||||
});
|
||||
|
||||
describe('submit', () => {
|
||||
// High hours: no scholing question, so no punten is asked or collected.
|
||||
const complete: Answers = { buitenlandGewerkt: 'nee', uren: '4160' };
|
||||
|
||||
it('reaches Submitting ONLY with valid answers', () => {
|
||||
// Bad punten only blocks when scholing was followed (otherwise punten is ignored).
|
||||
expect(
|
||||
submit(
|
||||
answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja', punten: 'x' }),
|
||||
).tag,
|
||||
).toBe('Answering');
|
||||
const good = submit(answering(complete));
|
||||
expect(good.tag).toBe('Submitting');
|
||||
expect((good as any).data.uren).toBe(4160);
|
||||
expect((good as any).data.punten).toBeUndefined(); // not collected without scholing
|
||||
});
|
||||
|
||||
it('punten is required only when aanvullende scholing was gevolgd', () => {
|
||||
// scholing = ja but punten missing -> blocked on punten.
|
||||
const missing = submit(
|
||||
answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja' }),
|
||||
);
|
||||
expect(missing.tag).toBe('Answering');
|
||||
expect((missing as any).errors.punten).toBeTruthy();
|
||||
// scholing = nee -> punten not required, submits without it.
|
||||
expect(
|
||||
submit(answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'nee' })).tag,
|
||||
).toBe('Submitting');
|
||||
});
|
||||
|
||||
it('low hours requires the scholing answer before submit', () => {
|
||||
const noScholing = submit(answering({ buitenlandGewerkt: 'nee', uren: '500' }));
|
||||
expect(noScholing.tag).toBe('Answering'); // scholing question is required, unanswered
|
||||
const withScholing = submit(
|
||||
answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja', punten: '200' }),
|
||||
);
|
||||
expect(withScholing.tag).toBe('Submitting');
|
||||
expect((withScholing as any).data.aanvullendeScholing).toBe(true);
|
||||
expect((withScholing as any).data.punten).toBe(200);
|
||||
});
|
||||
|
||||
it('resolve maps Submitting to Submitted on a successful submit', () => {
|
||||
const submitting = submit(answering(complete));
|
||||
expect(resolve(submitting, ok(undefined)).tag).toBe('Submitted');
|
||||
});
|
||||
|
||||
it('resolve maps Submitting to Failed on a failed submit', () => {
|
||||
const submitting = submit(answering(complete));
|
||||
expect(resolve(submitting, err('boom')).tag).toBe('Failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('reduce (message-driven happy path)', () => {
|
||||
it('drives abroad branch end to end', () => {
|
||||
let s: IntakeState = initial;
|
||||
// Step 1: buitenland — the country/hours questions reveal inline (same step).
|
||||
s = reduce(s, { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'ja' });
|
||||
s = reduce(s, { tag: 'SetAnswer', key: 'land', value: 'België' });
|
||||
s = reduce(s, { tag: 'SetAnswer', key: 'buitenlandseUren', value: '800' });
|
||||
s = reduce(s, { tag: 'Next' });
|
||||
expect(currentStep(s as any)).toBe('werk');
|
||||
// Step 2: werk — uren + punten (no inline scholing, hours are high).
|
||||
s = reduce(s, { tag: 'SetAnswer', key: 'uren', value: '4160' });
|
||||
s = reduce(s, { tag: 'SetAnswer', key: 'punten', value: '200' });
|
||||
s = reduce(s, { tag: 'Next' });
|
||||
expect(currentStep(s as any)).toBe('review');
|
||||
s = reduce(s, { tag: 'Submit' });
|
||||
expect(s.tag).toBe('Submitting');
|
||||
s = reduce(s, { tag: 'SubmitConfirmed' });
|
||||
expect(s.tag).toBe('Submitted');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
import { Result, ok, err, assertNever } from '@shared/kernel/fp';
|
||||
import { Uren, parseUren } from '@registratie/domain/value-objects/uren';
|
||||
|
||||
/**
|
||||
* A FIXED 3-step wizard with progressive disclosure. The steps never change in
|
||||
* number (always `STEPS`); instead, follow-up questions appear *inline within a
|
||||
* step* depending on earlier answers — answer "buiten Nederland gewerkt? → ja"
|
||||
* and the country/hours questions reveal in the same step; report few hours and
|
||||
* the scholing-question reveals inside the 'werk' step. "Is this field required
|
||||
* right now" is a pure function (`validateStep`/`lageUren`), so it's trivial to
|
||||
* test and impossible to get out of sync with the data.
|
||||
*/
|
||||
|
||||
export type JaNee = 'ja' | 'nee';
|
||||
|
||||
/** The three fixed steps. Each step groups one or more questions. */
|
||||
export type StepId = 'buitenland' | 'werk' | 'review';
|
||||
|
||||
/** One record carried across every step (and persisted). All optional: the user
|
||||
fills it in gradually, and branches may never ask some fields. */
|
||||
export interface Answers {
|
||||
buitenlandGewerkt?: JaNee; // Q1
|
||||
land?: string; // Q1a — only when buitenlandGewerkt === 'ja'
|
||||
buitenlandseUren?: string; // Q1b — only when buitenlandGewerkt === 'ja'
|
||||
uren?: string; // Q2 — uren in NL
|
||||
scholingGevolgd?: JaNee; // Q3 — only when total hours are below the threshold
|
||||
punten?: string; // Q4
|
||||
}
|
||||
|
||||
/** What we have after the review step parses — guaranteed valid/typed. */
|
||||
export interface ValidIntake {
|
||||
werktBuitenland: boolean;
|
||||
land?: string;
|
||||
buitenlandseUren?: Uren;
|
||||
uren: Uren;
|
||||
aanvullendeScholing?: boolean;
|
||||
punten?: Uren; // only collected when aanvullende scholing is gevolgd (scholingGevolgd === 'ja')
|
||||
}
|
||||
|
||||
/** Demo fallback only — the real threshold is a SERVER-OWNED policy value fetched
|
||||
at runtime (see IntakePolicyDto / SetPolicy). ponytail: default is the offline
|
||||
fallback; the server value wins. */
|
||||
export const SCHOLING_THRESHOLD_DEFAULT = 1000;
|
||||
|
||||
/** The server-owned intake policy (domain-side, parsed from the wire at the boundary). */
|
||||
export interface IntakePolicy {
|
||||
readonly scholingThreshold: number;
|
||||
}
|
||||
|
||||
/** True when NL-hours are low enough that the scholing question must be answered.
|
||||
The threshold is passed in (server-owned), not hardcoded. */
|
||||
export function lageUren(a: Answers, scholingThreshold = SCHOLING_THRESHOLD_DEFAULT): boolean {
|
||||
const r = parseUren(a.uren ?? '');
|
||||
return r.ok && r.value < scholingThreshold;
|
||||
}
|
||||
|
||||
// #region showcase:steps
|
||||
/** The fixed step list. Number of steps never changes; questions reveal inline. */
|
||||
export const STEPS: StepId[] = ['buitenland', 'werk', 'review'];
|
||||
// #endregion showcase:steps
|
||||
|
||||
/** Per-field error map: one message per question, since a step holds several. */
|
||||
type Errors = Partial<Record<keyof Answers, string>>;
|
||||
|
||||
export type IntakeState =
|
||||
| {
|
||||
tag: 'Answering';
|
||||
answers: Answers;
|
||||
cursor: number;
|
||||
errors: Errors;
|
||||
scholingThreshold: number;
|
||||
}
|
||||
| { tag: 'Submitting'; data: ValidIntake }
|
||||
| { tag: 'Submitted'; data: ValidIntake }
|
||||
| { tag: 'Failed'; data: ValidIntake; error: string };
|
||||
|
||||
export const initial: IntakeState = {
|
||||
tag: 'Answering',
|
||||
answers: {},
|
||||
cursor: 0,
|
||||
errors: {},
|
||||
scholingThreshold: SCHOLING_THRESHOLD_DEFAULT,
|
||||
};
|
||||
|
||||
/** Which step the cursor currently points at (clamped to the fixed list). */
|
||||
export function currentStep(s: Extract<IntakeState, { tag: 'Answering' }>): StepId {
|
||||
return STEPS[Math.min(s.cursor, STEPS.length - 1)];
|
||||
}
|
||||
|
||||
/** Has the user meaningfully started, so it's worth persisting as a Concept?
|
||||
(No auto-prefill here — pristine means truly untouched.) */
|
||||
export function hasProgress(s: Extract<IntakeState, { tag: 'Answering' }>): boolean {
|
||||
return s.cursor > 0 || Object.keys(s.answers).length > 0;
|
||||
}
|
||||
|
||||
/** Validate every question currently visible in ONE step. Errors keyed per field. */
|
||||
function validateStep(step: StepId, a: Answers, scholingThreshold: number): Result<Errors, void> {
|
||||
const errors: Errors = {};
|
||||
switch (step) {
|
||||
case 'buitenland':
|
||||
if (!a.buitenlandGewerkt)
|
||||
errors.buitenlandGewerkt = $localize`:@@validation.maakKeuze:Maak een keuze.`;
|
||||
else if (a.buitenlandGewerkt === 'ja') {
|
||||
if (!a.land || a.land.trim() === '')
|
||||
errors.land = $localize`:@@validation.land:Vul een land in.`;
|
||||
const u = parseUren(a.buitenlandseUren ?? '');
|
||||
if (!u.ok) errors.buitenlandseUren = u.error;
|
||||
}
|
||||
break;
|
||||
case 'werk': {
|
||||
const u = parseUren(a.uren ?? '');
|
||||
if (!u.ok) errors.uren = u.error;
|
||||
if (lageUren(a, scholingThreshold) && !a.scholingGevolgd)
|
||||
errors.scholingGevolgd = $localize`:@@validation.maakKeuze:Maak een keuze.`;
|
||||
// Nascholingspunten are only asked (and required) when scholing was followed.
|
||||
if (a.scholingGevolgd === 'ja') {
|
||||
const p = parseUren(a.punten ?? '');
|
||||
if (!p.ok) errors.punten = p.error;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'review':
|
||||
break; // review shows a summary; no own fields
|
||||
default:
|
||||
return assertNever(step);
|
||||
}
|
||||
return Object.keys(errors).length > 0 ? err(errors) : ok(undefined);
|
||||
}
|
||||
|
||||
/** Parse the whole questionnaire into a ValidIntake (called on submit). */
|
||||
function validateAll(a: Answers, scholingThreshold: number): Result<Errors, ValidIntake> {
|
||||
const errors: Errors = {};
|
||||
for (const step of STEPS) {
|
||||
const r = validateStep(step, a, scholingThreshold);
|
||||
if (!r.ok) Object.assign(errors, r.error);
|
||||
}
|
||||
if (Object.keys(errors).length > 0) return err(errors);
|
||||
|
||||
const uren = parseUren(a.uren ?? '');
|
||||
// validateStep guaranteed uren parses, but keep the compiler happy.
|
||||
if (!uren.ok) return err(errors);
|
||||
|
||||
const werktBuitenland = a.buitenlandGewerkt === 'ja';
|
||||
const buitenland = parseUren(a.buitenlandseUren ?? '');
|
||||
// Punten are only collected when aanvullende scholing was gevolgd.
|
||||
const punten = a.scholingGevolgd === 'ja' ? parseUren(a.punten ?? '') : undefined;
|
||||
return ok({
|
||||
werktBuitenland,
|
||||
land: werktBuitenland ? a.land : undefined,
|
||||
buitenlandseUren: werktBuitenland && buitenland.ok ? buitenland.value : undefined,
|
||||
uren: uren.value,
|
||||
aanvullendeScholing: lageUren(a, scholingThreshold) ? a.scholingGevolgd === 'ja' : undefined,
|
||||
punten: punten?.ok ? punten.value : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export function setAnswer(s: IntakeState, key: keyof Answers, value: string): IntakeState {
|
||||
if (s.tag !== 'Answering') return s;
|
||||
// Steps are fixed, so editing an answer never moves the cursor — it only
|
||||
// reveals/hides inline questions within the current step.
|
||||
return { ...s, answers: { ...s.answers, [key]: value } };
|
||||
}
|
||||
|
||||
export function next(s: IntakeState): IntakeState {
|
||||
if (s.tag !== 'Answering') return s;
|
||||
const r = validateStep(currentStep(s), s.answers, s.scholingThreshold);
|
||||
if (!r.ok) return { ...s, errors: r.error };
|
||||
return { ...s, cursor: Math.min(s.cursor + 1, STEPS.length - 1), errors: {} };
|
||||
}
|
||||
|
||||
/** Apply a server-owned policy value (e.g. the scholing threshold). */
|
||||
export function setPolicy(s: IntakeState, scholingThreshold: number): IntakeState {
|
||||
return s.tag === 'Answering' ? { ...s, scholingThreshold } : s;
|
||||
}
|
||||
|
||||
export function back(s: IntakeState): IntakeState {
|
||||
if (s.tag !== 'Answering' || s.cursor === 0) return s;
|
||||
return { ...s, cursor: s.cursor - 1, errors: {} };
|
||||
}
|
||||
|
||||
/** Jump back to an earlier step to correct answers (review → step N). Forward
|
||||
jumps are not allowed (would skip validation). */
|
||||
export function gaNaarStap(s: IntakeState, cursor: number): IntakeState {
|
||||
if (s.tag !== 'Answering' || cursor < 0 || cursor >= s.cursor) return s;
|
||||
return { ...s, cursor, errors: {} };
|
||||
}
|
||||
|
||||
export function submit(s: IntakeState): IntakeState {
|
||||
if (s.tag !== 'Answering') return s;
|
||||
const r = validateAll(s.answers, s.scholingThreshold);
|
||||
return r.ok ? { tag: 'Submitting', data: r.value } : { ...s, errors: r.error };
|
||||
}
|
||||
|
||||
export function resolve(s: IntakeState, r: Result<string, void>): IntakeState {
|
||||
if (s.tag !== 'Submitting') return s;
|
||||
return r.ok
|
||||
? { tag: 'Submitted', data: s.data }
|
||||
: { tag: 'Failed', data: s.data, error: r.error };
|
||||
}
|
||||
|
||||
export type IntakeMsg =
|
||||
| { tag: 'SetAnswer'; key: keyof Answers; value: string }
|
||||
| { tag: 'Next' }
|
||||
| { tag: 'Back' }
|
||||
| { tag: 'GaNaarStap'; cursor: number }
|
||||
| { tag: 'Submit' }
|
||||
| { tag: 'Retry' }
|
||||
| { tag: 'SubmitConfirmed' }
|
||||
| { tag: 'SubmitFailed'; error: string }
|
||||
| { tag: 'SetPolicy'; scholingThreshold: number }
|
||||
| { tag: 'Seed'; state: IntakeState };
|
||||
|
||||
export function reduce(s: IntakeState, m: IntakeMsg): IntakeState {
|
||||
switch (m.tag) {
|
||||
case 'SetAnswer':
|
||||
return setAnswer(s, m.key, m.value);
|
||||
case 'Next':
|
||||
return next(s);
|
||||
case 'Back':
|
||||
return back(s);
|
||||
case 'GaNaarStap':
|
||||
return gaNaarStap(s, m.cursor);
|
||||
case 'Submit':
|
||||
return submit(s);
|
||||
case 'Retry':
|
||||
return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s;
|
||||
case 'SubmitConfirmed':
|
||||
return s.tag === 'Submitting' ? { tag: 'Submitted', data: s.data } : s;
|
||||
case 'SubmitFailed':
|
||||
return s.tag === 'Submitting' ? { tag: 'Failed', data: s.data, error: m.error } : s;
|
||||
case 'SetPolicy':
|
||||
return setPolicy(s, m.scholingThreshold);
|
||||
case 'Seed':
|
||||
return m.state;
|
||||
default:
|
||||
return assertNever(m);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseIntakePolicy } from './intake-policy.adapter';
|
||||
|
||||
describe('intake-policy.adapter parse boundary', () => {
|
||||
it('parses a well-formed policy', () => {
|
||||
const r = parseIntakePolicy({ scholingThreshold: 800 });
|
||||
expect(r).toEqual({ ok: true, value: { scholingThreshold: 800 } });
|
||||
});
|
||||
|
||||
it('rejects a missing or non-numeric threshold', () => {
|
||||
expect(parseIntakePolicy({}).ok).toBe(false);
|
||||
expect(parseIntakePolicy({ scholingThreshold: '800' }).ok).toBe(false);
|
||||
expect(parseIntakePolicy(null).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Injectable, inject, resource } from '@angular/core';
|
||||
import { Result, ok, err } from '@shared/kernel/fp';
|
||||
import { IntakePolicy } from '@herregistratie/domain/intake.machine';
|
||||
import { ApiClient } from '@shared/infrastructure/api-client';
|
||||
|
||||
/**
|
||||
* Infrastructure adapter for the intake policy (the scholing threshold config
|
||||
* value). Same shape as every other adapter — a signal `resource` over the
|
||||
* generated typed client — so HTTP lives in exactly one place per concern.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class IntakePolicyAdapter {
|
||||
private client = inject(ApiClient);
|
||||
|
||||
policyResource() {
|
||||
return resource({
|
||||
loader: async () => {
|
||||
const parsed = parseIntakePolicy(await this.client.policy());
|
||||
if (!parsed.ok) throw new Error(parsed.error);
|
||||
return parsed.value;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Trust-boundary parse: an unrecognized/missing threshold is an explicit Failure. */
|
||||
export function parseIntakePolicy(json: unknown): Result<string, IntakePolicy> {
|
||||
if (typeof json !== 'object' || json === null) return err('intake-policy: not an object');
|
||||
const dto = json as { scholingThreshold?: unknown };
|
||||
if (typeof dto.scholingThreshold !== 'number')
|
||||
return err('intake-policy: missing scholingThreshold');
|
||||
return ok({ scholingThreshold: dto.scholingThreshold });
|
||||
}
|
||||
+289
@@ -0,0 +1,289 @@
|
||||
import { Component, computed, inject, input } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
|
||||
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
|
||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||
import {
|
||||
WizardShellComponent,
|
||||
WizardError,
|
||||
WizardStatus,
|
||||
naarStapLabel,
|
||||
} from '@shared/layout/wizard-shell/wizard-shell.component';
|
||||
import { ConfirmationComponent } from '@shared/ui/confirmation/confirmation.component';
|
||||
import { createStore } from '@shared/application/store';
|
||||
import { whenTag } from '@shared/kernel/fp';
|
||||
import { BigProfileStore } from '@registratie/application/big-profile.store';
|
||||
import {
|
||||
WizardState,
|
||||
WizardMsg,
|
||||
Draft,
|
||||
initial,
|
||||
reduce,
|
||||
hasProgress,
|
||||
} from '@herregistratie/domain/herregistratie.machine';
|
||||
import { createDraftSync } from '@registratie/application/draft-sync';
|
||||
import { DocumentUploadComponent } from '@shared/ui/upload/document-upload/document-upload.component';
|
||||
import { createUploadController } from '@shared/upload/upload-controller';
|
||||
import { UploadAdapter } from '@shared/upload/upload.adapter';
|
||||
import { UploadState, initialUpload, deliveryRefs } from '@shared/upload/upload.machine';
|
||||
|
||||
/** Organism: multi-step herregistratie wizard. ALL state lives in one signal
|
||||
driven by the pure `reduce` function (see herregistratie.machine.ts) via an
|
||||
Elm-style store. The UI just sends messages and folds over the state's tag —
|
||||
no booleans like `submitting`/`submitted` that could contradict each other.
|
||||
Submitting also flips an optimistic flag on the shared BigProfileStore, so
|
||||
the dashboard shows "in behandeling" immediately. */
|
||||
@Component({
|
||||
selector: 'app-herregistratie-wizard',
|
||||
imports: [
|
||||
FormsModule,
|
||||
FormFieldComponent,
|
||||
TextInputComponent,
|
||||
AlertComponent,
|
||||
ConfirmationComponent,
|
||||
WizardShellComponent,
|
||||
DocumentUploadComponent,
|
||||
],
|
||||
template: `
|
||||
<app-wizard-shell
|
||||
[steps]="stepLabels"
|
||||
[current]="step() - 1"
|
||||
[stepTitle]="stepTitle()"
|
||||
i18n-processName="@@herregWizard.processName"
|
||||
processName="Herregistratie aanvragen"
|
||||
[status]="shellStatus()"
|
||||
[primaryLabel]="primaryLabel()"
|
||||
[canGoBack]="step() > 1"
|
||||
[errors]="errorList()"
|
||||
[errorMessage]="errorMessage()"
|
||||
(primary)="onPrimary()"
|
||||
(back)="dispatch({ tag: 'Back' })"
|
||||
(cancel)="restart()"
|
||||
(retry)="onRetry()"
|
||||
(goToStep)="goToStep($event)"
|
||||
>
|
||||
@switch (step()) {
|
||||
@case (1) {
|
||||
<fieldset>
|
||||
<app-form-field
|
||||
i18n-label="@@herregWizard.urenLabel"
|
||||
label="Gewerkte uren (afgelopen 5 jaar)"
|
||||
fieldId="uren"
|
||||
required
|
||||
[error]="errUren()"
|
||||
>
|
||||
<app-text-input
|
||||
inputId="uren"
|
||||
[ngModel]="draft().uren"
|
||||
(ngModelChange)="dispatch({ tag: 'SetField', key: 'uren', value: $event })"
|
||||
name="uren"
|
||||
[invalid]="!!errUren()"
|
||||
i18n-placeholder="@@herregWizard.urenPlaceholder"
|
||||
placeholder="bijv. 4160"
|
||||
/>
|
||||
</app-form-field>
|
||||
<app-form-field
|
||||
i18n-label="@@herregWizard.jarenLabel"
|
||||
label="Aantal jaren werkzaam"
|
||||
fieldId="jaren"
|
||||
required
|
||||
[error]="errJaren()"
|
||||
>
|
||||
<app-text-input
|
||||
inputId="jaren"
|
||||
[ngModel]="draft().jaren"
|
||||
(ngModelChange)="dispatch({ tag: 'SetField', key: 'jaren', value: $event })"
|
||||
name="jaren"
|
||||
[invalid]="!!errJaren()"
|
||||
i18n-placeholder="@@herregWizard.jarenPlaceholder"
|
||||
placeholder="bijv. 5"
|
||||
/>
|
||||
</app-form-field>
|
||||
</fieldset>
|
||||
}
|
||||
@case (2) {
|
||||
<fieldset>
|
||||
<app-form-field
|
||||
i18n-label="@@herregWizard.puntenLabel"
|
||||
label="Behaalde nascholingspunten"
|
||||
fieldId="punten"
|
||||
required
|
||||
[error]="errPunten()"
|
||||
>
|
||||
<app-text-input
|
||||
inputId="punten"
|
||||
[ngModel]="draft().punten"
|
||||
(ngModelChange)="dispatch({ tag: 'SetField', key: 'punten', value: $event })"
|
||||
name="punten"
|
||||
[invalid]="!!errPunten()"
|
||||
i18n-placeholder="@@herregWizard.puntenPlaceholder"
|
||||
placeholder="bijv. 200"
|
||||
/>
|
||||
</app-form-field>
|
||||
</fieldset>
|
||||
}
|
||||
@case (3) {
|
||||
<app-document-upload
|
||||
[state]="upload()"
|
||||
[previewUrlFor]="previewUrlFor"
|
||||
(fileSelected)="uploadCtl.onFileSelected($event.categoryId, $event.files)"
|
||||
(removeUpload)="uploadCtl.onRemove($event)"
|
||||
(retryUpload)="uploadCtl.onRetry($event)"
|
||||
(deleteUpload)="uploadCtl.onDelete($event)"
|
||||
(channelChange)="uploadCtl.onChannelChange($event.categoryId, $event.channel)"
|
||||
/>
|
||||
@if (errDocumenten()) {
|
||||
<app-alert type="warning">{{ errDocumenten() }}</app-alert>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
<div wizardSuccess>
|
||||
<app-confirmation
|
||||
i18n-title="@@herregWizard.success.title"
|
||||
title="Uw aanvraag tot herregistratie is ontvangen"
|
||||
/>
|
||||
</div>
|
||||
</app-wizard-shell>
|
||||
`,
|
||||
})
|
||||
export class HerregistratieWizardComponent {
|
||||
private profile = inject(BigProfileStore);
|
||||
private uploadAdapter = inject(UploadAdapter);
|
||||
private store = createStore<WizardState, WizardMsg>(initial, reduce);
|
||||
|
||||
/** Preview/download link for a completed upload; dev-simulation `demo-*` ids have
|
||||
no stored bytes, so they get no link. */
|
||||
protected previewUrlFor = (documentId: string): string | undefined =>
|
||||
documentId.startsWith('demo-') ? undefined : this.uploadAdapter.contentUrl(documentId);
|
||||
|
||||
/** Optional seed so Storybook / the showcase can mount any state directly. */
|
||||
seed = input<WizardState>(initial);
|
||||
|
||||
readonly state = this.store.model; // public so the showcase can highlight the live state
|
||||
protected dispatch = this.store.dispatch;
|
||||
|
||||
// Backend draft-sync (new persistence for this wizard): create a Concept on first
|
||||
// progress, debounced-sync the snapshot, resume by `?aanvraag=<id>`.
|
||||
private draftSync = createDraftSync({
|
||||
type: 'herregistratie',
|
||||
snapshot: () => {
|
||||
const s = this.state();
|
||||
if (s.tag !== 'Editing' || !hasProgress(s)) return null;
|
||||
const documentIds = deliveryRefs(s.upload)
|
||||
.filter((r) => r.channel === 'digital' && r.documentId)
|
||||
.map((r) => r.documentId!);
|
||||
return { draft: s, stepIndex: s.step - 1, stepCount: this.stepLabels.length, documentIds };
|
||||
},
|
||||
onResume: (draft) => this.dispatch({ tag: 'Seed', state: draft as WizardState }),
|
||||
enabled: () => this.seed() === initial,
|
||||
});
|
||||
|
||||
// Stepper labels + per-step heading titles (presentational only).
|
||||
readonly stepLabels = [
|
||||
$localize`:@@herregWizard.step.werkervaring:Werkervaring`,
|
||||
$localize`:@@herregWizard.step.nascholing:Nascholing`,
|
||||
$localize`:@@herregWizard.step.documenten:Documenten`,
|
||||
];
|
||||
private stepTitles = [
|
||||
$localize`:@@herregWizard.title.werkervaring:Werkervaring (afgelopen 5 jaar)`,
|
||||
$localize`:@@herregWizard.title.nascholing:Nascholing`,
|
||||
$localize`:@@herregWizard.title.documenten:Documenten aanleveren`,
|
||||
];
|
||||
|
||||
private editing = computed(() => whenTag(this.state(), 'Editing'));
|
||||
protected step = computed(() => this.editing()?.step ?? 1);
|
||||
protected draft = computed<Draft>(
|
||||
() => this.editing()?.draft ?? { uren: '', jaren: '', punten: '' },
|
||||
);
|
||||
protected upload = computed<UploadState>(() => this.editing()?.upload ?? initialUpload);
|
||||
protected errUren = computed(() => this.editing()?.errors.uren ?? '');
|
||||
protected errJaren = computed(() => this.editing()?.errors.jaren ?? '');
|
||||
protected errPunten = computed(() => this.editing()?.errors.punten ?? '');
|
||||
protected errDocumenten = computed(() => this.editing()?.errors.documenten ?? '');
|
||||
protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? '');
|
||||
protected uploadCtl = createUploadController({
|
||||
wizardId: 'herregistratie',
|
||||
getUpload: () => this.upload(),
|
||||
dispatch: (msg) => this.dispatch({ tag: 'Upload', msg }),
|
||||
});
|
||||
|
||||
// --- Presentational wiring for the shared wizard shell ---------------------
|
||||
protected stepTitle = computed(() => this.stepTitles[this.step() - 1]);
|
||||
protected primaryLabel = computed(() => {
|
||||
const step = this.step();
|
||||
return step < 3
|
||||
? naarStapLabel(step + 1, this.stepLabels[step])
|
||||
: $localize`:@@herregWizard.indienen:Herregistratie aanvragen`;
|
||||
});
|
||||
|
||||
/** Stepper emits a 0-based index for an earlier (visited) step. */
|
||||
protected goToStep(index: number) {
|
||||
this.dispatch({ tag: 'GaNaarStap', step: (index + 1) as 1 | 2 | 3 });
|
||||
}
|
||||
protected errorMessage = computed(
|
||||
() => $localize`:@@wizard.indienenMislukt:Indienen mislukt:` + ` ${this.failedError()}`,
|
||||
);
|
||||
protected shellStatus = computed<WizardStatus>(() => {
|
||||
switch (this.state().tag) {
|
||||
case 'Editing':
|
||||
return 'editing';
|
||||
case 'Submitting':
|
||||
return 'submitting';
|
||||
case 'Submitted':
|
||||
return 'submitted';
|
||||
case 'Failed':
|
||||
return 'failed';
|
||||
}
|
||||
});
|
||||
/** Current step's field errors, flattened for the shell's error summary. */
|
||||
protected errorList = computed<WizardError[]>(() => {
|
||||
const e = this.editing()?.errors ?? {};
|
||||
return (Object.keys(e) as (keyof typeof e)[])
|
||||
.filter((k) => e[k])
|
||||
.map((k) => ({ id: k, message: e[k]! }));
|
||||
});
|
||||
|
||||
constructor() {
|
||||
// An explicit seed (stories/tests) wins; otherwise resume the backend draft
|
||||
// (`?aanvraag=<id>`) or start fresh. Persistence is the draftSync controller's job.
|
||||
const seeded = this.seed();
|
||||
queueMicrotask(() =>
|
||||
seeded !== initial ? this.dispatch({ tag: 'Seed', state: seeded }) : this.draftSync.resume(),
|
||||
);
|
||||
}
|
||||
|
||||
onPrimary() {
|
||||
const s = this.state();
|
||||
if (s.tag !== 'Editing') return;
|
||||
this.dispatch(s.step < 3 ? { tag: 'Next' } : { tag: 'Submit' });
|
||||
this.runIfSubmitting();
|
||||
}
|
||||
|
||||
onRetry() {
|
||||
this.dispatch({ tag: 'Retry' });
|
||||
this.runIfSubmitting();
|
||||
}
|
||||
|
||||
/** Reset the wizard to a fresh, empty start. */
|
||||
restart() {
|
||||
this.draftSync.reset();
|
||||
this.dispatch({ tag: 'Seed', state: initial });
|
||||
}
|
||||
|
||||
/** The effect: when we entered Submitting, submit through the aanvraag lifecycle,
|
||||
flip the optimistic cross-page flag, then dispatch the result (commit/rollback). */
|
||||
private async runIfSubmitting() {
|
||||
const s = this.state();
|
||||
if (s.tag !== 'Submitting') return;
|
||||
this.profile.beginHerregistratie();
|
||||
const r = await this.draftSync.submit({ uren: s.data.uren, documents: s.data.documents });
|
||||
if (r.ok) {
|
||||
this.dispatch({ tag: 'SubmitConfirmed' });
|
||||
this.profile.confirmHerregistratie();
|
||||
} else {
|
||||
this.dispatch({ tag: 'SubmitFailed', error: r.error });
|
||||
this.profile.rollbackHerregistratie();
|
||||
}
|
||||
}
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { applicationConfig } from '@storybook/angular';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideApiClient } from '@shared/infrastructure/api-client.provider';
|
||||
import { HerregistratieWizardComponent } from './herregistratie-wizard.component';
|
||||
import { WizardState } from '@herregistratie/domain/herregistratie.machine';
|
||||
import { initialUpload } from '@shared/upload/upload.machine';
|
||||
import { Uren } from '@registratie/domain/value-objects/uren';
|
||||
|
||||
const validData = { uren: 4160 as Uren, jaren: 5, punten: 200, documents: [] };
|
||||
|
||||
const meta: Meta<HerregistratieWizardComponent> = {
|
||||
title: 'Domein/Herregistratie/Wizard',
|
||||
component: HerregistratieWizardComponent,
|
||||
// The wizard injects BigProfileStore (for the optimistic cross-page flag),
|
||||
// which creates httpResources — so the story needs an HttpClient.
|
||||
decorators: [applicationConfig({ providers: [provideHttpClient(), provideApiClient()] })],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<HerregistratieWizardComponent>;
|
||||
|
||||
// Each story seeds one state of the machine — one render per union variant.
|
||||
export const Step1: Story = {
|
||||
args: {
|
||||
seed: {
|
||||
tag: 'Editing',
|
||||
step: 1,
|
||||
draft: { uren: '', jaren: '', punten: '' },
|
||||
errors: {},
|
||||
upload: initialUpload,
|
||||
},
|
||||
},
|
||||
};
|
||||
export const Step1Error: Story = {
|
||||
args: {
|
||||
seed: {
|
||||
tag: 'Editing',
|
||||
step: 1,
|
||||
draft: { uren: 'abc', jaren: '', punten: '' },
|
||||
errors: {
|
||||
uren: 'Vul een geheel aantal in (0 of meer).',
|
||||
jaren: 'Vul een geheel aantal in (0 of meer).',
|
||||
},
|
||||
upload: initialUpload,
|
||||
} satisfies WizardState,
|
||||
},
|
||||
};
|
||||
export const Step2: Story = {
|
||||
args: {
|
||||
seed: {
|
||||
tag: 'Editing',
|
||||
step: 2,
|
||||
draft: { uren: '4160', jaren: '5', punten: '' },
|
||||
errors: {},
|
||||
upload: initialUpload,
|
||||
},
|
||||
},
|
||||
};
|
||||
export const Step3: Story = {
|
||||
args: {
|
||||
seed: {
|
||||
tag: 'Editing',
|
||||
step: 3,
|
||||
draft: { uren: '4160', jaren: '5', punten: '200' },
|
||||
errors: {},
|
||||
upload: initialUpload,
|
||||
},
|
||||
},
|
||||
};
|
||||
export const Submitting: Story = { args: { seed: { tag: 'Submitting', data: validData } } };
|
||||
export const Submitted: Story = { args: { seed: { tag: 'Submitted', data: validData } } };
|
||||
export const Failed: Story = {
|
||||
args: { seed: { tag: 'Failed', data: validData, error: 'Netwerkfout' } },
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Component, computed, inject } from '@angular/core';
|
||||
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||
import { ASYNC } from '@shared/ui/async/async.component';
|
||||
import { map } from '@shared/application/remote-data';
|
||||
import { BigProfileStore } from '@registratie/application/big-profile.store';
|
||||
import { HerregistratieWizardComponent } from '@herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component';
|
||||
|
||||
/** A whole new page built from existing building blocks. Eligibility is a
|
||||
SERVER-computed decision read from the aggregated view — the frontend renders
|
||||
it, it does not recompute the rule. */
|
||||
@Component({
|
||||
selector: 'app-herregistratie-page',
|
||||
imports: [PageShellComponent, AlertComponent, ...ASYNC, HerregistratieWizardComponent],
|
||||
template: `
|
||||
<app-page-shell
|
||||
i18n-heading="@@herregistratie.heading"
|
||||
heading="Herregistratie aanvragen"
|
||||
backLink="/dashboard"
|
||||
>
|
||||
<app-async [data]="eligibility()">
|
||||
<ng-template appAsyncLoaded let-eligible>
|
||||
@if (eligible) {
|
||||
<app-alert type="info" i18n="@@herregistratie.eligible">
|
||||
Uw huidige registratie verloopt binnenkort. Vraag tijdig herregistratie aan.
|
||||
</app-alert>
|
||||
<div class="app-section">
|
||||
<app-herregistratie-wizard />
|
||||
</div>
|
||||
} @else {
|
||||
<app-alert type="warning" i18n="@@herregistratie.notEligible">
|
||||
Voor uw huidige registratiestatus is herregistratie niet mogelijk.
|
||||
</app-alert>
|
||||
}
|
||||
</ng-template>
|
||||
</app-async>
|
||||
</app-page-shell>
|
||||
`,
|
||||
})
|
||||
export class HerregistratiePage {
|
||||
private store = inject(BigProfileStore);
|
||||
// The eligibility decision comes from the server (decisions block), not a
|
||||
// client-side rule. The UI just reads the boolean.
|
||||
protected eligibility = computed(() =>
|
||||
map(this.store.decisions(), (d) => d.eligibleForHerregistratie),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { provideApiClient } from '@shared/infrastructure/api-client.provider';
|
||||
import { IntakeWizardComponent } from './intake-wizard.component';
|
||||
import { IntakeState } from '@herregistratie/domain/intake.machine';
|
||||
|
||||
// Regression: wizard steps must render their logical field groups as separate CIBG grey
|
||||
// <fieldset> blocks (`.form-horizontal fieldset` ⇒ #f1f5f9, 1.25em gap). If the fieldset
|
||||
// wrapping is dropped, the inputs revert to bare white. The buitenland step with
|
||||
// buitenlandGewerkt='ja' has two groups (the question + the land/uren follow-up), so it
|
||||
// must render ≥2 fieldsets, each holding a form-group.
|
||||
const buitenlandJa: IntakeState = {
|
||||
tag: 'Answering',
|
||||
answers: { buitenlandGewerkt: 'ja' },
|
||||
cursor: 0,
|
||||
errors: {},
|
||||
scholingThreshold: 1000,
|
||||
};
|
||||
|
||||
describe('IntakeWizardComponent', () => {
|
||||
it('renders each field group as its own grey <fieldset>', () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideHttpClient(), provideApiClient()],
|
||||
});
|
||||
const fixture = TestBed.createComponent(IntakeWizardComponent);
|
||||
fixture.componentInstance.dispatch({ tag: 'Seed', state: buitenlandJa });
|
||||
fixture.detectChanges();
|
||||
|
||||
const fieldsets: HTMLElement[] = Array.from(
|
||||
fixture.nativeElement.querySelectorAll('form.form-horizontal fieldset'),
|
||||
);
|
||||
expect(fieldsets.length).toBeGreaterThanOrEqual(2);
|
||||
fieldsets.forEach((fs) => expect(fs.querySelector('.form-group')).toBeTruthy());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,399 @@
|
||||
import { Component, computed, effect, inject, input, untracked } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
|
||||
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
|
||||
import { RadioGroupComponent, JA_NEE } from '@shared/ui/radio-group/radio-group.component';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
|
||||
import { ReviewSectionComponent } from '@shared/ui/review-section/review-section.component';
|
||||
import { ConfirmationComponent } from '@shared/ui/confirmation/confirmation.component';
|
||||
import {
|
||||
WizardShellComponent,
|
||||
WizardError,
|
||||
WizardStatus,
|
||||
naarStapLabel,
|
||||
} from '@shared/layout/wizard-shell/wizard-shell.component';
|
||||
import { createStore } from '@shared/application/store';
|
||||
import { whenTag } from '@shared/kernel/fp';
|
||||
import { BigProfileStore } from '@registratie/application/big-profile.store';
|
||||
import {
|
||||
IntakeState,
|
||||
IntakeMsg,
|
||||
Answers,
|
||||
StepId,
|
||||
initial,
|
||||
reduce,
|
||||
STEPS,
|
||||
lageUren,
|
||||
hasProgress,
|
||||
SCHOLING_THRESHOLD_DEFAULT,
|
||||
} from '@herregistratie/domain/intake.machine';
|
||||
import { createDraftSync } from '@registratie/application/draft-sync';
|
||||
import { IntakePolicyStore } from '@herregistratie/application/intake-policy.store';
|
||||
|
||||
/** Organism: a BRANCHING intake questionnaire. All state lives in one signal
|
||||
driven by the pure `reduce` (intake.machine.ts). Which step renders is derived
|
||||
from the answers via `visibleSteps`, never stored — so editing an earlier
|
||||
answer immediately changes the remaining steps. Answers are persisted to
|
||||
sessionStorage so a page reload keeps the user's progress (cleared on tab close). */
|
||||
@Component({
|
||||
selector: 'app-intake-wizard',
|
||||
imports: [
|
||||
FormsModule,
|
||||
FormFieldComponent,
|
||||
TextInputComponent,
|
||||
RadioGroupComponent,
|
||||
ButtonComponent,
|
||||
AlertComponent,
|
||||
DataRowComponent,
|
||||
ReviewSectionComponent,
|
||||
ConfirmationComponent,
|
||||
WizardShellComponent,
|
||||
],
|
||||
template: `
|
||||
<app-wizard-shell
|
||||
[steps]="stepLabels"
|
||||
[current]="cursor()"
|
||||
[stepTitle]="stepTitle()"
|
||||
i18n-processName="@@intake.processName"
|
||||
processName="Herregistratie-intake"
|
||||
[status]="shellStatus()"
|
||||
[primaryLabel]="primaryLabel()"
|
||||
[canGoBack]="cursor() > 0"
|
||||
[errors]="errorList()"
|
||||
[errorMessage]="errorMessage()"
|
||||
(primary)="onPrimary()"
|
||||
(back)="dispatch({ tag: 'Back' })"
|
||||
(cancel)="restart()"
|
||||
(retry)="onRetry()"
|
||||
(goToStep)="dispatch({ tag: 'GaNaarStap', cursor: $event })"
|
||||
>
|
||||
@switch (step()) {
|
||||
@case ('buitenland') {
|
||||
<fieldset>
|
||||
<app-form-field
|
||||
i18n-label="@@intake.q.buitenland"
|
||||
label="Heeft u de afgelopen 5 jaar buiten Nederland gewerkt?"
|
||||
fieldId="buitenlandGewerkt"
|
||||
required
|
||||
[error]="err('buitenlandGewerkt')"
|
||||
>
|
||||
<app-radio-group
|
||||
name="buitenlandGewerkt"
|
||||
[options]="jaNee"
|
||||
[ngModel]="answers().buitenlandGewerkt ?? ''"
|
||||
(ngModelChange)="set('buitenlandGewerkt', $event)"
|
||||
/>
|
||||
</app-form-field>
|
||||
</fieldset>
|
||||
@if (answers().buitenlandGewerkt === 'ja') {
|
||||
<fieldset>
|
||||
<app-form-field
|
||||
i18n-label="@@intake.q.land"
|
||||
label="In welk land?"
|
||||
fieldId="land"
|
||||
required
|
||||
[error]="err('land')"
|
||||
>
|
||||
<app-text-input
|
||||
inputId="land"
|
||||
[ngModel]="answers().land ?? ''"
|
||||
(ngModelChange)="set('land', $event)"
|
||||
name="land"
|
||||
i18n-placeholder="@@intake.q.landPlaceholder"
|
||||
placeholder="bijv. België"
|
||||
/>
|
||||
</app-form-field>
|
||||
<app-form-field
|
||||
i18n-label="@@intake.q.buitenlandseUren"
|
||||
label="Hoeveel uur heeft u daar gewerkt?"
|
||||
fieldId="buitenlandseUren"
|
||||
required
|
||||
[error]="err('buitenlandseUren')"
|
||||
>
|
||||
<app-text-input
|
||||
inputId="buitenlandseUren"
|
||||
[ngModel]="answers().buitenlandseUren ?? ''"
|
||||
(ngModelChange)="set('buitenlandseUren', $event)"
|
||||
name="buitenlandseUren"
|
||||
i18n-placeholder="@@intake.q.buitenlandseUrenPlaceholder"
|
||||
placeholder="bijv. 800"
|
||||
/>
|
||||
</app-form-field>
|
||||
</fieldset>
|
||||
}
|
||||
}
|
||||
@case ('werk') {
|
||||
<fieldset>
|
||||
<app-form-field
|
||||
i18n-label="@@intake.q.urenNl"
|
||||
label="Gewerkte uren in Nederland (afgelopen 5 jaar)"
|
||||
fieldId="uren"
|
||||
required
|
||||
[error]="err('uren')"
|
||||
>
|
||||
<app-text-input
|
||||
inputId="uren"
|
||||
[ngModel]="answers().uren ?? ''"
|
||||
(ngModelChange)="set('uren', $event)"
|
||||
name="uren"
|
||||
i18n-placeholder="@@intake.q.urenNlPlaceholder"
|
||||
placeholder="bijv. 4160"
|
||||
/>
|
||||
</app-form-field>
|
||||
</fieldset>
|
||||
@if (scholingZichtbaar()) {
|
||||
<fieldset>
|
||||
<app-form-field
|
||||
i18n-label="@@intake.q.scholing"
|
||||
label="U werkte relatief weinig uren. Heeft u aanvullende scholing gevolgd?"
|
||||
fieldId="scholingGevolgd"
|
||||
required
|
||||
[error]="err('scholingGevolgd')"
|
||||
>
|
||||
<app-radio-group
|
||||
name="scholingGevolgd"
|
||||
[options]="jaNee"
|
||||
[ngModel]="answers().scholingGevolgd ?? ''"
|
||||
(ngModelChange)="set('scholingGevolgd', $event)"
|
||||
/>
|
||||
</app-form-field>
|
||||
</fieldset>
|
||||
}
|
||||
@if (answers().scholingGevolgd === 'ja') {
|
||||
<fieldset>
|
||||
<app-form-field
|
||||
i18n-label="@@intake.q.punten"
|
||||
label="Behaalde nascholingspunten"
|
||||
fieldId="punten"
|
||||
required
|
||||
[error]="err('punten')"
|
||||
>
|
||||
<app-text-input
|
||||
inputId="punten"
|
||||
[ngModel]="answers().punten ?? ''"
|
||||
(ngModelChange)="set('punten', $event)"
|
||||
name="punten"
|
||||
i18n-placeholder="@@intake.q.puntenPlaceholder"
|
||||
placeholder="bijv. 200"
|
||||
/>
|
||||
</app-form-field>
|
||||
</fieldset>
|
||||
}
|
||||
}
|
||||
@case ('review') {
|
||||
<app-alert type="info" i18n="@@intake.review.controleer"
|
||||
>Controleer uw antwoorden en dien de aanvraag in.</app-alert
|
||||
>
|
||||
<app-review-section
|
||||
i18n-heading="@@intake.sectie.buitenland"
|
||||
heading="Buitenland"
|
||||
i18n-editAriaLabel="@@intake.buitenlandWijzigenAria"
|
||||
editAriaLabel="Wijzigen buitenland"
|
||||
(edit)="dispatch({ tag: 'GaNaarStap', cursor: 0 })"
|
||||
>
|
||||
<div
|
||||
app-data-row
|
||||
i18n-key="@@intake.review.buitenNl"
|
||||
key="Buiten NL gewerkt"
|
||||
[value]="answers().buitenlandGewerkt ?? '—'"
|
||||
></div>
|
||||
@if (answers().buitenlandGewerkt === 'ja') {
|
||||
<div
|
||||
app-data-row
|
||||
i18n-key="@@intake.review.land"
|
||||
key="Land"
|
||||
[value]="answers().land ?? ''"
|
||||
></div>
|
||||
<div
|
||||
app-data-row
|
||||
i18n-key="@@intake.review.buitenlandseUren"
|
||||
key="Buitenlandse uren"
|
||||
[value]="answers().buitenlandseUren ?? ''"
|
||||
></div>
|
||||
}
|
||||
</app-review-section>
|
||||
<app-review-section
|
||||
class="app-section"
|
||||
i18n-heading="@@intake.sectie.werk"
|
||||
heading="Werk in Nederland"
|
||||
i18n-editAriaLabel="@@intake.werkWijzigenAria"
|
||||
editAriaLabel="Wijzigen werk in Nederland"
|
||||
(edit)="dispatch({ tag: 'GaNaarStap', cursor: 1 })"
|
||||
>
|
||||
<div
|
||||
app-data-row
|
||||
i18n-key="@@intake.review.urenNl"
|
||||
key="Uren NL"
|
||||
[value]="answers().uren ?? ''"
|
||||
></div>
|
||||
@if (scholingZichtbaar()) {
|
||||
<div
|
||||
app-data-row
|
||||
i18n-key="@@intake.review.scholing"
|
||||
key="Aanvullende scholing"
|
||||
[value]="answers().scholingGevolgd ?? ''"
|
||||
></div>
|
||||
}
|
||||
@if (answers().scholingGevolgd === 'ja') {
|
||||
<div
|
||||
app-data-row
|
||||
i18n-key="@@intake.review.punten"
|
||||
key="Nascholingspunten"
|
||||
[value]="answers().punten ?? ''"
|
||||
></div>
|
||||
}
|
||||
</app-review-section>
|
||||
}
|
||||
}
|
||||
|
||||
<div wizardSuccess>
|
||||
<app-confirmation
|
||||
i18n-title="@@intake.success.title"
|
||||
title="Uw aanvraag tot herregistratie is ontvangen"
|
||||
>
|
||||
<div class="app-section">
|
||||
<app-button variant="secondary" (click)="restart()" i18n="@@intake.opnieuw"
|
||||
>Opnieuw beginnen</app-button
|
||||
>
|
||||
</div>
|
||||
</app-confirmation>
|
||||
</div>
|
||||
</app-wizard-shell>
|
||||
`,
|
||||
})
|
||||
export class IntakeWizardComponent {
|
||||
private profile = inject(BigProfileStore);
|
||||
// Server-owned policy (scholing threshold): fetched from the backend via the
|
||||
// application facade, not hardcoded. The backend stays the authority on submit.
|
||||
private policyStore = inject(IntakePolicyStore);
|
||||
private store = createStore<IntakeState, IntakeMsg>(initial, reduce);
|
||||
|
||||
/** Optional seed so Storybook / the showcase can mount any state directly. */
|
||||
seed = input<IntakeState>(initial);
|
||||
|
||||
readonly jaNee = JA_NEE;
|
||||
readonly state = this.store.model;
|
||||
readonly dispatch = this.store.dispatch;
|
||||
|
||||
// Backend draft-sync (replaces sessionStorage); the intake has no uploads.
|
||||
private draftSync = createDraftSync({
|
||||
type: 'intake',
|
||||
snapshot: () => {
|
||||
const s = this.state();
|
||||
if (s.tag !== 'Answering' || !hasProgress(s)) return null;
|
||||
return { draft: s, stepIndex: s.cursor, stepCount: STEPS.length, documentIds: [] };
|
||||
},
|
||||
onResume: (draft) => this.dispatch({ tag: 'Seed', state: draft as IntakeState }),
|
||||
enabled: () => this.seed() === initial,
|
||||
});
|
||||
|
||||
private answering = computed(() => whenTag(this.state(), 'Answering'));
|
||||
/** Public so the showcase can render the (fixed) step list next to the wizard. */
|
||||
readonly steps = STEPS;
|
||||
protected cursor = computed(() => this.answering()?.cursor ?? 0);
|
||||
protected answers = computed<Answers>(() => this.answering()?.answers ?? {});
|
||||
protected step = computed<StepId>(() => STEPS[Math.min(this.cursor(), STEPS.length - 1)]);
|
||||
/** Server-owned threshold from the policy endpoint (mirrored into machine state). */
|
||||
protected scholingThreshold = computed(
|
||||
() => this.answering()?.scholingThreshold ?? SCHOLING_THRESHOLD_DEFAULT,
|
||||
);
|
||||
/** Whether the inline scholing question is shown (and required) in the 'werk' step. */
|
||||
protected scholingZichtbaar = computed(() => lageUren(this.answers(), this.scholingThreshold()));
|
||||
protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? '');
|
||||
|
||||
// --- Presentational wiring for the shared wizard shell ---------------------
|
||||
readonly stepLabels = [
|
||||
$localize`:@@intake.step.buitenland:Buitenland`,
|
||||
$localize`:@@intake.step.werk:Werk`,
|
||||
$localize`:@@intake.step.controle:Controle`,
|
||||
];
|
||||
private stepTitles: Record<StepId, string> = {
|
||||
buitenland: $localize`:@@intake.title.buitenland:Werken in het buitenland`,
|
||||
werk: $localize`:@@intake.title.werk:Werkervaring in Nederland`,
|
||||
review: $localize`:@@intake.title.review:Controleren en indienen`,
|
||||
};
|
||||
protected stepTitle = computed(() => this.stepTitles[this.step()]);
|
||||
protected primaryLabel = computed(() => {
|
||||
if (this.step() === 'review') return $localize`:@@intake.indienen:Aanvraag indienen`;
|
||||
const next = this.cursor() + 1;
|
||||
return naarStapLabel(next + 1, this.stepLabels[next]);
|
||||
});
|
||||
protected errorMessage = computed(
|
||||
() => $localize`:@@wizard.indienenMislukt:Indienen mislukt:` + ` ${this.failedError()}`,
|
||||
);
|
||||
protected shellStatus = computed<WizardStatus>(() => {
|
||||
switch (this.state().tag) {
|
||||
case 'Answering':
|
||||
return 'editing';
|
||||
case 'Submitting':
|
||||
return 'submitting';
|
||||
case 'Submitted':
|
||||
return 'submitted';
|
||||
case 'Failed':
|
||||
return 'failed';
|
||||
}
|
||||
});
|
||||
/** Current step's field errors, flattened for the shell's error summary. The
|
||||
field ids match the answer keys, so the summary anchors jump to the field. */
|
||||
protected errorList = computed<WizardError[]>(() => {
|
||||
const e = this.answering()?.errors ?? {};
|
||||
return (Object.keys(e) as (keyof Answers)[])
|
||||
.filter((k) => e[k])
|
||||
.map((k) => ({ id: k, message: e[k]! }));
|
||||
});
|
||||
|
||||
protected err = (k: keyof Answers) => this.answering()?.errors[k] ?? '';
|
||||
protected set = (key: keyof Answers, value: string) =>
|
||||
this.dispatch({ tag: 'SetAnswer', key, value });
|
||||
|
||||
constructor() {
|
||||
// An explicit seed (stories/tests) wins; otherwise resume the backend draft
|
||||
// (`?aanvraag=<id>`) or start fresh. Persistence is the draftSync controller's job.
|
||||
const seeded = this.seed();
|
||||
queueMicrotask(() =>
|
||||
seeded !== initial ? this.dispatch({ tag: 'Seed', state: seeded }) : this.draftSync.resume(),
|
||||
);
|
||||
// Apply the server-owned threshold into machine state as it arrives. Track
|
||||
// only the policy value; untrack the dispatch (it reads the state signal
|
||||
// internally, which would otherwise make this effect loop on its own write).
|
||||
effect(() => {
|
||||
const scholingThreshold = this.policyStore.scholingThreshold();
|
||||
untracked(() => this.dispatch({ tag: 'SetPolicy', scholingThreshold }));
|
||||
});
|
||||
}
|
||||
|
||||
onPrimary() {
|
||||
const s = this.state();
|
||||
if (s.tag !== 'Answering') return;
|
||||
this.dispatch(this.step() === 'review' ? { tag: 'Submit' } : { tag: 'Next' });
|
||||
this.runIfSubmitting();
|
||||
}
|
||||
|
||||
onRetry() {
|
||||
this.dispatch({ tag: 'Retry' });
|
||||
this.runIfSubmitting();
|
||||
}
|
||||
|
||||
restart() {
|
||||
this.draftSync.reset();
|
||||
this.dispatch({ tag: 'Seed', state: initial });
|
||||
}
|
||||
|
||||
/** The effect: when we enter Submitting, submit through the aanvraag lifecycle,
|
||||
flip the optimistic cross-page flag, then dispatch the outcome (commit/rollback). */
|
||||
private async runIfSubmitting() {
|
||||
const s = this.state();
|
||||
if (s.tag !== 'Submitting') return;
|
||||
this.profile.beginHerregistratie();
|
||||
const r = await this.draftSync.submit({ uren: s.data.uren });
|
||||
if (r.ok) {
|
||||
this.dispatch({ tag: 'SubmitConfirmed' });
|
||||
this.profile.confirmHerregistratie();
|
||||
} else {
|
||||
this.dispatch({ tag: 'SubmitFailed', error: r.error });
|
||||
this.profile.rollbackHerregistratie();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { applicationConfig } from '@storybook/angular';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideApiClient } from '@shared/infrastructure/api-client.provider';
|
||||
import { IntakeWizardComponent } from './intake-wizard.component';
|
||||
import { IntakeState, Answers } from '@herregistratie/domain/intake.machine';
|
||||
import { Uren } from '@registratie/domain/value-objects/uren';
|
||||
|
||||
const validData = { werktBuitenland: false, uren: 4160 as Uren, punten: 200 as Uren };
|
||||
|
||||
const meta: Meta<IntakeWizardComponent> = {
|
||||
title: 'Domein/Herregistratie/IntakeWizard',
|
||||
component: IntakeWizardComponent,
|
||||
// Injects BigProfileStore (optimistic flag) which creates httpResources.
|
||||
decorators: [applicationConfig({ providers: [provideHttpClient(), provideApiClient()] })],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<IntakeWizardComponent>;
|
||||
|
||||
const answering = (answers: Answers, cursor = 0): IntakeState => ({
|
||||
tag: 'Answering',
|
||||
answers,
|
||||
cursor,
|
||||
errors: {},
|
||||
scholingThreshold: 1000,
|
||||
});
|
||||
|
||||
export const Start: Story = { args: { seed: answering({}) } };
|
||||
// Inline reveal: country/hours appear within the buitenland step (cursor 0).
|
||||
export const AbroadBranch: Story = { args: { seed: answering({ buitenlandGewerkt: 'ja' }, 0) } };
|
||||
// Inline reveal: the scholing question appears within the werk step (cursor 1).
|
||||
export const LowHoursScholing: Story = {
|
||||
args: { seed: answering({ buitenlandGewerkt: 'nee', uren: '500' }, 1) },
|
||||
};
|
||||
export const Review: Story = {
|
||||
args: { seed: answering({ buitenlandGewerkt: 'nee', uren: '4160', punten: '200' }, 2) },
|
||||
};
|
||||
export const Submitting: Story = { args: { seed: { tag: 'Submitting', data: validData } } };
|
||||
export const Submitted: Story = { args: { seed: { tag: 'Submitted', data: validData } } };
|
||||
export const Failed: Story = {
|
||||
args: { seed: { tag: 'Failed', data: validData, error: 'Netwerkfout' } },
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||
import { IntakeWizardComponent } from '@herregistratie/ui/intake-wizard/intake-wizard.component';
|
||||
|
||||
/** Page: the branching intake questionnaire. Built entirely from existing
|
||||
building blocks (page shell + alert + the intake-wizard organism). */
|
||||
@Component({
|
||||
selector: 'app-intake-page',
|
||||
imports: [PageShellComponent, AlertComponent, IntakeWizardComponent],
|
||||
template: `
|
||||
<app-page-shell
|
||||
i18n-heading="@@intake.heading"
|
||||
heading="Herregistratie — intake"
|
||||
backLink="/dashboard"
|
||||
>
|
||||
<app-alert type="info" i18n="@@intake.intro">
|
||||
Een paar vragen bepalen welke gegevens we nodig hebben. Afhankelijk van uw antwoorden
|
||||
verschijnen er extra vragen. Uw antwoorden blijven bewaard als u de pagina herlaadt.
|
||||
</app-alert>
|
||||
<div class="app-section">
|
||||
<app-intake-wizard />
|
||||
</div>
|
||||
</app-page-shell>
|
||||
`,
|
||||
})
|
||||
export class IntakePage {}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { ApplicationsAdapter } from '@registratie/infrastructure/applications.adapter';
|
||||
import { AdminCasesStore } from './admin-cases.store';
|
||||
|
||||
const summary = (id: string) => ({
|
||||
id,
|
||||
type: 'registratie',
|
||||
status: { tag: 'Concept', stepIndex: 0, stepCount: 3 },
|
||||
documentIds: [],
|
||||
createdAt: '2026-07-23T10:00:00Z',
|
||||
updatedAt: '2026-07-23T10:00:00Z',
|
||||
owner: '19012345601',
|
||||
});
|
||||
|
||||
function setup(adapter: Partial<ApplicationsAdapter>): AdminCasesStore {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [{ provide: ApplicationsAdapter, useValue: adapter }],
|
||||
});
|
||||
return TestBed.inject(AdminCasesStore);
|
||||
}
|
||||
|
||||
describe('AdminCasesStore', () => {
|
||||
it('loads and parses the cross-owner list', async () => {
|
||||
const store = setup({ listAll: () => Promise.resolve([summary('a'), summary('b')]) });
|
||||
await store.load();
|
||||
const s = store.cases();
|
||||
expect(s.tag).toBe('Success');
|
||||
expect(s.tag === 'Success' && s.value.map((c) => c.id)).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('deletes optimistically and confirms via the admin endpoint', async () => {
|
||||
const deleteAny = vi.fn().mockResolvedValue(undefined);
|
||||
const store = setup({
|
||||
listAll: () => Promise.resolve([summary('a'), summary('b')]),
|
||||
deleteAny,
|
||||
});
|
||||
await store.load();
|
||||
|
||||
await store.delete('a');
|
||||
expect(deleteAny).toHaveBeenCalledWith('a');
|
||||
const s = store.cases();
|
||||
expect(s.tag === 'Success' && s.value.map((c) => c.id)).toEqual(['b']);
|
||||
});
|
||||
|
||||
it('rolls back the removal when the delete fails', async () => {
|
||||
const deleteAny = vi.fn().mockRejectedValue(new Error('boom'));
|
||||
const store = setup({ listAll: () => Promise.resolve([summary('a')]), deleteAny });
|
||||
await store.load();
|
||||
|
||||
await store.delete('a');
|
||||
const s = store.cases();
|
||||
expect(s.tag === 'Success' && s.value.map((c) => c.id)).toEqual(['a']); // reappears
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import { RemoteData } from '@shared/application/remote-data';
|
||||
import { Aanvraag } from '@registratie/domain/aanvraag';
|
||||
import {
|
||||
ApplicationsAdapter,
|
||||
parseApplications,
|
||||
} from '@registratie/infrastructure/applications.adapter';
|
||||
|
||||
type Err = Error | undefined;
|
||||
|
||||
/**
|
||||
* Admin view of ALL cases across owners (WP-36; `cases:manage`) — the back-office
|
||||
* counterpart of the user-facing `ApplicationsStore`. Same shape: one root singleton
|
||||
* owns the list as a writable RemoteData signal, delete removes the row synchronously
|
||||
* (optimistic) and rolls back on error. Admin delete removes any case (any owner,
|
||||
* submitted or not — the server enforces the capability).
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AdminCasesStore {
|
||||
private adapter = inject(ApplicationsAdapter);
|
||||
|
||||
private state = signal<RemoteData<Err, Aanvraag[]>>({ tag: 'Loading' });
|
||||
readonly cases = this.state.asReadonly();
|
||||
|
||||
/** Fetch + parse at the trust boundary, then publish as RemoteData. Keeps the
|
||||
last-good value on a resync (only shows Loading on the first load). */
|
||||
async load() {
|
||||
if (this.state().tag !== 'Success') this.state.set({ tag: 'Loading' });
|
||||
try {
|
||||
const parsed = parseApplications(await this.adapter.listAll());
|
||||
this.state.set(
|
||||
parsed.ok
|
||||
? { tag: 'Success', value: parsed.value }
|
||||
: { tag: 'Failure', error: new Error(parsed.error) },
|
||||
);
|
||||
} catch (e) {
|
||||
this.state.set({ tag: 'Failure', error: e as Error });
|
||||
}
|
||||
}
|
||||
|
||||
reload() {
|
||||
void this.load();
|
||||
}
|
||||
|
||||
/** Delete a case: drop it now (synchronous), then confirm the DELETE; roll back on error. */
|
||||
async delete(id: string) {
|
||||
const before = this.state();
|
||||
if (before.tag === 'Success') {
|
||||
this.state.set({ tag: 'Success', value: before.value.filter((a) => a.id !== id) });
|
||||
}
|
||||
try {
|
||||
await this.adapter.deleteAny(id);
|
||||
} catch {
|
||||
this.state.set(before); // roll back: the row reappears
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import { RemoteData } from '@shared/application/remote-data';
|
||||
import { Aanvraag } from '@registratie/domain/aanvraag';
|
||||
import {
|
||||
ApplicationsAdapter,
|
||||
parseApplications,
|
||||
} from '@registratie/infrastructure/applications.adapter';
|
||||
|
||||
type Err = Error | undefined;
|
||||
|
||||
/**
|
||||
* The dashboard's view of the user's applications (aanvragen) — the backend is the
|
||||
* system of record (PRD 0001). One root singleton OWNS the list as a writable
|
||||
* RemoteData signal (CLAUDE.md §3: change state only through methods). Cancel removes
|
||||
* the row SYNCHRONOUSLY, so the block disappears deterministically — no dependence on
|
||||
* change-detection timing, HTTP caching, or a resource `reload()`. `reload()` re-fetches
|
||||
* so a page revisit reflects auto-approval (Concept → In behandeling → Goedgekeurd is
|
||||
* computed server-side on read).
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ApplicationsStore {
|
||||
private adapter = inject(ApplicationsAdapter);
|
||||
|
||||
private state = signal<RemoteData<Err, Aanvraag[]>>({ tag: 'Loading' });
|
||||
readonly applications = this.state.asReadonly();
|
||||
|
||||
constructor() {
|
||||
void this.load();
|
||||
}
|
||||
|
||||
/** Fetch + parse at the trust boundary, then publish as RemoteData. Keeps the
|
||||
last-good value on a resync (only shows Loading on the first load). */
|
||||
async load() {
|
||||
if (this.state().tag !== 'Success') this.state.set({ tag: 'Loading' });
|
||||
try {
|
||||
const parsed = parseApplications(await this.adapter.list());
|
||||
this.state.set(
|
||||
parsed.ok
|
||||
? { tag: 'Success', value: parsed.value }
|
||||
: { tag: 'Failure', error: new Error(parsed.error) },
|
||||
);
|
||||
} catch (e) {
|
||||
this.state.set({ tag: 'Failure', error: e as Error });
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-fetch (e.g. on dashboard revisit) so auto-approval transitions show up. */
|
||||
reload() {
|
||||
void this.load();
|
||||
}
|
||||
|
||||
/** Cancel a Concept: drop it now (synchronous, guaranteed), then confirm the DELETE.
|
||||
No resync — the delete succeeded, so the optimistic removal is authoritative. */
|
||||
async cancel(id: string) {
|
||||
const before = this.state();
|
||||
if (before.tag === 'Success') {
|
||||
this.state.set({ tag: 'Success', value: before.value.filter((a) => a.id !== id) });
|
||||
}
|
||||
try {
|
||||
await this.adapter.cancel(id);
|
||||
} catch {
|
||||
this.state.set(before); // roll back: the block reappears
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
import { RemoteData, fromResource, map } from '@shared/application/remote-data';
|
||||
import { Aantekening } from '../domain/registration';
|
||||
import { BigProfile } from '../domain/big-profile';
|
||||
import { HerregistratieDecisions } from '../contracts/dashboard-view.dto';
|
||||
import { BigRegisterAdapter } from '../infrastructure/big-register.adapter';
|
||||
import {
|
||||
DashboardView,
|
||||
DashboardViewAdapter,
|
||||
parseDashboardView,
|
||||
} from '../infrastructure/dashboard-view.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 dashboard data now comes from ONE screen-shaped ("BFF-lite") call that
|
||||
* returns registration + person + server-computed `decisions`. One request → one
|
||||
* consistent snapshot, instead of stitching three independently loading/erroring
|
||||
* resources together client-side. See docs/reference/architecture/0001-bff-lite-decision-dtos.md.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class BigProfileStore {
|
||||
private big = inject(BigRegisterAdapter);
|
||||
private viewAdapter = inject(DashboardViewAdapter);
|
||||
|
||||
private viewRes = this.viewAdapter.dashboardViewResource();
|
||||
private aantekeningenRes = this.big.aantekeningenResource();
|
||||
|
||||
/** The aggregated view, validated at the trust boundary (DTO → domain). */
|
||||
private view = computed<RemoteData<Err, DashboardView>>(() => {
|
||||
const rd = fromResource(this.viewRes);
|
||||
if (rd.tag !== 'Success') return rd;
|
||||
const parsed = parseDashboardView(rd.value);
|
||||
return parsed.ok
|
||||
? { tag: 'Success', value: parsed.value }
|
||||
: { tag: 'Failure', error: new Error(parsed.error) };
|
||||
});
|
||||
|
||||
/** Registration + person, from the single aggregated call. */
|
||||
readonly profile = computed<RemoteData<Err, BigProfile>>(() =>
|
||||
map(this.view(), (v) => v.profile),
|
||||
);
|
||||
|
||||
/** Server-computed decisions (e.g. herregistratie eligibility) — rendered, not recomputed. */
|
||||
readonly decisions = computed<RemoteData<Err, HerregistratieDecisions>>(() =>
|
||||
map(this.view(), (v) => v.decisions),
|
||||
);
|
||||
|
||||
/** Specialisms/notes stay a separate stream (they have their own empty state). */
|
||||
readonly aantekeningen = computed<RemoteData<Err, Aantekening[]>>(() => {
|
||||
const rd = fromResource(this.aantekeningenRes, (v) => !v || v.length === 0);
|
||||
return rd.tag === 'Success' ? { tag: 'Success', value: rd.value ?? [] } : rd;
|
||||
});
|
||||
|
||||
// --- 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.viewRes.reload(); // invalidate: re-fetch the now-updated view (registration + decisions)
|
||||
}
|
||||
rollbackHerregistratie() {
|
||||
this.pending.set(false); // submission failed — undo the optimistic flag
|
||||
}
|
||||
|
||||
// Retry hooks for [data]-fed <app-async> instances (they don't own the resource).
|
||||
reloadProfile() {
|
||||
this.viewRes.reload();
|
||||
}
|
||||
reloadAantekeningen() {
|
||||
this.aantekeningenRes.reload();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { ApplicationRef, signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { ApplicationsAdapter } from '@registratie/infrastructure/applications.adapter';
|
||||
import { createDraftSync, DraftSnapshot } from './draft-sync';
|
||||
|
||||
function setup(adapter: Partial<ApplicationsAdapter>) {
|
||||
const navigate = vi.fn().mockResolvedValue(true);
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: ApplicationsAdapter, useValue: adapter },
|
||||
{ provide: Router, useValue: { navigate } },
|
||||
{ provide: ActivatedRoute, useValue: { snapshot: { queryParamMap: { get: () => null } } } },
|
||||
],
|
||||
});
|
||||
const snap = signal<DraftSnapshot | null>(null);
|
||||
const onResume = vi.fn();
|
||||
const draftSync = TestBed.runInInjectionContext(() =>
|
||||
createDraftSync({
|
||||
type: 'registratie',
|
||||
snapshot: () => snap(),
|
||||
onResume,
|
||||
enabled: () => true,
|
||||
}),
|
||||
);
|
||||
TestBed.inject(ApplicationRef).tick(); // flush the effect's initial run
|
||||
return { draftSync, snap, navigate, onResume };
|
||||
}
|
||||
|
||||
const tick = () => TestBed.inject(ApplicationRef).tick();
|
||||
|
||||
describe('createDraftSync', () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('coalesces rapid snapshot changes into ONE debounced sync of the latest value', async () => {
|
||||
const create = vi.fn().mockResolvedValue('a1');
|
||||
const syncDraft = vi.fn().mockResolvedValue(undefined);
|
||||
const { snap } = setup({ create, syncDraft });
|
||||
|
||||
snap.set({ draft: { step: 1 }, stepIndex: 0, stepCount: 3, documentIds: [] });
|
||||
tick();
|
||||
snap.set({ draft: { step: 1, x: 'a' }, stepIndex: 0, stepCount: 3, documentIds: [] });
|
||||
tick();
|
||||
snap.set({ draft: { step: 1, x: 'ab' }, stepIndex: 0, stepCount: 3, documentIds: [] });
|
||||
tick();
|
||||
|
||||
// still inside the 600ms debounce window — nothing has synced yet
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(syncDraft).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
expect(create).toHaveBeenCalledTimes(1); // one Concept created, not three
|
||||
expect(syncDraft).toHaveBeenCalledTimes(1); // one sync, not three
|
||||
expect(syncDraft).toHaveBeenCalledWith(
|
||||
'a1',
|
||||
expect.objectContaining({ draft: { step: 1, x: 'ab' } }), // the LAST snapshot wins
|
||||
);
|
||||
});
|
||||
|
||||
it('a trailing change after the debounce fires schedules its own sync', async () => {
|
||||
const create = vi.fn().mockResolvedValue('a1');
|
||||
const syncDraft = vi.fn().mockResolvedValue(undefined);
|
||||
const { snap } = setup({ create, syncDraft });
|
||||
|
||||
snap.set({ draft: { step: 1 }, stepIndex: 0, stepCount: 3, documentIds: [] });
|
||||
tick();
|
||||
await vi.advanceTimersByTimeAsync(600);
|
||||
expect(syncDraft).toHaveBeenCalledTimes(1);
|
||||
|
||||
snap.set({ draft: { step: 2 }, stepIndex: 1, stepCount: 3, documentIds: [] });
|
||||
tick();
|
||||
await vi.advanceTimersByTimeAsync(600);
|
||||
expect(syncDraft).toHaveBeenCalledTimes(2);
|
||||
expect(syncDraft).toHaveBeenLastCalledWith('a1', expect.objectContaining({ stepIndex: 1 }));
|
||||
});
|
||||
|
||||
describe('submit', () => {
|
||||
it('resolves ok with the server response on success', async () => {
|
||||
const create = vi.fn().mockResolvedValue('a1');
|
||||
const submit = vi.fn().mockResolvedValue({ id: 'a1', autoApprovable: true });
|
||||
const { draftSync } = setup({ create, submit });
|
||||
|
||||
const r = await draftSync.submit({});
|
||||
expect(r).toEqual({ ok: true, value: { id: 'a1', autoApprovable: true } });
|
||||
expect(submit).toHaveBeenCalledWith('a1', {});
|
||||
});
|
||||
|
||||
it('folds a rejected submit into a Result error, never throwing', async () => {
|
||||
const create = vi.fn().mockResolvedValue('a1');
|
||||
const submit = vi.fn().mockRejectedValue(new Error('boom'));
|
||||
const { draftSync } = setup({ create, submit });
|
||||
|
||||
const r = await draftSync.submit({});
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('recovers from a create conflict by adopting the existing Concept (WP-35)', async () => {
|
||||
// Server enforces one Concept per type: a stale/cross-tab create is rejected (409),
|
||||
// and ensureId adopts the existing Concept from the list instead of erroring.
|
||||
const create = vi.fn().mockRejectedValue({ status: 409 });
|
||||
const list = vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: 'existing-1',
|
||||
type: 'registratie',
|
||||
status: { tag: 'Concept', stepIndex: 1, stepCount: 3 },
|
||||
createdAt: '2026-07-23T10:00:00Z',
|
||||
updatedAt: '2026-07-23T10:00:00Z',
|
||||
},
|
||||
]);
|
||||
const submit = vi.fn().mockResolvedValue({ id: 'existing-1', autoApprovable: true });
|
||||
const { draftSync } = setup({ create, list, submit });
|
||||
|
||||
const r = await draftSync.submit({});
|
||||
expect(r.ok).toBe(true);
|
||||
expect(submit).toHaveBeenCalledWith('existing-1', {}); // adopted, not a new id
|
||||
});
|
||||
});
|
||||
|
||||
describe('flushPending (CanDeactivate guard / beforeunload)', () => {
|
||||
it('hasPendingSave reflects an armed debounce timer', () => {
|
||||
const { draftSync, snap } = setup({
|
||||
create: vi.fn().mockResolvedValue('a1'),
|
||||
syncDraft: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
expect(draftSync.hasPendingSave()).toBe(false);
|
||||
|
||||
snap.set({ draft: { step: 1 }, stepIndex: 0, stepCount: 3, documentIds: [] });
|
||||
tick(); // the effect arms the 600ms timer
|
||||
expect(draftSync.hasPendingSave()).toBe(true);
|
||||
});
|
||||
|
||||
it('flushPending writes the pending draft immediately, before the debounce fires', async () => {
|
||||
const create = vi.fn().mockResolvedValue('a1');
|
||||
const syncDraft = vi.fn().mockResolvedValue(undefined);
|
||||
const { draftSync, snap } = setup({ create, syncDraft });
|
||||
|
||||
snap.set({ draft: { step: 1 }, stepIndex: 0, stepCount: 3, documentIds: [] });
|
||||
tick();
|
||||
await draftSync.flushPending();
|
||||
|
||||
expect(syncDraft).toHaveBeenCalledTimes(1); // no timer advance needed
|
||||
expect(draftSync.hasPendingSave()).toBe(false); // timer consumed
|
||||
});
|
||||
|
||||
it('flushPending is a no-op when nothing is pending', async () => {
|
||||
const syncDraft = vi.fn().mockResolvedValue(undefined);
|
||||
const { draftSync } = setup({ create: vi.fn().mockResolvedValue('a1'), syncDraft });
|
||||
|
||||
await draftSync.flushPending();
|
||||
expect(syncDraft).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,236 @@
|
||||
import { DestroyRef, effect, inject } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { Result } from '@shared/kernel/fp';
|
||||
import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit';
|
||||
import { registerPendingSave } from '@shared/application/pending-saves';
|
||||
import type {
|
||||
SubmitApplicationRequest,
|
||||
SubmitApplicationResponse,
|
||||
} from '@shared/infrastructure/api-client';
|
||||
import { AanvraagType } from '@registratie/domain/aanvraag';
|
||||
import {
|
||||
ApplicationsAdapter,
|
||||
parseApplications,
|
||||
} from '@registratie/infrastructure/applications.adapter';
|
||||
|
||||
/** What a wizard persists per step: the opaque machine snapshot + progress + docs. */
|
||||
export interface DraftSnapshot {
|
||||
draft: unknown;
|
||||
stepIndex: number;
|
||||
stepCount: number;
|
||||
documentIds: string[];
|
||||
}
|
||||
|
||||
export interface DraftSyncDeps {
|
||||
type: AanvraagType;
|
||||
/** The machine snapshot while it's worth persisting; null when not (pristine/done). */
|
||||
snapshot: () => DraftSnapshot | null;
|
||||
/** Seed the machine from a resumed draft. Called at most once, on init, and ONLY
|
||||
with a real draft on a still-pristine machine — see `applyResume`. */
|
||||
onResume: (draft: unknown) => void;
|
||||
/** Draft-sync only runs in the real app — false in Storybook/tests (explicit seed). */
|
||||
enabled: () => boolean;
|
||||
}
|
||||
|
||||
const DEBOUNCE_MS = 600; // ponytail: fixed debounce; tune if the sync feels laggy/chatty.
|
||||
|
||||
/**
|
||||
* The effectful glue that replaces per-wizard sessionStorage with a backend-owned
|
||||
* Concept (PRD 0001, phase D). Instantiated in a field initializer (like
|
||||
* `createStore`/`createUploadController`). Responsibilities:
|
||||
*
|
||||
* - resume: a `?aanvraag=<id>` link wins; otherwise resume the ONE existing Concept of
|
||||
* this type (at most one per type), seeding the machine from its saved draft;
|
||||
* - create-on-first-progress: when no Concept exists, one is created lazily the first
|
||||
* time the wizard reports a non-null snapshot, and its id is stamped into the URL;
|
||||
* - debounced draft sync on every subsequent change.
|
||||
*
|
||||
* Inert without a Router (stories) or when `enabled()` is false — no network, no resume.
|
||||
*/
|
||||
export function createDraftSync(deps: DraftSyncDeps) {
|
||||
const adapter = inject(ApplicationsAdapter);
|
||||
const router = inject(Router, { optional: true });
|
||||
const route = inject(ActivatedRoute, { optional: true });
|
||||
const active = () => deps.enabled() && !!router && !!route;
|
||||
|
||||
let id: string | undefined;
|
||||
let ensuring: Promise<string> | undefined; // in-flight create, so we never create twice
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
// Resolves once resume() has decided whether a Concept of this type already exists;
|
||||
// gates ensureId so a fast typist can't create a duplicate before that lookup lands.
|
||||
let resumeGate: Promise<unknown> = Promise.resolve();
|
||||
|
||||
const ensureId = async (): Promise<string> => {
|
||||
await resumeGate;
|
||||
if (id) return id;
|
||||
ensuring ??= adapter
|
||||
.create(deps.type)
|
||||
// WP-35: one Concept per type is server-enforced. Within a tab the resumeGate
|
||||
// already prevents a second create, but a cross-tab/stale race can still hit the
|
||||
// server's guard (409) — recover by adopting the existing Concept instead of
|
||||
// erroring. Only recover when one actually exists; otherwise surface the failure.
|
||||
.catch(async (e) => {
|
||||
const existing = await findConcept();
|
||||
if (existing) return existing;
|
||||
throw e;
|
||||
})
|
||||
.then((newId) => {
|
||||
id = newId;
|
||||
// Stamp the id into the URL (no navigation) so a reload resumes this Concept.
|
||||
void router!.navigate([], {
|
||||
relativeTo: route!,
|
||||
queryParams: { aanvraag: newId },
|
||||
queryParamsHandling: 'merge',
|
||||
replaceUrl: true,
|
||||
});
|
||||
return newId;
|
||||
});
|
||||
return ensuring;
|
||||
};
|
||||
|
||||
// Apply a resumed draft only when it's safe to: a late lookup must never clobber
|
||||
// progress the user already made while it was in flight, and "start fresh" needs no
|
||||
// dispatch (the machine already starts fresh). snapshot() is non-null once the user
|
||||
// has real progress.
|
||||
const applyResume = (draft: unknown | null) => {
|
||||
if (draft == null || deps.snapshot() != null) return;
|
||||
deps.onResume(draft);
|
||||
};
|
||||
|
||||
const flush = async () => {
|
||||
const snap = deps.snapshot();
|
||||
if (!snap) return;
|
||||
const theId = await ensureId();
|
||||
await adapter.syncDraft(theId, {
|
||||
draft: snap.draft,
|
||||
stepIndex: snap.stepIndex,
|
||||
stepCount: snap.stepCount,
|
||||
documentIds: snap.documentIds,
|
||||
});
|
||||
};
|
||||
|
||||
// One effect watches the snapshot; each change resets a debounce timer. The timer's
|
||||
// callback only does network I/O (never dispatch), so it can't livelock the store.
|
||||
effect(() => {
|
||||
if (!active()) return;
|
||||
const snap = deps.snapshot(); // tracked: fires on every machine change
|
||||
if (!snap) return;
|
||||
if (timer) clearTimeout(timer);
|
||||
// Null the handle when it fires so `hasPendingSave()` reflects "a write is still owed".
|
||||
timer = setTimeout(() => {
|
||||
timer = undefined;
|
||||
void flush();
|
||||
}, DEBOUNCE_MS);
|
||||
});
|
||||
|
||||
inject(DestroyRef).onDestroy(() => timer && clearTimeout(timer));
|
||||
|
||||
// Flush a pending debounced draft write before an in-app route change / unload (see
|
||||
// pending-saves.ts). onDestroy above only cancels the timer — this actually persists it.
|
||||
const hasPendingSave = () => timer !== undefined;
|
||||
const flushPending = async () => {
|
||||
if (timer === undefined) return;
|
||||
clearTimeout(timer);
|
||||
timer = undefined;
|
||||
await flush();
|
||||
};
|
||||
registerPendingSave({ hasPendingSave, flushPending });
|
||||
|
||||
// Attach to a specific Concept id and seed the machine from its draft. A non-Concept
|
||||
// (submitted/gone) id is treated as fresh so it can't reopen as an editable draft.
|
||||
const load = (linked: string): Promise<void> => {
|
||||
id = linked;
|
||||
return adapter
|
||||
.detail(linked)
|
||||
.then((dto) => {
|
||||
if (dto.status && dto.status.tag !== 'Concept') {
|
||||
id = undefined;
|
||||
applyResume(null);
|
||||
return;
|
||||
}
|
||||
applyResume(dto.draft ?? null);
|
||||
})
|
||||
.catch(() => {
|
||||
id = undefined;
|
||||
applyResume(null); // unknown/deleted id → start fresh
|
||||
});
|
||||
};
|
||||
|
||||
// Find the user's existing Concept of this type (at most one), if any.
|
||||
const findConcept = async (): Promise<string | undefined> => {
|
||||
try {
|
||||
const parsed = parseApplications(await adapter.list());
|
||||
return parsed.ok
|
||||
? parsed.value.find((a) => a.type === deps.type && a.status.tag === 'Concept')?.id
|
||||
: undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
/** True while a debounced draft write is still pending (PendingSave). */
|
||||
hasPendingSave,
|
||||
/** Flush the pending draft write now and await it; no-op when nothing is pending. */
|
||||
flushPending,
|
||||
|
||||
/** Resolve the initial state: a `?aanvraag` link wins; else resume this type's
|
||||
existing Concept; else start fresh (a Concept is created on first progress). */
|
||||
async resume() {
|
||||
let release!: () => void;
|
||||
resumeGate = new Promise<void>((r) => (release = r));
|
||||
try {
|
||||
if (!active()) {
|
||||
applyResume(null);
|
||||
return;
|
||||
}
|
||||
const linked = route!.snapshot.queryParamMap.get('aanvraag');
|
||||
if (linked) {
|
||||
await load(linked);
|
||||
return;
|
||||
}
|
||||
const existing = await findConcept();
|
||||
if (existing) {
|
||||
await load(existing);
|
||||
// Stamp the id into the URL so a reload resumes the same Concept.
|
||||
void router!.navigate([], {
|
||||
relativeTo: route!,
|
||||
queryParams: { aanvraag: existing },
|
||||
queryParamsHandling: 'merge',
|
||||
replaceUrl: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
applyResume(null);
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
},
|
||||
|
||||
/** Submit through the aanvraag lifecycle: ensure the Concept exists, then
|
||||
`POST /applications/{id}/submit` (server sets autoApprovable + transitions).
|
||||
Folded into a Result like the old submit-* commands. */
|
||||
submit(body: SubmitApplicationRequest): Promise<Result<string, SubmitApplicationResponse>> {
|
||||
return runSubmit(async () => adapter.submit(await ensureId(), body), SUBMIT_FAILED);
|
||||
},
|
||||
|
||||
/** Restart: discard the current in-progress Concept (delete it) and detach, so a
|
||||
fresh one is created on next progress. Keeps the one-per-type invariant. A
|
||||
submitted id can't be deleted (409, caught) — that submission correctly remains,
|
||||
and detaching still lets the user start a new Concept. */
|
||||
reset() {
|
||||
if (id) {
|
||||
void adapter.cancel(id).catch(() => {}); // Concept → deleted; submitted → 409, kept
|
||||
id = undefined;
|
||||
ensuring = undefined;
|
||||
}
|
||||
if (active())
|
||||
void router!.navigate([], {
|
||||
relativeTo: route!,
|
||||
queryParams: { aanvraag: null },
|
||||
queryParamsHandling: 'merge',
|
||||
replaceUrl: true,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Injectable, computed, inject } from '@angular/core';
|
||||
import { RemoteData, fromResource } from '@shared/application/remote-data';
|
||||
import { DuoLookupDto } from '../contracts/duo-diplomas.dto';
|
||||
import { BrpAdapter, parseBrpAddress } from '../infrastructure/brp.adapter';
|
||||
import { DuoAdapter, parseDuoLookup } from '../infrastructure/duo.adapter';
|
||||
|
||||
type Err = Error | undefined;
|
||||
|
||||
/**
|
||||
* Application-layer facade for the registratie wizard's two lookups (BRP address,
|
||||
* DUO diplomas). It owns the httpResources (created here, in the required injection
|
||||
* context), runs the trust-boundary parse, and exposes RemoteData / derived signals
|
||||
* — so the UI reaches the network through application/, never infrastructure/
|
||||
* directly (CLAUDE.md §1: ui → application → domain). Same facade shape as
|
||||
* BigProfileStore.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class RegistratieLookupStore {
|
||||
private brp = inject(BrpAdapter);
|
||||
private duo = inject(DuoAdapter);
|
||||
|
||||
private adresRes = this.brp.adresResource();
|
||||
private diplomasRes = this.duo.diplomasResource();
|
||||
|
||||
/** BRP lookup outcome. A failure or "geen adres" never blocks the wizard — both
|
||||
fall back to manual entry (PRD §7). 'geen' covers both no-address-found and a
|
||||
malformed response; 'fout' is an unreachable BRP. */
|
||||
readonly adresStatus = computed<'laden' | 'gevonden' | 'geen' | 'fout'>(() => {
|
||||
const st = this.adresRes.status();
|
||||
if (st === 'loading' || st === 'reloading') return 'laden';
|
||||
if (st === 'error') return 'fout';
|
||||
const json = this.adresRes.value();
|
||||
const parsed = json !== undefined ? parseBrpAddress(json) : null;
|
||||
return parsed && parsed.ok && parsed.value.gevonden ? 'gevonden' : 'geen';
|
||||
});
|
||||
|
||||
/** The address to prefill the draft with, once BRP resolves with a found address;
|
||||
null otherwise (loading, error, no address, malformed). */
|
||||
readonly prefillAdres = computed<{ straat: string; postcode: string; woonplaats: string } | null>(
|
||||
() => {
|
||||
const json = this.adresRes.value();
|
||||
if (json === undefined) return null;
|
||||
const parsed = parseBrpAddress(json);
|
||||
return parsed.ok && parsed.value.gevonden && parsed.value.adres ? parsed.value.adres : null;
|
||||
},
|
||||
);
|
||||
|
||||
/** The DUO lookup (diplomas + manual fallback), validated at the trust boundary. */
|
||||
readonly duoLookup = computed<RemoteData<Err, DuoLookupDto>>(() => {
|
||||
const rd = fromResource(this.diplomasRes);
|
||||
if (rd.tag !== 'Success') return rd;
|
||||
const parsed = parseDuoLookup(rd.value);
|
||||
return parsed.ok
|
||||
? { tag: 'Success', value: parsed.value }
|
||||
: { tag: 'Failure', error: new Error(parsed.error) };
|
||||
});
|
||||
|
||||
/** Reload the BRP lookup (e.g. when the wizard restarts) so the address re-prefills. */
|
||||
reloadAdres() {
|
||||
this.adresRes.reload();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Valid } from '@registratie/domain/change-request.machine';
|
||||
import { ChangeRequestAdapter } from '@registratie/infrastructure/change-request.adapter';
|
||||
import { createSubmitChangeRequest } from './submit-change-request';
|
||||
import { parseTelefoonnummer } from '@registratie/domain/value-objects/telefoonnummer';
|
||||
|
||||
const telefoon = parseTelefoonnummer('0612345678');
|
||||
if (!telefoon.ok) throw new Error('fixture phone should parse');
|
||||
|
||||
const data: Valid = { telefoon: telefoon.value };
|
||||
|
||||
function setup(adapter: Partial<ChangeRequestAdapter>) {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [{ provide: ChangeRequestAdapter, useValue: adapter }],
|
||||
});
|
||||
return TestBed.runInInjectionContext(() => createSubmitChangeRequest());
|
||||
}
|
||||
|
||||
describe('createSubmitChangeRequest', () => {
|
||||
it('resolves ok with the referentie on success', async () => {
|
||||
const submit = setup({ changeRequest: () => Promise.resolve('BIG-2026-000123') });
|
||||
const r = await submit(data);
|
||||
expect(r).toEqual({ ok: true, value: 'BIG-2026-000123' });
|
||||
});
|
||||
|
||||
it('folds a rejected call into a Result error, never throwing', async () => {
|
||||
const submit = setup({
|
||||
changeRequest: () => Promise.reject(new Error('network kaput')),
|
||||
});
|
||||
const r = await submit(data);
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('surfaces a ProblemDetails detail message when the server rejects with one', async () => {
|
||||
const submit = setup({
|
||||
changeRequest: () => Promise.reject({ detail: 'Telefoonnummer is ongeldig.' }),
|
||||
});
|
||||
const r = await submit(data);
|
||||
expect(r).toEqual({ ok: false, error: 'Telefoonnummer is ongeldig.' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { Result } from '@shared/kernel/fp';
|
||||
import { Valid } from '@registratie/domain/change-request.machine';
|
||||
import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit';
|
||||
import { ChangeRequestAdapter } from '@registratie/infrastructure/change-request.adapter';
|
||||
|
||||
/**
|
||||
* Command factory: binds the change-request adapter (which owns the `ApiClient`)
|
||||
* in an injection context and returns the submit function the form calls. Same
|
||||
* field-initializer shape as `createStore`/`createDraftSync`, so the UI holds an
|
||||
* application command — not the network client. Returns a `Result`, never a thrown
|
||||
* error, so the form's reduce can branch on the outcome.
|
||||
*/
|
||||
export function createSubmitChangeRequest() {
|
||||
const adapter = inject(ChangeRequestAdapter);
|
||||
return (data: Valid): Promise<Result<string, string>> =>
|
||||
runSubmit(() => adapter.changeRequest(data), SUBMIT_FAILED);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* WIRE CONTRACT for the BRP address lookup ("BFF-lite" — one screen-shaped call).
|
||||
*
|
||||
* In production this is GENERATED from the OpenAPI/TypeSpec spec and served by our
|
||||
* own backend, which talks to the BRP behind an adapter. The frontend never sees
|
||||
* the BRP's own wire format. See docs/reference/architecture/0001-bff-lite-decision-dtos.md.
|
||||
*
|
||||
* "Geen adres bekend" is a first-class outcome (`gevonden: false`), not an error —
|
||||
* the wizard falls back to manual entry (PRD §7). Slice 1 ships only the happy
|
||||
* path (gevonden: true).
|
||||
*/
|
||||
export interface BrpAddressDto {
|
||||
gevonden: boolean;
|
||||
adres?: { straat: string; postcode: string; woonplaats: string };
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* WIRE CONTRACT for the dashboard screen — the "BFF-lite" response.
|
||||
*
|
||||
* PURE wire shapes: this file imports NOTHING (CLAUDE.md §1, ADR-0001). Enums are
|
||||
* inlined string-literal unions that describe the wire, not the domain. The
|
||||
* adapter's `parseDashboardView` validates this untrusted shape and MAPS it onto
|
||||
* the FE domain model (Registration/Person/BigProfile) — that map is the
|
||||
* decoupling seam: the wire can change without the domain following.
|
||||
*
|
||||
* In production these types are GENERATED from the OpenAPI/TypeSpec spec (one
|
||||
* source of truth for both sides), and the `decisions` block is computed BY THE
|
||||
* BACKEND — never recomputed on the client. The frontend renders decisions; it
|
||||
* does not own the rules. See docs/reference/architecture/0001-bff-lite-decision-dtos.md.
|
||||
*
|
||||
* One screen-shaped call replaces the previous three (BIG-register + BRP + …),
|
||||
* so the page always sees one consistent snapshot instead of three independently
|
||||
* loading/erroring resources.
|
||||
*/
|
||||
|
||||
/** Registration status on the wire: the discriminant tags as they arrive. */
|
||||
export type RegistrationStatusDto =
|
||||
| { tag: 'Geregistreerd'; herregistratieDatum: string } // ISO date
|
||||
| { tag: 'Geschorst'; geschorstTot: string; reden: string }
|
||||
| { tag: 'Doorgehaald'; doorgehaaldOp: string; reden: string };
|
||||
|
||||
export interface RegistrationDto {
|
||||
bigNummer: string;
|
||||
naam: string;
|
||||
beroep: string;
|
||||
registratiedatum: string; // ISO date
|
||||
geboortedatum: string;
|
||||
status: RegistrationStatusDto;
|
||||
}
|
||||
|
||||
export interface AdresDto {
|
||||
straat: string;
|
||||
postcode: string;
|
||||
woonplaats: string;
|
||||
}
|
||||
|
||||
export interface PersonDto {
|
||||
naam: string;
|
||||
geboortedatum: string; // ISO date
|
||||
adres: AdresDto;
|
||||
}
|
||||
|
||||
/** Server-computed decisions. Rendered by the FE as-is (decision DTO, ADR-0001):
|
||||
the eligibility rule lives on the backend; the optional reason lets the UI
|
||||
explain itself without knowing the rule. */
|
||||
export interface HerregistratieDecisions {
|
||||
eligibleForHerregistratie: boolean;
|
||||
herregistratieReason?: string;
|
||||
}
|
||||
|
||||
export interface DashboardViewDto {
|
||||
registration: RegistrationDto;
|
||||
person: PersonDto;
|
||||
decisions: HerregistratieDecisions;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* WIRE CONTRACT for the DUO diploma lookup ("BFF-lite" — one screen-shaped call
|
||||
* returning everything the beroep step needs).
|
||||
*
|
||||
* Each diploma carries its server-computed `beroep` (the profession it maps to)
|
||||
* and the `policyQuestions` (geldigheidsvragen) that apply to it. These are
|
||||
* DECISIONS computed by the backend from the diploma's attributes — the frontend
|
||||
* renders them, it does not derive them (decision-DTO pattern, ADR-0001). E.g. an
|
||||
* English-language diploma carries the Dutch-proficiency question.
|
||||
*
|
||||
* `handmatig` is the fallback when the diploma is not in the DUO list: the
|
||||
* professions the user may declare and the MAXIMAL policy-question set that then
|
||||
* applies (a manual diploma is unverified, so the strictest set is used).
|
||||
*/
|
||||
export interface DuoLookupDto {
|
||||
diplomas: DuoDiplomaDto[];
|
||||
handmatig: ManualDiplomaPolicyDto;
|
||||
}
|
||||
|
||||
export interface DuoDiplomaDto {
|
||||
id: string;
|
||||
naam: string;
|
||||
instelling: string;
|
||||
jaar: number;
|
||||
beroep: string; // server-derived profession
|
||||
policyQuestions: PolicyQuestionDto[]; // server-decided geldigheidsvragen
|
||||
}
|
||||
|
||||
export interface ManualDiplomaPolicyDto {
|
||||
beroepen: string[]; // professions the user may declare for a manual diploma
|
||||
policyQuestions: PolicyQuestionDto[]; // maximal set applied to a manual diploma
|
||||
}
|
||||
|
||||
export interface PolicyQuestionDto {
|
||||
id: string;
|
||||
vraag: string;
|
||||
type: 'ja-nee' | 'tekst';
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { submittedRow, detailRows, purposeLabel, statusLabel, TYPE_LABELS } from './aanvraag-view';
|
||||
import { Aanvraag } from './aanvraag';
|
||||
|
||||
const base = {
|
||||
id: '1',
|
||||
type: 'herregistratie' as const,
|
||||
documentIds: [],
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
submittedAt: '2024-05-12',
|
||||
};
|
||||
|
||||
describe('submittedRow', () => {
|
||||
it('heading is the type, subtitle is the purpose', () => {
|
||||
const row = submittedRow({
|
||||
...base,
|
||||
status: { tag: 'InBehandeling', referentie: 'R1', manual: false },
|
||||
} as Aanvraag);
|
||||
expect(row.heading).toBe(TYPE_LABELS.herregistratie);
|
||||
expect(row.subtitle).toBe(purposeLabel('herregistratie'));
|
||||
});
|
||||
|
||||
it('status line carries the status label, reference and submit date', () => {
|
||||
const row = submittedRow({
|
||||
...base,
|
||||
status: { tag: 'InBehandeling', referentie: 'R1', manual: false },
|
||||
} as Aanvraag);
|
||||
expect(row.status).toContain(
|
||||
statusLabel({ tag: 'InBehandeling', referentie: 'R1', manual: false }),
|
||||
);
|
||||
expect(row.status).toContain('R1');
|
||||
expect(row.status).toContain('12 mei 2024');
|
||||
});
|
||||
|
||||
it('manual review adds a note; rejection adds its reason', () => {
|
||||
const manual = submittedRow({
|
||||
...base,
|
||||
status: { tag: 'InBehandeling', referentie: 'R1', manual: true },
|
||||
} as Aanvraag);
|
||||
expect(manual.status).toContain('handmatig');
|
||||
const rejected = submittedRow({
|
||||
...base,
|
||||
status: { tag: 'Afgewezen', referentie: 'R2', reden: 'Onvoldoende uren' },
|
||||
} as Aanvraag);
|
||||
expect(rejected.status).toContain('Onvoldoende uren');
|
||||
});
|
||||
|
||||
it('meer-info-gevraagd adds its reason, like a rejection', () => {
|
||||
const row = submittedRow({
|
||||
...base,
|
||||
status: { tag: 'MeerInfoGevraagd', referentie: 'R3', reden: 'Diploma ontbreekt' },
|
||||
} as Aanvraag);
|
||||
expect(row.status).toContain('Diploma ontbreekt');
|
||||
});
|
||||
});
|
||||
|
||||
describe('detailRows', () => {
|
||||
it('lists soort/waarvoor/status/referentie/ingediend, plus reason when rejected', () => {
|
||||
const rows = detailRows({
|
||||
...base,
|
||||
status: { tag: 'Afgewezen', referentie: 'R2', reden: 'Onvoldoende uren' },
|
||||
} as Aanvraag);
|
||||
const values = rows.map((r) => r.value);
|
||||
expect(values).toContain(TYPE_LABELS.herregistratie);
|
||||
expect(values).toContain('R2');
|
||||
expect(values).toContain('Onvoldoende uren');
|
||||
expect(rows.length).toBe(6);
|
||||
});
|
||||
|
||||
it('reference falls back to em dash for a Concept', () => {
|
||||
const rows = detailRows({
|
||||
...base,
|
||||
submittedAt: undefined,
|
||||
status: { tag: 'Concept', stepIndex: 0, stepCount: 3 },
|
||||
} as Aanvraag);
|
||||
const ref = rows.find((r) => r.value === '—');
|
||||
expect(ref).toBeTruthy();
|
||||
expect(rows.length).toBe(5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { formatDatumNl } from '@shared/kernel/datum';
|
||||
import { Aanvraag, AanvraagStatus, AanvraagType } from './aanvraag';
|
||||
|
||||
/** View-model mapping for an aanvraag: type → labels, status → label, and the fields
|
||||
for a CIBG "aanvragen" row / the case-detail page. Pure, no Angular — the UI
|
||||
renders these, it does not derive them. */
|
||||
|
||||
export const TYPE_LABELS: Record<AanvraagType, string> = {
|
||||
registratie: $localize`:@@aanvraagBlock.type.registratie:Inschrijving`,
|
||||
herregistratie: $localize`:@@aanvraagBlock.type.herregistratie:Herregistratie`,
|
||||
intake: $localize`:@@aanvraagBlock.type.intake:Herregistratie-intake`,
|
||||
};
|
||||
|
||||
/** What the aanvraag is for (shown under the title). */
|
||||
export function purposeLabel(type: AanvraagType): string {
|
||||
switch (type) {
|
||||
case 'registratie':
|
||||
return $localize`:@@aanvraag.purpose.registratie:Inschrijving in het BIG-register`;
|
||||
case 'herregistratie':
|
||||
return $localize`:@@aanvraag.purpose.herregistratie:Verlenging van uw BIG-registratie`;
|
||||
case 'intake':
|
||||
return $localize`:@@aanvraag.purpose.intake:Intake-vragenlijst voor uw herregistratie`;
|
||||
}
|
||||
}
|
||||
|
||||
/** The status as a plain label (what state the aanvraag is in). */
|
||||
export function statusLabel(status: AanvraagStatus): string {
|
||||
switch (status.tag) {
|
||||
case 'Concept':
|
||||
return $localize`:@@aanvraag.status.concept:Concept (nog niet ingediend)`;
|
||||
case 'Ingediend':
|
||||
return $localize`:@@aanvraag.status.ingediend:Ingediend`;
|
||||
case 'InBehandeling':
|
||||
return $localize`:@@aanvraag.status.inBehandeling:In behandeling`;
|
||||
case 'MeerInfoGevraagd':
|
||||
return $localize`:@@aanvraag.status.meerInfoGevraagd:Meer informatie gevraagd`;
|
||||
case 'Goedgekeurd':
|
||||
return $localize`:@@aanvraag.status.goedgekeurd:Goedgekeurd`;
|
||||
case 'Afgewezen':
|
||||
return $localize`:@@aanvraag.status.afgewezen:Afgewezen`;
|
||||
}
|
||||
}
|
||||
|
||||
/** The reference number, or '' for a Concept (which has none yet). */
|
||||
export function referentie(status: AanvraagStatus): string {
|
||||
return status.tag === 'Concept' ? '' : status.referentie;
|
||||
}
|
||||
|
||||
export interface AanvraagRow {
|
||||
heading: string;
|
||||
/** What the aanvraag is for (the `.subtitle` line). */
|
||||
subtitle: string;
|
||||
/** The status: label + reference + submit date (+ any note) — the `.status` line. */
|
||||
status: string;
|
||||
}
|
||||
|
||||
/** Fields for a submitted aanvraag's row in the dashboard "aanvragen" list (Concept
|
||||
has no row — it renders as a resumable melding, see aanvraag-block). */
|
||||
export function submittedRow(a: Aanvraag): AanvraagRow {
|
||||
const s = a.status;
|
||||
const parts = [statusLabel(s)];
|
||||
const ref = referentie(s);
|
||||
if (ref) parts.push($localize`:@@aanvraag.row.ref:Referentie ${ref}:ref:`);
|
||||
if (a.submittedAt)
|
||||
parts.push(
|
||||
$localize`:@@aanvraag.row.ingediend:ingediend op ${formatDatumNl(a.submittedAt)}:datum:`,
|
||||
);
|
||||
if (s.tag === 'InBehandeling' && s.manual)
|
||||
parts.push(
|
||||
$localize`:@@aanvraagBlock.manual:Uw aanvraag wordt handmatig beoordeeld in de backoffice.`,
|
||||
);
|
||||
if (s.tag === 'Afgewezen' || s.tag === 'MeerInfoGevraagd') parts.push(s.reden);
|
||||
return {
|
||||
heading: TYPE_LABELS[a.type],
|
||||
subtitle: purposeLabel(a.type),
|
||||
status: parts.join(' · '),
|
||||
};
|
||||
}
|
||||
|
||||
/** Key/value rows for the case-detail page (CIBG Datablock). */
|
||||
export function detailRows(a: Aanvraag): { key: string; value: string }[] {
|
||||
const rows = [
|
||||
{ key: $localize`:@@aanvraag.detail.soort:Soort aanvraag`, value: TYPE_LABELS[a.type] },
|
||||
{ key: $localize`:@@aanvraag.detail.waarvoor:Waarvoor`, value: purposeLabel(a.type) },
|
||||
{ key: $localize`:@@aanvraag.detail.status:Status`, value: statusLabel(a.status) },
|
||||
{
|
||||
key: $localize`:@@aanvraag.detail.referentie:Referentie`,
|
||||
value: referentie(a.status) || '—',
|
||||
},
|
||||
{
|
||||
key: $localize`:@@aanvraag.detail.ingediend:Ingediend op`,
|
||||
value: a.submittedAt ? formatDatumNl(a.submittedAt) : '—',
|
||||
},
|
||||
];
|
||||
if (a.status.tag === 'Afgewezen') {
|
||||
rows.push({
|
||||
key: $localize`:@@aanvraag.detail.reden:Reden van afwijzing`,
|
||||
value: a.status.reden,
|
||||
});
|
||||
}
|
||||
if (a.status.tag === 'MeerInfoGevraagd') {
|
||||
rows.push({
|
||||
key: $localize`:@@aanvraag.detail.meerInfoReden:Gevraagde informatie`,
|
||||
value: a.status.reden,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* An application (aanvraag) as the frontend sees it — the parsed, domain-side view
|
||||
* of the backend-owned aggregate (see backend ApplicationStore + PRD 0001). Pure
|
||||
* types, no Angular. Lives in `registratie` because the dashboard (here) is the
|
||||
* consumer and the downstream wizards (`herregistratie → registratie`) produce them.
|
||||
*
|
||||
* The status is a discriminated union so illegal states are unrepresentable — same
|
||||
* reflex as RemoteData. The server computes which tag applies (auto-approval on
|
||||
* read); the FE renders it, it does not recompute the lifecycle.
|
||||
*/
|
||||
export type AanvraagType = 'registratie' | 'herregistratie' | 'intake';
|
||||
|
||||
// Ingediend/MeerInfoGevraagd (ADR-0002/WP-63) are widened into the union so the parse
|
||||
// boundary + renderers are ready, but no backend path emits them yet — that's WP-65's
|
||||
// behandelaar-facing transition endpoint.
|
||||
export type AanvraagStatus =
|
||||
| { tag: 'Concept'; stepIndex: number; stepCount: number }
|
||||
| { tag: 'Ingediend'; referentie: string }
|
||||
| { tag: 'InBehandeling'; referentie: string; manual: boolean } // manual=true → "wordt handmatig beoordeeld"
|
||||
| { tag: 'MeerInfoGevraagd'; referentie: string; reden: string }
|
||||
| { tag: 'Goedgekeurd'; referentie: string }
|
||||
| { tag: 'Afgewezen'; referentie: string; reden: string };
|
||||
|
||||
export interface Aanvraag {
|
||||
id: string;
|
||||
type: AanvraagType;
|
||||
status: AanvraagStatus;
|
||||
documentIds: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
submittedAt?: string;
|
||||
/** The case owner (a BSN). Only populated by the admin cross-owner list (WP-36);
|
||||
the user's own list leaves it undefined. */
|
||||
owner?: string;
|
||||
}
|
||||
|
||||
/** Detail adds the opaque wizard snapshot used to resume a Concept. */
|
||||
export interface AanvraagDetail extends Aanvraag {
|
||||
draft: unknown;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { blockActions } from './block-actions';
|
||||
|
||||
describe('blockActions', () => {
|
||||
it('a Concept can be resumed or cancelled', () => {
|
||||
expect(blockActions({ tag: 'Concept', stepIndex: 1, stepCount: 3 })).toEqual([
|
||||
'resume',
|
||||
'cancel',
|
||||
]);
|
||||
});
|
||||
|
||||
it('an in-behandeling aanvraag only exposes its documents', () => {
|
||||
expect(blockActions({ tag: 'InBehandeling', referentie: 'BIG-1', manual: true })).toEqual([
|
||||
'viewDocuments',
|
||||
]);
|
||||
});
|
||||
|
||||
it('ingediend and meer-info-gevraagd behave like in-behandeling', () => {
|
||||
expect(blockActions({ tag: 'Ingediend', referentie: 'BIG-1' })).toEqual(['viewDocuments']);
|
||||
expect(blockActions({ tag: 'MeerInfoGevraagd', referentie: 'BIG-1', reden: 'x' })).toEqual([
|
||||
'viewDocuments',
|
||||
]);
|
||||
});
|
||||
|
||||
it('resolved aanvragen have no actions', () => {
|
||||
expect(blockActions({ tag: 'Goedgekeurd', referentie: 'BIG-1' })).toEqual([]);
|
||||
expect(blockActions({ tag: 'Afgewezen', referentie: 'BIG-1', reden: 'x' })).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { AanvraagStatus } from './aanvraag';
|
||||
|
||||
/** What a dashboard "Mijn aanvragen" block offers per status. The badge itself
|
||||
follows directly from `status.tag` (the UI maps tag → colour + label), so this
|
||||
pure function owns only the *actions* decision. */
|
||||
export type BlockAction = 'resume' | 'cancel' | 'viewDocuments';
|
||||
|
||||
export function blockActions(status: AanvraagStatus): BlockAction[] {
|
||||
switch (status.tag) {
|
||||
case 'Concept':
|
||||
return ['resume', 'cancel'];
|
||||
case 'Ingediend':
|
||||
case 'InBehandeling':
|
||||
case 'MeerInfoGevraagd':
|
||||
return ['viewDocuments'];
|
||||
case 'Goedgekeurd':
|
||||
case 'Afgewezen':
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ChangeRequestState, reduce, initial } from './change-request.machine';
|
||||
|
||||
const editingWith = (telefoon: string): ChangeRequestState => ({
|
||||
tag: 'Editing',
|
||||
draft: { telefoon },
|
||||
errors: {},
|
||||
});
|
||||
|
||||
describe('change-request reduce', () => {
|
||||
it('SetField updates the draft while editing', () => {
|
||||
const s = reduce(initial, { tag: 'SetField', key: 'telefoon', value: '0612345678' });
|
||||
expect(s.tag).toBe('Editing');
|
||||
expect((s as Extract<ChangeRequestState, { tag: 'Editing' }>).draft.telefoon).toBe(
|
||||
'0612345678',
|
||||
);
|
||||
});
|
||||
|
||||
it('Submit with an invalid draft stays Editing and reports field errors', () => {
|
||||
const s = reduce(editingWith('nope'), { tag: 'Submit' });
|
||||
expect(s.tag).toBe('Editing');
|
||||
const errors = (s as Extract<ChangeRequestState, { tag: 'Editing' }>).errors;
|
||||
expect(errors.telefoon).toBeTruthy();
|
||||
});
|
||||
|
||||
it('Submit with a valid draft moves to Submitting with parsed (normalised) data', () => {
|
||||
const s = reduce(editingWith('06 12 34 56 78'), { tag: 'Submit' });
|
||||
expect(s.tag).toBe('Submitting');
|
||||
expect((s as Extract<ChangeRequestState, { tag: 'Submitting' }>).data.telefoon).toBe(
|
||||
'0612345678',
|
||||
);
|
||||
});
|
||||
|
||||
it('SubmitConfirmed maps Submitting to Submitted with the referentie', () => {
|
||||
const submitting = reduce(editingWith('0612345678'), { tag: 'Submit' });
|
||||
const ok = reduce(submitting, { tag: 'SubmitConfirmed', referentie: 'BIG-2026-1' });
|
||||
expect(ok).toMatchObject({ tag: 'Submitted', referentie: 'BIG-2026-1' });
|
||||
});
|
||||
|
||||
it('SubmitFailed maps Submitting to Failed with the error', () => {
|
||||
const submitting = reduce(editingWith('0612345678'), { tag: 'Submit' });
|
||||
const failed = reduce(submitting, { tag: 'SubmitFailed', error: 'boom' });
|
||||
expect(failed).toMatchObject({ tag: 'Failed', error: 'boom' });
|
||||
});
|
||||
|
||||
it('Retry re-submits a failure', () => {
|
||||
const submitting = reduce(editingWith('0612345678'), { tag: 'Submit' });
|
||||
const failed = reduce(submitting, { tag: 'SubmitFailed', error: 'boom' });
|
||||
expect(reduce(failed, { tag: 'Retry' }).tag).toBe('Submitting');
|
||||
});
|
||||
|
||||
it('Reset returns to the initial editing state', () => {
|
||||
const submitting = reduce(editingWith('0612345678'), { tag: 'Submit' });
|
||||
expect(reduce(submitting, { tag: 'Reset' })).toEqual(initial);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Result, assertNever } from '@shared/kernel/fp';
|
||||
import {
|
||||
Telefoonnummer,
|
||||
parseTelefoonnummer,
|
||||
} from '@registratie/domain/value-objects/telefoonnummer';
|
||||
|
||||
/** What the user is typing (raw, possibly invalid). The BRP address is NOT part of
|
||||
the form — it is authoritative and shown read-only (WP-34); only the phone number
|
||||
is editable here. */
|
||||
export interface Draft {
|
||||
telefoon: string;
|
||||
}
|
||||
|
||||
/** After parsing — telefoon is the branded type, so downstream can't get a raw one. */
|
||||
export interface Valid {
|
||||
telefoon: Telefoonnummer;
|
||||
}
|
||||
|
||||
export type Errors = Partial<Record<keyof Draft, string>>;
|
||||
|
||||
/**
|
||||
* The contact-change (telefoonwijziging) form as one tagged union — the SAME idiom
|
||||
* as the wizards, just single-step. `draft`/`errors` exist only while Editing;
|
||||
* Submitting/Submitted/Failed carry the parsed `Valid`. Illegal states (submitting
|
||||
* an invalid draft, a success screen with errors) are unrepresentable.
|
||||
*/
|
||||
// #region showcase:machine
|
||||
export type ChangeRequestState =
|
||||
| { tag: 'Editing'; draft: Draft; errors: Errors } // draft/errors exist ONLY while editing
|
||||
| { tag: 'Submitting'; data: Valid } // carries the parsed value, no errors
|
||||
| { tag: 'Submitted'; data: Valid; referentie: string }
|
||||
| { tag: 'Failed'; data: Valid; error: string };
|
||||
// #endregion showcase:machine
|
||||
|
||||
export const initial: ChangeRequestState = {
|
||||
tag: 'Editing',
|
||||
draft: { telefoon: '' },
|
||||
errors: {},
|
||||
};
|
||||
|
||||
/** Parse via the value object; on success hand back a Valid, else per-field errors. */
|
||||
function validate(draft: Draft): Result<Errors, Valid> {
|
||||
const telefoon = parseTelefoonnummer(draft.telefoon);
|
||||
if (telefoon.ok) return { ok: true, value: { telefoon: telefoon.value } };
|
||||
return { ok: false, error: { telefoon: telefoon.error } };
|
||||
}
|
||||
|
||||
export type ChangeRequestMsg =
|
||||
| { tag: 'SetField'; key: keyof Draft; value: string }
|
||||
| { tag: 'Submit' }
|
||||
| { tag: 'Retry' }
|
||||
| { tag: 'SubmitConfirmed'; referentie: string }
|
||||
| { tag: 'SubmitFailed'; error: string }
|
||||
| { tag: 'Reset' }
|
||||
| { tag: 'Seed'; state: ChangeRequestState }; // mount a specific state (stories/tests)
|
||||
|
||||
export function reduce(s: ChangeRequestState, m: ChangeRequestMsg): ChangeRequestState {
|
||||
switch (m.tag) {
|
||||
case 'SetField':
|
||||
return s.tag === 'Editing' ? { ...s, draft: { ...s.draft, [m.key]: m.value } } : s;
|
||||
case 'Submit': {
|
||||
if (s.tag !== 'Editing') return s;
|
||||
const r = validate(s.draft);
|
||||
return r.ok ? { tag: 'Submitting', data: r.value } : { ...s, errors: r.error };
|
||||
}
|
||||
case 'Retry':
|
||||
return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s;
|
||||
case 'SubmitConfirmed':
|
||||
return s.tag === 'Submitting'
|
||||
? { tag: 'Submitted', data: s.data, referentie: m.referentie }
|
||||
: s;
|
||||
case 'SubmitFailed':
|
||||
return s.tag === 'Submitting' ? { tag: 'Failed', data: s.data, error: m.error } : s;
|
||||
case 'Reset':
|
||||
return initial;
|
||||
case 'Seed':
|
||||
return m.state;
|
||||
default:
|
||||
return assertNever(m);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { hasProgress, initial, RegistratieState } from './registratie-wizard.machine';
|
||||
|
||||
const invullen = (over: Partial<Extract<RegistratieState, { tag: 'Invullen' }>>) => ({
|
||||
...(initial as Extract<RegistratieState, { tag: 'Invullen' }>),
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('hasProgress', () => {
|
||||
it('is false for a fresh wizard', () => {
|
||||
expect(hasProgress(initial as Extract<RegistratieState, { tag: 'Invullen' }>)).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores an auto-prefilled BRP address at step 0', () => {
|
||||
const s = invullen({
|
||||
draft: {
|
||||
straat: 'Lange Voorhout 9',
|
||||
postcode: '2514 EA',
|
||||
woonplaats: 'Den Haag',
|
||||
adresHerkomst: 'brp',
|
||||
antwoorden: {},
|
||||
},
|
||||
});
|
||||
expect(hasProgress(s)).toBe(false);
|
||||
});
|
||||
|
||||
it('is true once the user advances, picks correspondence/diploma, or is past step 0', () => {
|
||||
expect(hasProgress(invullen({ cursor: 1 }))).toBe(true);
|
||||
expect(hasProgress(invullen({ draft: { correspondentie: 'post', antwoorden: {} } }))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(hasProgress(invullen({ draft: { diplomaId: 'd1', antwoorden: {} } }))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ok, err } from '@shared/kernel/fp';
|
||||
import { initialUpload } from '@shared/upload/upload.machine';
|
||||
import {
|
||||
Draft,
|
||||
RegistratieState,
|
||||
STEPS,
|
||||
initial,
|
||||
currentStep,
|
||||
next,
|
||||
back,
|
||||
gaNaarStap,
|
||||
kiesDiploma,
|
||||
kiesHandmatig,
|
||||
declareerBeroep,
|
||||
setAntwoord,
|
||||
setField,
|
||||
prefillAdres,
|
||||
submit,
|
||||
resolve,
|
||||
reduce,
|
||||
} from './registratie-wizard.machine';
|
||||
|
||||
const invullen = (draft: Partial<Draft>, cursor = 0): RegistratieState => ({
|
||||
tag: 'Invullen',
|
||||
draft: { antwoorden: {}, ...draft },
|
||||
cursor,
|
||||
errors: {},
|
||||
upload: initialUpload,
|
||||
});
|
||||
|
||||
const validAdres = {
|
||||
straat: 'Lange Voorhout 9',
|
||||
postcode: '2514 EA',
|
||||
woonplaats: 'Den Haag',
|
||||
correspondentie: 'post' as const,
|
||||
adresHerkomst: 'brp' as const,
|
||||
};
|
||||
const validDraft: Partial<Draft> = {
|
||||
...validAdres,
|
||||
diplomaId: 'd1',
|
||||
beroep: 'Arts',
|
||||
diplomaHerkomst: 'duo',
|
||||
};
|
||||
|
||||
describe('STEPS (fixed)', () => {
|
||||
it('always has the same three steps', () => {
|
||||
expect(STEPS).toEqual(['adres', 'beroep', 'controle']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('navigation', () => {
|
||||
it('Next is a no-op (sets errors) when the adres step is invalid', () => {
|
||||
const s = next(initial);
|
||||
expect(s.tag).toBe('Invullen');
|
||||
expect((s as any).cursor).toBe(0);
|
||||
expect((s as any).errors.straat).toBeTruthy();
|
||||
expect((s as any).errors.correspondentie).toBeTruthy();
|
||||
});
|
||||
|
||||
it('Next advances once the adres step is valid', () => {
|
||||
const s = next(invullen(validAdres));
|
||||
expect((s as any).cursor).toBe(1);
|
||||
expect(currentStep(s as any)).toBe('beroep');
|
||||
});
|
||||
|
||||
it('requires a valid e-mail only when the channel is email', () => {
|
||||
const bad = next(invullen({ ...validAdres, correspondentie: 'email' }));
|
||||
expect((bad as any).errors.email).toBeTruthy();
|
||||
const good = next(invullen({ ...validAdres, correspondentie: 'email', email: 'a@b.nl' }));
|
||||
expect((good as any).cursor).toBe(1);
|
||||
});
|
||||
|
||||
it('beroep step requires a chosen diploma', () => {
|
||||
const noDiploma = next(invullen(validAdres, 1));
|
||||
expect((noDiploma as any).cursor).toBe(1);
|
||||
expect((noDiploma as any).errors.diploma).toBeTruthy();
|
||||
const withDiploma = next(invullen(validDraft, 1));
|
||||
expect((withDiploma as any).cursor).toBe(2);
|
||||
});
|
||||
|
||||
it('Back never goes below the first step and preserves the draft', () => {
|
||||
expect(back(initial)).toBe(initial);
|
||||
const s = back(invullen(validDraft, 2));
|
||||
expect((s as any).cursor).toBe(1);
|
||||
expect((s as any).draft.beroep).toBe('Arts');
|
||||
});
|
||||
|
||||
it('GaNaarStap only jumps backwards', () => {
|
||||
expect((gaNaarStap(invullen(validDraft, 2), 0) as any).cursor).toBe(0);
|
||||
expect((gaNaarStap(invullen(validDraft, 1), 2) as any).cursor).toBe(1); // forward jump rejected
|
||||
});
|
||||
});
|
||||
|
||||
describe('adres origin (BRP vs handmatig)', () => {
|
||||
it('prefillAdres flags origin brp', () => {
|
||||
const s = prefillAdres(invullen({}), 'Lange Voorhout 9', '2514 EA', 'Den Haag');
|
||||
expect((s as any).draft.adresHerkomst).toBe('brp');
|
||||
expect((s as any).draft.straat).toBe('Lange Voorhout 9');
|
||||
});
|
||||
|
||||
it('editing a prefilled address field flips origin to handmatig', () => {
|
||||
const prefilled = prefillAdres(invullen({}), 'Lange Voorhout 9', '2514 EA', 'Den Haag');
|
||||
const edited = setField(prefilled, 'woonplaats', 'Rotterdam');
|
||||
expect((edited as any).draft.adresHerkomst).toBe('handmatig');
|
||||
});
|
||||
|
||||
it('typing an address with no BRP prefill yields handmatig', () => {
|
||||
const s = setField(invullen({}), 'straat', 'Kerkstraat 1');
|
||||
expect((s as any).draft.adresHerkomst).toBe('handmatig');
|
||||
});
|
||||
|
||||
it('editing the e-mail field does not change the address origin', () => {
|
||||
const prefilled = prefillAdres(invullen({}), 'Lange Voorhout 9', '2514 EA', 'Den Haag');
|
||||
const edited = setField(prefilled, 'email', 'a@b.nl');
|
||||
expect((edited as any).draft.adresHerkomst).toBe('brp');
|
||||
});
|
||||
|
||||
it('a manually entered address still submits (only manual diploma is gated)', () => {
|
||||
const s = submit(
|
||||
invullen({
|
||||
straat: 'Kerkstraat 1',
|
||||
postcode: '1234 AB',
|
||||
woonplaats: 'Utrecht',
|
||||
correspondentie: 'post',
|
||||
adresHerkomst: 'handmatig',
|
||||
diplomaId: 'd1',
|
||||
beroep: 'Arts',
|
||||
diplomaHerkomst: 'duo',
|
||||
}),
|
||||
);
|
||||
expect(s.tag).toBe('Indienen');
|
||||
expect((s as any).data.adresHerkomst).toBe('handmatig');
|
||||
});
|
||||
});
|
||||
|
||||
describe('kiesDiploma', () => {
|
||||
it('derives the beroep from the chosen diploma and flags origin duo', () => {
|
||||
const s = kiesDiploma(invullen({}), 'd9', 'Verpleegkundige', []);
|
||||
expect((s as any).draft.diplomaId).toBe('d9');
|
||||
expect((s as any).draft.beroep).toBe('Verpleegkundige');
|
||||
expect((s as any).draft.diplomaHerkomst).toBe('duo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('policy questions (geldigheidsvragen)', () => {
|
||||
it('a diploma with questions blocks Next until they are answered', () => {
|
||||
let s = kiesDiploma(invullen(validAdres, 1), 'd2', 'Arts', ['nl-taalvaardigheid']);
|
||||
const blocked = next(s);
|
||||
expect((blocked as any).cursor).toBe(1);
|
||||
expect((blocked as any).errors.antwoorden['nl-taalvaardigheid']).toBeTruthy();
|
||||
s = setAntwoord(s, 'nl-taalvaardigheid', 'ja');
|
||||
expect((next(s) as any).cursor).toBe(2);
|
||||
});
|
||||
|
||||
it('validateAll keeps only the answers to the questions that applied', () => {
|
||||
let s = kiesDiploma(invullen(validAdres, 2), 'd2', 'Arts', ['nl-taalvaardigheid']);
|
||||
s = setAntwoord(s, 'nl-taalvaardigheid', 'ja');
|
||||
s = setAntwoord(s, 'stale', 'x'); // not in vraagIds
|
||||
const done = submit(s);
|
||||
expect(done.tag).toBe('Indienen');
|
||||
expect((done as any).data.antwoorden).toEqual({ 'nl-taalvaardigheid': 'ja' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('manual diploma fallback', () => {
|
||||
const maxIds = ['nl-taalvaardigheid', 'diploma-erkend', 'toelichting'];
|
||||
|
||||
it('KiesHandmatig flags handmatig with the maximal question set and no beroep yet', () => {
|
||||
const s = kiesHandmatig(invullen(validAdres, 1), maxIds);
|
||||
expect((s as any).draft.diplomaHerkomst).toBe('handmatig');
|
||||
expect((s as any).draft.beroep).toBeUndefined();
|
||||
expect((s as any).draft.vraagIds).toEqual(maxIds);
|
||||
});
|
||||
|
||||
it('requires a declared beroep + all maximal questions before submit', () => {
|
||||
let s = kiesHandmatig(invullen(validAdres, 2), maxIds);
|
||||
expect(submit(s).tag).toBe('Invullen'); // no beroep declared
|
||||
s = declareerBeroep(s, 'Fysiotherapeut');
|
||||
expect(submit(s).tag).toBe('Invullen'); // questions unanswered
|
||||
for (const id of maxIds) s = setAntwoord(s, id, 'ja');
|
||||
const done = submit(s);
|
||||
expect(done.tag).toBe('Indienen');
|
||||
expect((done as any).data.diplomaHerkomst).toBe('handmatig');
|
||||
expect((done as any).data.beroep).toBe('Fysiotherapeut');
|
||||
});
|
||||
});
|
||||
|
||||
describe('submit', () => {
|
||||
it('stays in Invullen when the draft is incomplete (no diploma)', () => {
|
||||
expect(submit(invullen(validAdres)).tag).toBe('Invullen');
|
||||
});
|
||||
|
||||
it('reaches Indienen with a complete, valid draft, carrying its data', () => {
|
||||
const good = submit(invullen(validDraft));
|
||||
expect(good.tag).toBe('Indienen');
|
||||
expect((good as any).data.beroep).toBe('Arts');
|
||||
expect((good as any).data.adres.postcode).toBe('2514 EA');
|
||||
expect((good as any).data.adresHerkomst).toBe('brp');
|
||||
});
|
||||
|
||||
it('resolve maps Indienen to Ingediend with the referentie', () => {
|
||||
const ingediend = resolve(submit(invullen(validDraft)), ok('BIG-2026-001'));
|
||||
expect(ingediend.tag).toBe('Ingediend');
|
||||
expect((ingediend as any).referentie).toBe('BIG-2026-001');
|
||||
});
|
||||
|
||||
it('resolve maps Indienen to Mislukt on a failed submit', () => {
|
||||
expect(resolve(submit(invullen(validDraft)), err('boom')).tag).toBe('Mislukt');
|
||||
});
|
||||
});
|
||||
|
||||
describe('reduce (message-driven happy path)', () => {
|
||||
it('drives the full flow via messages', () => {
|
||||
let s: RegistratieState = initial;
|
||||
s = reduce(s, {
|
||||
tag: 'PrefillAdres',
|
||||
straat: 'Lange Voorhout 9',
|
||||
postcode: '2514 EA',
|
||||
woonplaats: 'Den Haag',
|
||||
});
|
||||
s = reduce(s, { tag: 'SetCorrespondentie', value: 'post' });
|
||||
s = reduce(s, { tag: 'Next' });
|
||||
expect(currentStep(s as any)).toBe('beroep');
|
||||
s = reduce(s, { tag: 'KiesDiploma', diplomaId: 'd1', beroep: 'Arts', vraagIds: [] });
|
||||
s = reduce(s, { tag: 'Next' });
|
||||
expect(currentStep(s as any)).toBe('controle');
|
||||
s = reduce(s, { tag: 'Submit' });
|
||||
expect(s.tag).toBe('Indienen');
|
||||
s = reduce(s, { tag: 'SubmitConfirmed', referentie: 'BIG-2026-001' });
|
||||
expect(s.tag).toBe('Ingediend');
|
||||
});
|
||||
|
||||
it('SubmitFailed moves Indienen to Mislukt', () => {
|
||||
const s = reduce(reduce(invullen(validDraft), { tag: 'Submit' }), {
|
||||
tag: 'SubmitFailed',
|
||||
error: 'boom',
|
||||
});
|
||||
expect(s.tag).toBe('Mislukt');
|
||||
});
|
||||
|
||||
it('Retry returns Mislukt to Indienen with the same data', () => {
|
||||
const mislukt = reduce(reduce(invullen(validDraft), { tag: 'Submit' }), {
|
||||
tag: 'SubmitFailed',
|
||||
error: 'boom',
|
||||
});
|
||||
const s = reduce(mislukt, { tag: 'Retry' });
|
||||
expect(s.tag).toBe('Indienen');
|
||||
expect((s as any).data.beroep).toBe('Arts');
|
||||
});
|
||||
});
|
||||
|
||||
describe('inline document upload (beroep step)', () => {
|
||||
const cat = {
|
||||
categoryId: 'diploma',
|
||||
label: 'Diploma',
|
||||
description: '',
|
||||
required: true,
|
||||
acceptedTypes: [],
|
||||
maxSizeMb: 10,
|
||||
multiple: false,
|
||||
allowPostDelivery: true,
|
||||
};
|
||||
|
||||
it('routes Upload messages through the upload reducer', () => {
|
||||
const s = reduce(invullen(validDraft), {
|
||||
tag: 'Upload',
|
||||
msg: { type: 'CategoriesLoaded', categories: [cat] },
|
||||
});
|
||||
expect((s as any).upload.categories).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('blocks the beroep step until a required category is satisfied', () => {
|
||||
let s = reduce(invullen(validDraft, 1), {
|
||||
tag: 'Upload',
|
||||
msg: { type: 'CategoriesLoaded', categories: [cat] },
|
||||
});
|
||||
s = reduce(s, { tag: 'Next' }); // beroep → controle blocked
|
||||
expect(currentStep(s as any)).toBe('beroep');
|
||||
expect((s as any).errors.documenten).toBeTruthy();
|
||||
// choosing post delivery satisfies the requirement
|
||||
s = reduce(s, {
|
||||
tag: 'Upload',
|
||||
msg: { type: 'DeliveryChannelChanged', categoryId: 'diploma', channel: 'post' },
|
||||
});
|
||||
s = reduce(s, { tag: 'Next' });
|
||||
expect(currentStep(s as any)).toBe('controle');
|
||||
});
|
||||
|
||||
it('includes delivery refs in the submitted data', () => {
|
||||
let s = reduce(invullen(validDraft), {
|
||||
tag: 'Upload',
|
||||
msg: { type: 'CategoriesLoaded', categories: [cat] },
|
||||
});
|
||||
s = reduce(s, {
|
||||
tag: 'Upload',
|
||||
msg: { type: 'DeliveryChannelChanged', categoryId: 'diploma', channel: 'post' },
|
||||
});
|
||||
const done = submit(s as any);
|
||||
expect(done.tag).toBe('Indienen');
|
||||
expect((done as any).data.documents).toEqual([{ categoryId: 'diploma', channel: 'post' }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,360 @@
|
||||
import { Result, ok, err, assertNever } from '@shared/kernel/fp';
|
||||
import { Postcode, parsePostcode } from '@registratie/domain/value-objects/postcode';
|
||||
import { Email, parseEmail } from '@registratie/domain/value-objects/email';
|
||||
import {
|
||||
UploadState,
|
||||
UploadMsg,
|
||||
DeliveryChannel,
|
||||
initialUpload,
|
||||
reduceUpload,
|
||||
requiredCategoriesSatisfied,
|
||||
deliveryRefs,
|
||||
} from '@shared/upload/upload.machine';
|
||||
|
||||
/**
|
||||
* A FIXED 3-step registration wizard. The steps never change in number (always
|
||||
* `STEPS`): (1) adres + correspondentievoorkeur, (2) beroep o.b.v. diploma,
|
||||
* (3) controle & indienen. Follow-up questions appear *inline within a step*
|
||||
* (e.g. choosing 'email' reveals the e-mail field). "Is this field required
|
||||
* right now" is a pure function (`validateStep`), so it is trivial to test and
|
||||
* impossible to get out of sync with the data. Invariants live here, not in the
|
||||
* UI: the wizard reaches `Indienen` only when a complete `ValidRegistratie` parses.
|
||||
*/
|
||||
|
||||
export type StepId = 'adres' | 'beroep' | 'controle';
|
||||
|
||||
/** The fixed step list. Number of steps never changes; questions reveal inline. */
|
||||
export const STEPS: StepId[] = ['adres', 'beroep', 'controle'];
|
||||
|
||||
/** Where a piece of data came from — recorded on the aggregate (PRD §5). */
|
||||
export type AdresHerkomst = 'brp' | 'handmatig';
|
||||
export type DiplomaHerkomst = 'duo' | 'handmatig';
|
||||
export type Correspondentie = 'email' | 'post';
|
||||
|
||||
/** One record carried across every step (and persisted). All optional: the user
|
||||
fills it in gradually. Adres fields are kept flat so one `SetField` message
|
||||
serves them all (mirrors the intake machine). */
|
||||
export interface Draft {
|
||||
straat?: string;
|
||||
postcode?: string;
|
||||
woonplaats?: string;
|
||||
adresHerkomst?: AdresHerkomst;
|
||||
correspondentie?: Correspondentie;
|
||||
email?: string;
|
||||
diplomaId?: string;
|
||||
diplomaHerkomst?: DiplomaHerkomst;
|
||||
beroep?: string; // DERIVED from the chosen DUO diploma (or declared for a manual one)
|
||||
vraagIds?: string[]; // ids of the policy questions that apply to the chosen diploma
|
||||
antwoorden: Record<string, string>; // geldigheidsantwoorden, keyed by question id
|
||||
}
|
||||
|
||||
/** What we have after the controle step parses — guaranteed valid/typed. */
|
||||
export interface ValidRegistratie {
|
||||
adres: { straat: string; postcode: Postcode; woonplaats: string };
|
||||
adresHerkomst: AdresHerkomst;
|
||||
correspondentie: Correspondentie;
|
||||
email?: Email; // only when correspondentie === 'email'
|
||||
diplomaId: string;
|
||||
diplomaHerkomst: DiplomaHerkomst;
|
||||
beroep: string;
|
||||
antwoorden: Record<string, string>;
|
||||
documents: Array<{ categoryId: string; channel: DeliveryChannel; documentId?: string }>;
|
||||
}
|
||||
|
||||
/** Text fields settable via SetField. */
|
||||
export type DraftField = 'straat' | 'postcode' | 'woonplaats' | 'email';
|
||||
|
||||
/** Per-field error map. `antwoorden` holds per-policy-question errors, keyed by
|
||||
question id (a step can show several questions). */
|
||||
export interface Errors {
|
||||
straat?: string;
|
||||
postcode?: string;
|
||||
woonplaats?: string;
|
||||
email?: string;
|
||||
correspondentie?: string;
|
||||
diploma?: string;
|
||||
documenten?: string;
|
||||
antwoorden?: Record<string, string>;
|
||||
}
|
||||
|
||||
export type RegistratieState =
|
||||
| { tag: 'Invullen'; draft: Draft; cursor: number; errors: Errors; upload: UploadState }
|
||||
| { tag: 'Indienen'; data: ValidRegistratie }
|
||||
| { tag: 'Ingediend'; data: ValidRegistratie; referentie: string }
|
||||
| { tag: 'Mislukt'; data: ValidRegistratie; error: string };
|
||||
|
||||
const emptyDraft: Draft = { antwoorden: {} };
|
||||
export const initial: RegistratieState = {
|
||||
tag: 'Invullen',
|
||||
draft: emptyDraft,
|
||||
cursor: 0,
|
||||
errors: {},
|
||||
upload: initialUpload,
|
||||
};
|
||||
|
||||
/** Which step the cursor currently points at (clamped to the fixed list). */
|
||||
export function currentStep(s: Extract<RegistratieState, { tag: 'Invullen' }>): StepId {
|
||||
return STEPS[Math.min(s.cursor, STEPS.length - 1)];
|
||||
}
|
||||
|
||||
/** Has the user meaningfully started, so it's worth persisting as a Concept? Excludes
|
||||
the automatic BRP address prefill on step 0 — a bare page visit creates nothing.
|
||||
ponytail: an address typed at step 0 without any of these signals is not yet
|
||||
persisted (created once they advance/choose); accepted regression vs. sessionStorage. */
|
||||
export function hasProgress(s: Extract<RegistratieState, { tag: 'Invullen' }>): boolean {
|
||||
const d = s.draft;
|
||||
return (
|
||||
s.cursor > 0 ||
|
||||
!!d.correspondentie ||
|
||||
!!d.email ||
|
||||
!!d.diplomaId ||
|
||||
!!d.beroep ||
|
||||
deliveryRefs(s.upload).some((r) => r.channel === 'digital' && !!r.documentId)
|
||||
);
|
||||
}
|
||||
|
||||
/** Validate every question currently visible in ONE step. Errors keyed per field. */
|
||||
function validateStep(step: StepId, d: Draft, upload: UploadState): Result<Errors, void> {
|
||||
const errors: Errors = {};
|
||||
switch (step) {
|
||||
case 'adres': {
|
||||
if (!d.straat || d.straat.trim() === '')
|
||||
errors.straat = $localize`:@@validation.straat2:Vul een straat en huisnummer in.`;
|
||||
const pc = parsePostcode(d.postcode ?? '');
|
||||
if (!pc.ok) errors.postcode = pc.error;
|
||||
if (!d.woonplaats || d.woonplaats.trim() === '')
|
||||
errors.woonplaats = $localize`:@@validation.woonplaats:Vul een woonplaats in.`;
|
||||
if (!d.correspondentie)
|
||||
errors.correspondentie = $localize`:@@validation.maakKeuze:Maak een keuze.`;
|
||||
// E-mail is only required when 'email' is the chosen channel.
|
||||
if (d.correspondentie === 'email') {
|
||||
const e = parseEmail(d.email ?? '');
|
||||
if (!e.ok) errors.email = e.error;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'beroep': {
|
||||
// A diploma must be chosen (or declared manually); its beroep is then known.
|
||||
if (!d.diplomaId || !d.beroep) {
|
||||
errors.diploma = $localize`:@@validation.diploma:Kies het diploma waarmee u zich wilt registreren, of voer het handmatig in.`;
|
||||
break;
|
||||
}
|
||||
// Every policy question the chosen diploma raised must be answered. Which
|
||||
// questions apply is server-decided (carried in `vraagIds`); we only check
|
||||
// they're answered.
|
||||
const open: Record<string, string> = {};
|
||||
for (const id of d.vraagIds ?? []) {
|
||||
if (!(d.antwoorden[id] ?? '').trim())
|
||||
open[id] = $localize`:@@validation.beantwoordVraag:Beantwoord deze vraag.`;
|
||||
}
|
||||
if (Object.keys(open).length > 0) errors.antwoorden = open;
|
||||
// Required documents for this wizard attach to the beroep step (inline upload).
|
||||
if (!requiredCategoriesSatisfied(upload)) {
|
||||
errors.documenten = $localize`:@@validation.documenten:Lever de verplichte documenten aan (upload of kies "per post nasturen").`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'controle':
|
||||
break; // controle shows a summary; no own fields
|
||||
default:
|
||||
return assertNever(step);
|
||||
}
|
||||
return Object.keys(errors).length > 0 ? err(errors) : ok(undefined);
|
||||
}
|
||||
|
||||
/** Parse the whole wizard into a ValidRegistratie (called on submit). */
|
||||
function validateAll(d: Draft, upload: UploadState): Result<Errors, ValidRegistratie> {
|
||||
const errors: Errors = {};
|
||||
for (const step of STEPS) {
|
||||
const r = validateStep(step, d, upload);
|
||||
if (!r.ok) Object.assign(errors, r.error);
|
||||
}
|
||||
if (Object.keys(errors).length > 0) return err(errors);
|
||||
|
||||
const pc = parsePostcode(d.postcode ?? '');
|
||||
// validateStep guaranteed these parse, but keep the compiler happy.
|
||||
if (!pc.ok || !d.diplomaId || !d.beroep || !d.correspondentie) return err(errors);
|
||||
const email = d.correspondentie === 'email' ? parseEmail(d.email ?? '') : undefined;
|
||||
// Keep only the answers to the questions that actually applied.
|
||||
const vraagIds = d.vraagIds ?? [];
|
||||
const antwoorden = Object.fromEntries(vraagIds.map((id) => [id, d.antwoorden[id] ?? '']));
|
||||
|
||||
return ok({
|
||||
adres: { straat: d.straat!, postcode: pc.value, woonplaats: d.woonplaats! },
|
||||
adresHerkomst: d.adresHerkomst ?? 'handmatig',
|
||||
correspondentie: d.correspondentie,
|
||||
email: email?.ok ? email.value : undefined,
|
||||
diplomaId: d.diplomaId,
|
||||
diplomaHerkomst: d.diplomaHerkomst ?? 'handmatig',
|
||||
beroep: d.beroep,
|
||||
antwoorden,
|
||||
documents: deliveryRefs(upload),
|
||||
});
|
||||
}
|
||||
|
||||
export function setField(s: RegistratieState, key: DraftField, value: string): RegistratieState {
|
||||
if (s.tag !== 'Invullen') return s;
|
||||
const draft: Draft = { ...s.draft, [key]: value };
|
||||
// Editing an address field means the user owns it now — not the BRP copy.
|
||||
if (key === 'straat' || key === 'postcode' || key === 'woonplaats')
|
||||
draft.adresHerkomst = 'handmatig';
|
||||
return { ...s, draft };
|
||||
}
|
||||
|
||||
export function setCorrespondentie(s: RegistratieState, value: Correspondentie): RegistratieState {
|
||||
if (s.tag !== 'Invullen') return s;
|
||||
return { ...s, draft: { ...s.draft, correspondentie: value } };
|
||||
}
|
||||
|
||||
/** Prefill the address from a BRP lookup and flag its origin (PRD §7). */
|
||||
export function prefillAdres(
|
||||
s: RegistratieState,
|
||||
straat: string,
|
||||
postcode: string,
|
||||
woonplaats: string,
|
||||
): RegistratieState {
|
||||
if (s.tag !== 'Invullen') return s;
|
||||
return { ...s, draft: { ...s.draft, straat, postcode, woonplaats, adresHerkomst: 'brp' } };
|
||||
}
|
||||
|
||||
/** Pick a DUO diploma; the beroep is derived from it and the applicable policy
|
||||
questions (`vraagIds`) come with it (both server-computed, passed in). */
|
||||
export function kiesDiploma(
|
||||
s: RegistratieState,
|
||||
diplomaId: string,
|
||||
beroep: string,
|
||||
vraagIds: string[],
|
||||
): RegistratieState {
|
||||
if (s.tag !== 'Invullen') return s;
|
||||
return {
|
||||
...s,
|
||||
draft: { ...s.draft, diplomaId, beroep, vraagIds, diplomaHerkomst: 'duo' },
|
||||
errors: {},
|
||||
};
|
||||
}
|
||||
|
||||
/** Switch to manual diploma entry: the diploma isn't in DUO, so the MAXIMAL
|
||||
policy-question set applies and the entry is flagged handmatig/unverified. The
|
||||
beroep is declared separately (declareerBeroep). */
|
||||
export function kiesHandmatig(s: RegistratieState, vraagIds: string[]): RegistratieState {
|
||||
if (s.tag !== 'Invullen') return s;
|
||||
return {
|
||||
...s,
|
||||
draft: {
|
||||
...s.draft,
|
||||
diplomaId: 'handmatig',
|
||||
beroep: undefined,
|
||||
vraagIds,
|
||||
diplomaHerkomst: 'handmatig',
|
||||
},
|
||||
errors: {},
|
||||
};
|
||||
}
|
||||
|
||||
/** Declare the beroep for a manually-entered diploma (chosen from a fixed list). */
|
||||
export function declareerBeroep(s: RegistratieState, beroep: string): RegistratieState {
|
||||
if (s.tag !== 'Invullen') return s;
|
||||
return { ...s, draft: { ...s.draft, beroep } };
|
||||
}
|
||||
|
||||
export function setAntwoord(s: RegistratieState, vraagId: string, value: string): RegistratieState {
|
||||
if (s.tag !== 'Invullen') return s;
|
||||
return { ...s, draft: { ...s.draft, antwoorden: { ...s.draft.antwoorden, [vraagId]: value } } };
|
||||
}
|
||||
|
||||
export function next(s: RegistratieState): RegistratieState {
|
||||
if (s.tag !== 'Invullen') return s;
|
||||
const r = validateStep(currentStep(s), s.draft, s.upload);
|
||||
if (!r.ok) return { ...s, errors: r.error };
|
||||
return { ...s, cursor: Math.min(s.cursor + 1, STEPS.length - 1), errors: {} };
|
||||
}
|
||||
|
||||
export function back(s: RegistratieState): RegistratieState {
|
||||
if (s.tag !== 'Invullen' || s.cursor === 0) return s;
|
||||
return { ...s, cursor: s.cursor - 1, errors: {} };
|
||||
}
|
||||
|
||||
/** Jump back to an earlier step to correct data (controle → step N). Forward
|
||||
jumps are not allowed (would skip validation). Preserves the draft. */
|
||||
export function gaNaarStap(s: RegistratieState, cursor: number): RegistratieState {
|
||||
if (s.tag !== 'Invullen' || cursor < 0 || cursor >= s.cursor) return s;
|
||||
return { ...s, cursor, errors: {} };
|
||||
}
|
||||
|
||||
export function submit(s: RegistratieState): RegistratieState {
|
||||
if (s.tag !== 'Invullen') return s;
|
||||
const r = validateAll(s.draft, s.upload);
|
||||
return r.ok ? { tag: 'Indienen', data: r.value } : { ...s, errors: r.error };
|
||||
}
|
||||
|
||||
/** Route an upload sub-message through the pure upload reducer (Invullen only). */
|
||||
export function upload(s: RegistratieState, msg: UploadMsg): RegistratieState {
|
||||
if (s.tag !== 'Invullen') return s;
|
||||
return { ...s, upload: reduceUpload(s.upload, msg) };
|
||||
}
|
||||
|
||||
export function resolve(s: RegistratieState, r: Result<string, string>): RegistratieState {
|
||||
if (s.tag !== 'Indienen') return s;
|
||||
return r.ok
|
||||
? { tag: 'Ingediend', data: s.data, referentie: r.value }
|
||||
: { tag: 'Mislukt', data: s.data, error: r.error };
|
||||
}
|
||||
|
||||
export type RegistratieMsg =
|
||||
| { tag: 'SetField'; key: DraftField; value: string }
|
||||
| { tag: 'SetCorrespondentie'; value: Correspondentie }
|
||||
| { tag: 'PrefillAdres'; straat: string; postcode: string; woonplaats: string }
|
||||
| { tag: 'KiesDiploma'; diplomaId: string; beroep: string; vraagIds: string[] }
|
||||
| { tag: 'KiesHandmatig'; vraagIds: string[] }
|
||||
| { tag: 'DeclareerBeroep'; beroep: string }
|
||||
| { tag: 'SetAntwoord'; vraagId: string; value: string }
|
||||
| { tag: 'Next' }
|
||||
| { tag: 'Back' }
|
||||
| { tag: 'GaNaarStap'; cursor: number }
|
||||
| { tag: 'Submit' }
|
||||
| { tag: 'Retry' }
|
||||
| { tag: 'SubmitConfirmed'; referentie: string }
|
||||
| { tag: 'SubmitFailed'; error: string }
|
||||
| { tag: 'Upload'; msg: UploadMsg }
|
||||
| { tag: 'Seed'; state: RegistratieState };
|
||||
|
||||
export function reduce(s: RegistratieState, m: RegistratieMsg): RegistratieState {
|
||||
switch (m.tag) {
|
||||
case 'SetField':
|
||||
return setField(s, m.key, m.value);
|
||||
case 'SetCorrespondentie':
|
||||
return setCorrespondentie(s, m.value);
|
||||
case 'PrefillAdres':
|
||||
return prefillAdres(s, m.straat, m.postcode, m.woonplaats);
|
||||
case 'KiesDiploma':
|
||||
return kiesDiploma(s, m.diplomaId, m.beroep, m.vraagIds);
|
||||
case 'KiesHandmatig':
|
||||
return kiesHandmatig(s, m.vraagIds);
|
||||
case 'DeclareerBeroep':
|
||||
return declareerBeroep(s, m.beroep);
|
||||
case 'SetAntwoord':
|
||||
return setAntwoord(s, m.vraagId, m.value);
|
||||
case 'Next':
|
||||
return next(s);
|
||||
case 'Back':
|
||||
return back(s);
|
||||
case 'GaNaarStap':
|
||||
return gaNaarStap(s, m.cursor);
|
||||
case 'Submit':
|
||||
return submit(s);
|
||||
case 'Retry':
|
||||
return s.tag === 'Mislukt' ? { tag: 'Indienen', data: s.data } : s;
|
||||
case 'SubmitConfirmed':
|
||||
return s.tag === 'Indienen'
|
||||
? { tag: 'Ingediend', data: s.data, referentie: m.referentie }
|
||||
: s;
|
||||
case 'SubmitFailed':
|
||||
return s.tag === 'Indienen' ? { tag: 'Mislukt', data: s.data, error: m.error } : s;
|
||||
case 'Upload':
|
||||
return upload(s, m.msg);
|
||||
case 'Seed':
|
||||
return m.state;
|
||||
default:
|
||||
return assertNever(m);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
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.
|
||||
SERVER-OWNED RULE: this now runs on the backend (BFF), which ships the result
|
||||
as `decisions.eligibleForHerregistratie` in the dashboard view. Kept here as
|
||||
the reference implementation + unit test; the frontend no longer calls it. */
|
||||
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;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user