Merge refactor/readable-codebase — RD-01..RD-29 + RD-37/38
CI / changes (push) Successful in 10s
CI / lint (push) Successful in 1m14s
CI / frontend (push) Successful in 2m15s
CI / backend (push) Successful in 2m7s
CI / e2e (push) Failing after 3m37s
CI / semgrep (push) Successful in 1m4s
CI / api-client-drift (push) Successful in 1m45s
CI / storybook-a11y (push) Successful in 10m43s

The readability arc: a max-lines guard with self-cleaning exemptions, the
createStore effect map, RemoteData/lifecycle consolidation, the ticket-reference
sweep across apps, libs and backend, six oversized files split by concern, the
libs/shared/ui move into atoms/molecules/organisms, and the atomic ladder
enforced in dependency-cruiser.

RD-16 was dropped: the instruction would have discarded server decisions.
RD-30..RD-36 stay open — documentation updates plus two optional refactors.
This commit is contained in:
eho
2026-09-06 07:11:44 +02:00
370 changed files with 10355 additions and 2688 deletions
+23
View File
@@ -109,6 +109,29 @@ module.exports = function buildConfig(contextAllowed, appName, tsConfigFileName)
}, },
}, },
// --- Atomic ladder within libs/shared/src/ui (folder = layer, CLAUDE.md decision 2) ---
{
name: 'atoms-compose-nothing-above',
comment: 'An atom composes nothing above it — no molecule or organism. See CLAUDE.md §2.',
severity: 'error',
from: { path: '^libs/shared/src/ui/atoms/' },
to: { path: '^libs/shared/src/ui/(molecules|organisms)/' },
},
{
name: 'molecules-below-organisms',
comment: 'A molecule composes nothing above it — no organism. See CLAUDE.md §2.',
severity: 'error',
from: { path: '^libs/shared/src/ui/molecules/' },
to: { path: '^libs/shared/src/ui/organisms/' },
},
{
name: 'design-system-not-layout',
comment: 'The design system (ui/) does not depend on layout/ templates. See CLAUDE.md §2.',
severity: 'error',
from: { path: '^libs/shared/src/ui/' },
to: { path: '^libs/shared/src/layout/' },
},
{ {
name: 'no-testing-in-production', name: 'no-testing-in-production',
comment: comment:
+5 -1
View File
@@ -2,9 +2,13 @@
// scaffolding one (see `gen:context`, WP-44). // scaffolding one (see `gen:context`, WP-44).
module.exports = require('./.dependency-cruiser.base.js')( module.exports = require('./.dependency-cruiser.base.js')(
{ {
// Two sanctioned cross-feature edges, both pointing at registratie: the portal home
// composes registratie's dashboard sections (RD-03), and herregistratie builds on a
// registration. Every other context imports only libs/shared and libs/beheer.
overzicht: ['registratie'],
auth: [], auth: [],
registratie: [], registratie: [],
herregistratie: ['registratie'], // the one sanctioned cross-feature edge herregistratie: ['registratie'],
brief: [], brief: [],
showcase: null, // unrestricted — the sanctioned teaching page; nothing imports it showcase: null, // unrestricted — the sanctioned teaching page; nothing imports it
}, },
+4 -4
View File
@@ -210,10 +210,10 @@ each app has its **own Storybook instance** (`.storybook-ssp/`, `.storybook-beha
WP-67 — a single merged tsconfig can't resolve both apps' `@auth/*` at once), each globbing WP-67 — a single merged tsconfig can't resolve both apps' `@auth/*` at once), each globbing
its own app's stories plus both shared libraries'. **Story titles mirror the sidebar's its own app's stories plus both shared libraries'. **Story titles mirror the sidebar's
Design System/Domein split** (see `libs/shared/docs/layers.mdx`): a `libs/shared/ui|layout` Design System/Domein split** (see `libs/shared/docs/layers.mdx`): a `libs/shared/ui|layout`
or `libs/beheer/ui` component is titled `Design System/<Atoms|Molecules|Organisms|Templates|Devtools>/<Name>`; component is titled `Design System/<Atoms|Molecules|Organisms|Templates|Devtools>/<Name>`;
a component in an app context's `ui/` is titled `Domein/<Context>/<Name>` — full stop, a component in an app context's `ui/`, or in `libs/beheer/ui`, is titled
regardless of which atomic layer it is (a context organism doesn't get its own `Domein/<Context>/<Name>` — full stop, regardless of which atomic layer it is (a context
`Organisms/` bucket). organism doesn't get its own `Organisms/` bucket).
## Conventions ## Conventions
+3 -3
View File
@@ -20,7 +20,7 @@ export const routes: Routes = [
}, },
{ {
path: 'aanvraag/:id', path: 'aanvraag/:id',
// Same capability the werkvoorraad list itself is gated by (WP-64/65) — the // Same capability the werkvoorraad list itself is gated by — the
// detail page is reachable only from a row already filtered to that capability. // detail page is reachable only from a row already filtered to that capability.
canActivate: [capabilityGuard('aanvraag:beoordelen')], canActivate: [capabilityGuard('aanvraag:beoordelen')],
loadComponent: () => loadComponent: () =>
@@ -36,14 +36,14 @@ export const routes: Routes = [
}, },
{ {
path: 'beheer/audit', path: 'beheer/audit',
// Admin-only authz/PII-reveal audit trail (WP-41/42). capabilityGuard denies-by-default // Admin-only authz/PII-reveal audit trail. capabilityGuard denies-by-default
// unless GET /me resolved `cases:manage` (reused for audit read). Backend re-enforces. // unless GET /me resolved `cases:manage` (reused for audit read). Backend re-enforces.
canActivate: [capabilityGuard('cases:manage')], canActivate: [capabilityGuard('cases:manage')],
loadComponent: () => import('@beheer/ui/audit.page').then((m) => m.AuditPage), loadComponent: () => import('@beheer/ui/audit.page').then((m) => m.AuditPage),
}, },
{ {
path: 'beheer/functies', path: 'beheer/functies',
// Admin-only feature-flag toggles (WP-47), gated by `flags:manage`. // Admin-only feature-flag toggles, gated by `flags:manage`.
canActivate: [capabilityGuard('flags:manage')], canActivate: [capabilityGuard('flags:manage')],
loadComponent: () => loadComponent: () =>
import('@beheer/ui/feature-flags.page').then((m) => m.FeatureFlagsPage), import('@beheer/ui/feature-flags.page').then((m) => m.FeatureFlagsPage),
@@ -4,7 +4,7 @@ import { MEDEWERKER_ID, currentRollen } from './medewerker';
/** /**
* Infrastructure: resolves the current medewerker identity into a `Principal` * Infrastructure: resolves the current medewerker identity into a `Principal`
* (ADR-C-004/RB-13). Stands in for a real employee-SSO redirect flow (ADR-0002 §3, * (ADR-C-004). Stands in for a real employee-SSO redirect flow (ADR-0002 §3,
* "out of scope here") — there is no credential to enter and, unlike `DigidAdapter`'s * "out of scope here") — there is no credential to enter and, unlike `DigidAdapter`'s
* BSN check, no format to reject, so `authenticate()` takes no input and returns the * BSN check, no format to reject, so `authenticate()` takes no input and returns the
* `Principal` directly rather than a `Result` with an error variant that can never * `Principal` directly rather than a `Result` with an error variant that can never
@@ -1,8 +1,8 @@
import { Component, output } from '@angular/core'; import { Component, output } from '@angular/core';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
/** /**
* Organism: employee-SSO-style mock login (ADR-C-004/RB-13). No real auth — and, * Organism: employee-SSO-style mock login (ADR-C-004). No real auth — and,
* unlike the SSP's DigiD form, no credential to enter at all: a Behandelaar has no * unlike the SSP's DigiD form, no credential to enter at all: a Behandelaar has no
* BSN, and this app has no password of its own to check either way. There is * BSN, and this app has no password of its own to check either way. There is
* nothing to compose beyond one button, which is itself evidence for the ADR — the * nothing to compose beyond one button, which is itself evidence for the ADR — the
@@ -8,7 +8,7 @@ import {
type Err = Error | undefined; type Err = Error | undefined;
/** One aanvraag's beoordeling detail (WP-65) — a root singleton like `WerkvoorraadStore`. /** One aanvraag's beoordeling detail — a root singleton like `WerkvoorraadStore`.
Keyed by id: navigating to a different case resets to Loading. */ Keyed by id: navigating to a different case resets to Loading. */
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class BeoordelingStore { export class BeoordelingStore {
@@ -8,7 +8,7 @@ import {
type Err = Error | undefined; type Err = Error | undefined;
/** The behandelaar's queue (WP-64) — a root singleton like `AdminCasesStore`'s ssp /** The behandelaar's queue — a root singleton like `AdminCasesStore`'s ssp
counterpart. Fetch + parse at the trust boundary, publish as RemoteData. */ counterpart. Fetch + parse at the trust boundary, publish as RemoteData. */
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class WerkvoorraadStore { export class WerkvoorraadStore {
@@ -2,8 +2,8 @@ import { formatDatumNl } from '@shared/kernel/datum';
import { AanvraagType } from './werkvoorraad-item'; import { AanvraagType } from './werkvoorraad-item';
import { BeoordelingStatus, BeoordelingView } from './beoordeling'; import { BeoordelingStatus, BeoordelingView } from './beoordeling';
/** View-model mapping shared by the werkvoorraad list (WP-64) and the beoordeling /** View-model mapping shared by the werkvoorraad list and the beoordeling
detail screen (WP-65): type/status → labels. Pure, no Angular. Lives here (not in detail screen: type/status → labels. Pure, no Angular. Lives here (not in
`werkvoorraad-item-view.ts`) because `BeoordelingStatus` is the wider of the two `werkvoorraad-item-view.ts`) because `BeoordelingStatus` is the wider of the two
status unions — `werkvoorraad-item-view.ts` re-exports these for its own use. */ status unions — `werkvoorraad-item-view.ts` re-exports these for its own use. */
@@ -1,8 +1,8 @@
import { AanvraagType } from './werkvoorraad-item'; import { AanvraagType } from './werkvoorraad-item';
/** /**
* A case's full status lifecycle as the beoordeling detail screen sees it (WP-65) — * A case's full status lifecycle as the beoordeling detail screen sees it —
* wider than `WerkvoorraadStatus` (WP-64), which only ever sees the two "still open" * wider than `WerkvoorraadStatus`, which only ever sees the two "still open"
* tags. This is the same five-tag union ssp's `AanvraagStatus` models (minus `Concept` * tags. This is the same five-tag union ssp's `AanvraagStatus` models (minus `Concept`
* — the detail endpoint 404s a Concept, it isn't a case a behandelaar can treat yet). * — the detail endpoint 404s a Concept, it isn't a case a behandelaar can treat yet).
*/ */
@@ -1,6 +1,6 @@
import { Result, assertNever } from '@shared/kernel/fp'; import { Result, assertNever } from '@shared/kernel/fp';
/** The three actions the beoordeling screen offers a behandelaar (WP-65b) — mirrors the /** The three actions the beoordeling screen offers a behandelaar — mirrors the
backend's `Besluit` enum member names 1:1 (the wire convention: a string, not a raw backend's `Besluit` enum member names 1:1 (the wire convention: a string, not a raw
enum — see `RecordBesluitRequest`). */ enum — see `RecordBesluitRequest`). */
const BESLUIT_TAGS = ['Goedkeuren', 'Afwijzen', 'MeerInfoOpvragen'] as const; const BESLUIT_TAGS = ['Goedkeuren', 'Afwijzen', 'MeerInfoOpvragen'] as const;
@@ -1,5 +1,5 @@
/** /**
* A queue entry as the behandelportal sees it (WP-64) — the parsed, domain-side view * A queue entry as the behandelportal sees it — the parsed, domain-side view
* of the backend's cross-owner `GET /werkvoorraad`. Pure types, no Angular. * of the backend's cross-owner `GET /werkvoorraad`. Pure types, no Angular.
* *
* The status union is narrower than the SSP's full `AanvraagStatus` (ssp's * The status union is narrower than the SSP's full `AanvraagStatus` (ssp's
@@ -13,7 +13,7 @@ import {
import { AanvraagType } from '@behandeling/domain/werkvoorraad-item'; import { AanvraagType } from '@behandeling/domain/werkvoorraad-item';
/** /**
* Infrastructure adapter for the beoordeling detail read (WP-65) — the only place its * Infrastructure adapter for the beoordeling detail read — the only place its
* HTTP lives (ADR-0001 anti-corruption boundary). The untrusted response is validated + * HTTP lives (ADR-0001 anti-corruption boundary). The untrusted response is validated +
* mapped to domain by the parse* boundary below. * mapped to domain by the parse* boundary below.
*/ */
@@ -3,7 +3,7 @@ import { ApiClient } from '@shared/infrastructure/api-client';
import { Valid } from '@behandeling/domain/besluit.machine'; import { Valid } from '@behandeling/domain/besluit.machine';
/** /**
* Infrastructure adapter for recording a behandelaar's decision (WP-65b) — the single * Infrastructure adapter for recording a behandelaar's decision — the single
* place its HTTP lives. No return value: a successful call means the server accepted * place its HTTP lives. No return value: a successful call means the server accepted
* the transition; the caller reloads `BeoordelingStore` to see the new status (the * the transition; the caller reloads `BeoordelingStore` to see the new status (the
* server, not this adapter, re-validates and is the authority). * server, not this adapter, re-validates and is the authority).
@@ -8,7 +8,7 @@ import {
} from '@behandeling/domain/werkvoorraad-item'; } from '@behandeling/domain/werkvoorraad-item';
/** /**
* Infrastructure adapter for the behandelportal's queue read (WP-64) — the only * Infrastructure adapter for the behandelportal's queue read — the only
* place its HTTP lives (ADR-0001 anti-corruption boundary). The untrusted response * place its HTTP lives (ADR-0001 anti-corruption boundary). The untrusted response
* is validated + mapped to the (narrower) queue domain shape by the parse* boundary * is validated + mapped to the (narrower) queue domain shape by the parse* boundary
* below; a case whose status isn't `Ingediend`/`InBehandeling` is a parse error, not * below; a case whose status isn't `Ingediend`/`InBehandeling` is a parse error, not
@@ -1,7 +1,7 @@
import { Component, input } from '@angular/core'; import { Component, input } from '@angular/core';
import { BeoordelingDocument } from '@behandeling/domain/beoordeling'; import { BeoordelingDocument } from '@behandeling/domain/beoordeling';
/** Organism: the documents linked to an aanvraag (WP-65) — plain links to the existing /** Organism: the documents linked to an aanvraag — plain links to the existing
(pre-existing, unauthenticated — same as ssp's own document previews) content (pre-existing, unauthenticated — same as ssp's own document previews) content
endpoint. No new shared atom: a context-local list, not a reusable building block. */ endpoint. No new shared atom: a context-local list, not a reusable building block. */
@Component({ @Component({
@@ -1,20 +1,21 @@
import { Component, computed, inject } from '@angular/core'; import { Component, computed, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { successOf } from '@shared/application/remote-data';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component'; import { SkeletonComponent } from '@shared/ui/atoms/skeleton/skeleton.component';
import { DataBlockComponent } from '@shared/ui/data-block/data-block.component'; import { DataBlockComponent } from '@shared/ui/molecules/data-block/data-block.component';
import { DataRowComponent } from '@shared/ui/data-row/data-row.component'; import { DataRowComponent } from '@shared/ui/molecules/data-row/data-row.component';
import { ASYNC } from '@shared/ui/async/async.component'; import { ASYNC } from '@shared/ui/molecules/async/async.component';
import { BeoordelingStore } from '@behandeling/application/beoordeling.store'; import { BeoordelingStore } from '@behandeling/application/beoordeling.store';
import { detailRows } from '@behandeling/domain/beoordeling-view'; import { detailRows } from '@behandeling/domain/beoordeling-view';
import { BeoordelingDocumentenComponent } from '@behandeling/ui/beoordeling-documenten/beoordeling-documenten.component'; import { BeoordelingDocumentenComponent } from '@behandeling/ui/beoordeling-documenten/beoordeling-documenten.component';
import { BesluitFormComponent } from '@behandeling/ui/besluit-form/besluit-form.component'; import { BesluitFormComponent } from '@behandeling/ui/besluit-form/besluit-form.component';
/** /**
* Page: one aanvraag's beoordeling detail (WP-65). The werkvoorraad list (WP-64) links * Page: one aanvraag's beoordeling detail. The werkvoorraad list links
* here. `canBesluiten` (server-computed, ADR-0001) gates the decision form (WP-65b) — * here. `canBesluiten` (server-computed, ADR-0001) gates the decision form —
* the page never recomputes the lifecycle itself. On a recorded decision the form emits * the page never recomputes the lifecycle itself. On a recorded decision the form emits
* `decided`, and the page just reloads (the server is the authority on the new status). * `decided`, and the page just reloads (the server is the authority on the new status).
*/ */
@@ -73,10 +74,7 @@ export class BeoordelingPage {
protected retryText = $localize`:@@beoordeling.retry:Opnieuw proberen`; protected retryText = $localize`:@@beoordeling.retry:Opnieuw proberen`;
protected rows = detailRows; protected rows = detailRows;
protected readonly view = computed(() => { protected readonly view = computed(() => successOf(this.store.view()));
const rd = this.store.view();
return rd.tag === 'Success' ? rd.value : undefined;
});
constructor() { constructor() {
void this.store.load(this.id); void this.store.load(this.id);
@@ -1,18 +1,23 @@
import { Component, computed, input, output } from '@angular/core'; import { Component, computed, input, output } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { HeadingComponent } from '@shared/ui/heading/heading.component'; import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component'; import { FormFieldComponent } from '@shared/ui/molecules/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component'; import { TextInputComponent } from '@shared/ui/atoms/text-input/text-input.component';
import { RadioGroupComponent, RadioOption } from '@shared/ui/radio-group/radio-group.component'; import {
RadioGroupComponent,
RadioOption,
} from '@shared/ui/atoms/radio-group/radio-group.component';
import { DataBlockComponent } from '@shared/ui/molecules/data-block/data-block.component';
import { DataRowComponent } from '@shared/ui/molecules/data-row/data-row.component';
import { createStore } from '@shared/application/store'; import { createStore } from '@shared/application/store';
import { whenTag } from '@shared/kernel/fp'; import { whenTag } from '@shared/kernel/fp';
import { BesluitState, BesluitMsg, initial, reduce } from '@behandeling/domain/besluit.machine'; import { BesluitState, BesluitMsg, initial, reduce } from '@behandeling/domain/besluit.machine';
import { createSubmitBesluit } from '@behandeling/application/submit-besluit'; import { createSubmitBesluit } from '@behandeling/application/submit-besluit';
/** /**
* Organism: the decision form (WP-65b) — goedkeuren/afwijzen/meer-info-opvragen. Same * Organism: the decision form — goedkeuren/afwijzen/meer-info-opvragen. Same
* idiom as every other form in this house (`change-request-form`): all state in one * idiom as every other form in this house (`change-request-form`): all state in one
* signal driven by the pure `reduce` (besluit.machine.ts), submitted via a `submit-*` * signal driven by the pure `reduce` (besluit.machine.ts), submitted via a `submit-*`
* command returning `Result`. The server re-validates the transition and is the * command returning `Result`. The server re-validates the transition and is the
@@ -29,10 +34,35 @@ import { createSubmitBesluit } from '@behandeling/application/submit-besluit';
FormFieldComponent, FormFieldComponent,
TextInputComponent, TextInputComponent,
RadioGroupComponent, RadioGroupComponent,
DataBlockComponent,
DataRowComponent,
], ],
template: ` template: `
@if (state().tag === 'Submitted') { @if (state().tag === 'Submitted') {
<app-alert type="ok" i18n="@@besluit.success">Het besluit is vastgelegd.</app-alert> <app-alert type="ok" i18n="@@besluit.success">Het besluit is vastgelegd.</app-alert>
} @else if (state().tag === 'Failed') {
<app-heading [level]="2" i18n="@@besluit.heading">Besluit vastleggen</app-heading>
<app-alert type="error"
><ng-container i18n="@@besluit.failed">Het vastleggen is niet gelukt:</ng-container>
{{ failedError() }}</app-alert
>
<app-data-block class="app-section" [ariaLabel]="besluitLabelText">
<div app-data-row [key]="besluitLabelText" [value]="besluitOptieLabel()"></div>
@if (toelichting()) {
<div app-data-row [key]="toelichtingLabelText" [value]="toelichting()"></div>
}
</app-data-block>
<div class="app-section">
<app-button
variant="secondary"
(click)="dispatch({ tag: 'Retry' })"
i18n="@@wizard.opnieuwProberen"
>Opnieuw proberen</app-button
>
</div>
} @else { } @else {
<app-heading [level]="2" i18n="@@besluit.heading">Besluit vastleggen</app-heading> <app-heading [level]="2" i18n="@@besluit.heading">Besluit vastleggen</app-heading>
@@ -70,13 +100,6 @@ import { createSubmitBesluit } from '@behandeling/application/submit-besluit';
/> />
</app-form-field> </app-form-field>
@if (failedError()) {
<app-alert type="error"
><ng-container i18n="@@besluit.failed">Het vastleggen is niet gelukt:</ng-container>
{{ failedError() }}</app-alert
>
}
<app-button type="submit" variant="primary" [disabled]="state().tag === 'Submitting'"> <app-button type="submit" variant="primary" [disabled]="state().tag === 'Submitting'">
{{ state().tag === 'Submitting' ? submitBezigLabel : submitLabel }} {{ state().tag === 'Submitting' ? submitBezigLabel : submitLabel }}
</app-button> </app-button>
@@ -86,7 +109,19 @@ import { createSubmitBesluit } from '@behandeling/application/submit-besluit';
}) })
export class BesluitFormComponent { export class BesluitFormComponent {
private submit = createSubmitBesluit(); private submit = createSubmitBesluit();
private store = createStore<BesluitState, BesluitMsg>(initial, reduce); // Effect fires once, on Editing -> Submitting (RD-05's tag-transition rule; `Seed` is
// exempt, so a story mounting straight into `Submitting` does not call the network).
private store = createStore<BesluitState, BesluitMsg>(initial, reduce, {
Submitting: async (s, store) => {
const r = await this.submit(this.id(), s.data);
if (r.ok) {
store.dispatch({ tag: 'SubmitConfirmed' });
this.decided.emit();
} else {
store.dispatch({ tag: 'SubmitFailed', error: r.error });
}
},
});
id = input.required<string>(); id = input.required<string>();
decided = output<void>(); decided = output<void>();
@@ -109,12 +144,33 @@ export class BesluitFormComponent {
protected readonly submitLabel = $localize`:@@besluit.submit:Besluit vastleggen`; protected readonly submitLabel = $localize`:@@besluit.submit:Besluit vastleggen`;
protected readonly submitBezigLabel = $localize`:@@besluit.submitBezig:Bezig met vastleggen…`; protected readonly submitBezigLabel = $localize`:@@besluit.submitBezig:Bezig met vastleggen…`;
// Same ids as the form-field labels above, reused for the Failed data-block's row
// keys (the pattern change-request-form already uses for its read-only BRP rows).
protected readonly besluitLabelText = $localize`:@@besluit.besluitLabel:Besluit`;
protected readonly toelichtingLabelText = $localize`:@@besluit.toelichtingLabel:Toelichting`;
private editing = computed(() => whenTag(this.state(), 'Editing')); private editing = computed(() => whenTag(this.state(), 'Editing'));
protected errors = computed(() => this.editing()?.errors ?? {}); protected errors = computed(() => this.editing()?.errors ?? {});
protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? ''); protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? '');
protected besluit = computed(() => this.editing()?.draft.besluit ?? ''); /** The value shown in the field — the live draft while editing, the parsed value
protected toelichting = computed(() => this.editing()?.draft.toelichting ?? ''); while submitting/failed (so the user sees what they sent, same idiom as
change-request-form.telefoon()). */
protected besluit = computed(() => {
const s = this.state();
if (s.tag === 'Editing') return s.draft.besluit;
if (s.tag === 'Submitting' || s.tag === 'Failed') return s.data.besluit;
return '';
});
protected toelichting = computed(() => {
const s = this.state();
if (s.tag === 'Editing') return s.draft.toelichting;
if (s.tag === 'Submitting' || s.tag === 'Failed') return s.data.toelichting ?? '';
return '';
});
protected besluitOptieLabel = computed(
() => this.BESLUIT_OPTIONS.find((o) => o.value === this.besluit())?.label ?? '',
);
constructor() { constructor() {
queueMicrotask(() => this.dispatch({ tag: 'Seed', state: this.seed() })); queueMicrotask(() => this.dispatch({ tag: 'Seed', state: this.seed() }));
@@ -122,19 +178,5 @@ export class BesluitFormComponent {
onSubmit() { onSubmit() {
this.dispatch({ tag: 'Submit' }); this.dispatch({ tag: 'Submit' });
this.runIfSubmitting();
}
/** Effect: when we entered Submitting, call the command, then dispatch the outcome. */
private async runIfSubmitting() {
const s = this.state();
if (s.tag !== 'Submitting') return;
const r = await this.submit(this.id(), s.data);
if (r.ok) {
this.dispatch({ tag: 'SubmitConfirmed' });
this.decided.emit();
} else {
this.dispatch({ tag: 'SubmitFailed', error: r.error });
}
} }
} }
@@ -1,12 +1,12 @@
import { Component, input } from '@angular/core'; import { Component, input } from '@angular/core';
import { ApplicationListComponent } from '@shared/ui/application-list/application-list.component'; import { ApplicationListComponent } from '@shared/ui/molecules/application-list/application-list.component';
import { ApplicationLinkComponent } from '@shared/ui/application-link/application-link.component'; import { ApplicationLinkComponent } from '@shared/ui/molecules/application-link/application-link.component';
import { WerkvoorraadItem } from '@behandeling/domain/werkvoorraad-item'; import { WerkvoorraadItem } from '@behandeling/domain/werkvoorraad-item';
import { werkvoorraadRow } from '@behandeling/domain/werkvoorraad-item-view'; import { werkvoorraadRow } from '@behandeling/domain/werkvoorraad-item-view';
/** Organism: the behandelaar's queue as CIBG "aanvragen" rows (WP-64) — composition /** Organism: the behandelaar's queue as CIBG "aanvragen" rows — composition
of the two existing shared/ui molecules, no new atom. Each row links to the of the two existing shared/ui molecules, no new atom. Each row links to the
beoordeling detail page (WP-65). */ beoordeling detail page. */
@Component({ @Component({
selector: 'app-werkvoorraad-list', selector: 'app-werkvoorraad-list',
imports: [ApplicationListComponent, ApplicationLinkComponent], imports: [ApplicationListComponent, ApplicationLinkComponent],
@@ -1,18 +1,19 @@
import { Component, computed, effect, inject } from '@angular/core'; import { Component, computed, effect, inject } from '@angular/core';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component'; import { SkeletonComponent } from '@shared/ui/atoms/skeleton/skeleton.component';
import { ASYNC } from '@shared/ui/async/async.component'; import { ASYNC } from '@shared/ui/molecules/async/async.component';
import { AccessStore } from '@shared/application/access.store'; import { AccessStore } from '@shared/application/access.store';
import { successOr } from '@shared/application/remote-data';
import { WerkvoorraadStore } from '@behandeling/application/werkvoorraad.store'; import { WerkvoorraadStore } from '@behandeling/application/werkvoorraad.store';
import { WerkvoorraadListComponent } from '@behandeling/ui/werkvoorraad-list/werkvoorraad-list.component'; import { WerkvoorraadListComponent } from '@behandeling/ui/werkvoorraad-list/werkvoorraad-list.component';
/** /**
* Page: the behandelaar's werkvoorraad (WP-64) — the behandelportal's landing page. * Page: the behandelaar's werkvoorraad — the behandelportal's landing page.
* Deny-by-default capability gate (`aanvraag:beoordelen`), same idiom as ssp's * Deny-by-default capability gate (`aanvraag:beoordelen`), same idiom as ssp's
* AdminCasesPage: a denial alert for a non-behandelaar, the queue for one. Opening * AdminCasesPage: a denial alert for a non-behandelaar, the queue for one. Opening
* a case's detail is out of scope here (WP-65). * a case's detail is out of scope here.
*/ */
@Component({ @Component({
selector: 'app-werkvoorraad-page', selector: 'app-werkvoorraad-page',
@@ -56,10 +57,7 @@ export class WerkvoorraadPage {
protected access = inject(AccessStore); protected access = inject(AccessStore);
protected canBeoordelen = computed(() => this.access.can('aanvraag:beoordelen')); protected canBeoordelen = computed(() => this.access.can('aanvraag:beoordelen'));
protected items = computed(() => { protected items = computed(() => successOr(this.store.items(), []));
const rd = this.store.items();
return rd.tag === 'Success' ? rd.value : [];
});
protected heading = $localize`:@@werkvoorraad.heading:Werkvoorraad`; protected heading = $localize`:@@werkvoorraad.heading:Werkvoorraad`;
protected intro = $localize`:@@werkvoorraad.intro:Aanvragen die op beoordeling wachten.`; protected intro = $localize`:@@werkvoorraad.intro:Aanvragen die op beoordeling wachten.`;
@@ -71,7 +69,7 @@ export class WerkvoorraadPage {
private loadRequested = false; private loadRequested = false;
constructor() { constructor() {
// Load once the capability resolves to allowed (a 403 GET would be wasted otherwise) — // Load once the capability resolves to allowed (a 403 GET would be wasted otherwise) —
// same guard-against-the-loop idiom as AdminCasesPage (WP-26 lesson). // same guard-against-the-loop idiom as AdminCasesPage.
effect(() => { effect(() => {
if (this.canBeoordelen() && !this.loadRequested) { if (this.canBeoordelen() && !this.loadRequested) {
this.loadRequested = true; this.loadRequested = true;
@@ -8,7 +8,7 @@ export const NAV_ITEMS: readonly HeaderNavItem[] = [
/** This app's admin pages — provided to the shared site header via HEADER_ADMIN_LINKS. /** This app's admin pages — provided to the shared site header via HEADER_ADMIN_LINKS.
No huisstijl (that's the SSP's brief context) or zaken entry — inherited as-is from No huisstijl (that's the SSP's brief context) or zaken entry — inherited as-is from
WP-61's bootstrap trim, not revisited by this migration. */ the bootstrap trim, not revisited by this migration. */
export const ADMIN_LINKS: readonly AdminLink[] = [ export const ADMIN_LINKS: readonly AdminLink[] = [
{ {
label: $localize`:@@header.nav.stamdata:Stamdata`, label: $localize`:@@header.nav.stamdata:Stamdata`,
+7 -5
View File
@@ -14,9 +14,11 @@ export const routes: Routes = [
loadComponent: () => import('@auth/ui/login.page').then((m) => m.LoginPage), loadComponent: () => import('@auth/ui/login.page').then((m) => m.LoginPage),
}, },
{ {
// Path stays 'dashboard' on purpose: it is a user-visible URL and four e2e
// specs assert it. The context is `overzicht`; only the path string differs.
path: 'dashboard', path: 'dashboard',
canActivate: [authGuard], canActivate: [authGuard],
loadComponent: () => import('@registratie/ui/dashboard.page').then((m) => m.DashboardPage), loadComponent: () => import('@overzicht/ui/overzicht.page').then((m) => m.OverzichtPage),
}, },
{ {
path: 'registratie', path: 'registratie',
@@ -59,7 +61,7 @@ export const routes: Routes = [
}, },
{ {
path: 'brief/huisstijl', path: 'brief/huisstijl',
// Admin-only org-template editor (WP-26): capabilityGuard denies-by-default // Admin-only org-template editor: capabilityGuard denies-by-default
// unless GET /me resolved `orgtemplate:edit` (Admin role). Backend re-enforces // 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. // via the OrgAdmin gate — the guard just avoids loading a page that would 403.
canActivate: [capabilityGuard('orgtemplate:edit')], canActivate: [capabilityGuard('orgtemplate:edit')],
@@ -76,7 +78,7 @@ export const routes: Routes = [
}, },
{ {
path: 'beheer/zaken', path: 'beheer/zaken',
// Admin-only cases overview + delete (WP-36): capabilityGuard denies-by-default // Admin-only cases overview + delete: capabilityGuard denies-by-default
// unless GET /me resolved `cases:manage` (Admin role). Backend re-enforces via the // 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 // 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. // lives in registratie/ui (which owns the Aanvraag aggregate); routed under /beheer.
@@ -86,14 +88,14 @@ export const routes: Routes = [
}, },
{ {
path: 'beheer/audit', path: 'beheer/audit',
// Admin-only authz/PII-reveal audit trail (WP-41/42). capabilityGuard denies-by-default // Admin-only authz/PII-reveal audit trail. capabilityGuard denies-by-default
// unless GET /me resolved `cases:manage` (reused for audit read). Backend re-enforces. // unless GET /me resolved `cases:manage` (reused for audit read). Backend re-enforces.
canActivate: [capabilityGuard('cases:manage')], canActivate: [capabilityGuard('cases:manage')],
loadComponent: () => import('@beheer/ui/audit.page').then((m) => m.AuditPage), loadComponent: () => import('@beheer/ui/audit.page').then((m) => m.AuditPage),
}, },
{ {
path: 'beheer/functies', path: 'beheer/functies',
// Admin-only feature-flag toggles (WP-47), gated by `flags:manage`. // Admin-only feature-flag toggles, gated by `flags:manage`.
canActivate: [capabilityGuard('flags:manage')], canActivate: [capabilityGuard('flags:manage')],
loadComponent: () => loadComponent: () =>
import('@beheer/ui/feature-flags.page').then((m) => m.FeatureFlagsPage), import('@beheer/ui/feature-flags.page').then((m) => m.FeatureFlagsPage),
@@ -7,7 +7,7 @@ import { Principal } from '../domain/principal';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class DigidAdapter { export class DigidAdapter {
// ponytail: fake DigiD — any elfproef-valid BSN authenticates to a fixed identity. // 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 // Real BSN validation (parseBsn) is the trust boundary; swap the fixed identity
// for a real OIDC redirect flow when there's an IdP. // for a real OIDC redirect flow when there's an IdP.
async authenticate(bsn: string): Promise<Result<string, Principal>> { async authenticate(bsn: string): Promise<Result<string, Principal>> {
const r = parseBsn(bsn); const r = parseBsn(bsn);
@@ -1,8 +1,8 @@
import { Component, output } from '@angular/core'; import { Component, output } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component'; import { FormFieldComponent } from '@shared/ui/molecules/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component'; import { TextInputComponent } from '@shared/ui/atoms/text-input/text-input.component';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
/** Organism: DigiD-style mock login. No real auth — just composes atoms/molecules. */ /** Organism: DigiD-style mock login. No real auth — just composes atoms/molecules. */
@Component({ @Component({
+1 -1
View File
@@ -1,7 +1,7 @@
import { Component, inject, signal } from '@angular/core'; import { Component, inject, signal } from '@angular/core';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { LoginFormComponent } from '@auth/ui/login-form/login-form.component'; import { LoginFormComponent } from '@auth/ui/login-form/login-form.component';
import { SessionStore } from '@auth/application/session.store'; import { SessionStore } from '@auth/application/session.store';
@@ -54,7 +54,7 @@ const caseContext: CaseContext = {
const view: BriefView = { brief, availablePassages: [], decisions, orgTemplate, caseContext }; const view: BriefView = { brief, availablePassages: [], decisions, orgTemplate, caseContext };
/** A recording fake of BLOB_PRESENTER (RB-28/TE-006) — records every call instead of /** A recording fake of BLOB_PRESENTER (TE-006) — records every call instead of
touching the DOM, so a spec can assert a command's success path directly. */ touching the DOM, so a spec can assert a command's success path directly. */
function fakeBlobPresenter() { function fakeBlobPresenter() {
const opened: Blob[] = []; const opened: Blob[] = [];
@@ -158,7 +158,7 @@ describe('BriefStore action state (Idle | Busy | Failed)', () => {
}); });
}); });
// --- WP-27: undo/redo history + rejection diff --- // --- Undo/redo history + rejection diff ---
function block(id: string, text: string): LetterBlock { function block(id: string, text: string): LetterBlock {
return { return {
@@ -179,7 +179,7 @@ const filledView: BriefView = { ...view, brief: filledBrief };
function loadedBrief(store: BriefStore): Brief { function loadedBrief(store: BriefStore): Brief {
const s = store.model(); const s = store.model();
if (s.tag !== 'loaded') throw new Error('not loaded'); if (s.tag !== 'Loaded') throw new Error('not loaded');
return s.brief; return s.brief;
} }
@@ -308,7 +308,7 @@ describe('BriefStore rejection diff', () => {
describe('BriefStore.previewLetter', () => { describe('BriefStore.previewLetter', () => {
afterEach(() => vi.restoreAllMocks()); afterEach(() => vi.restoreAllMocks());
it('opens the composed letter via BLOB_PRESENTER on success (RB-28)', async () => { it('opens the composed letter via BLOB_PRESENTER on success', async () => {
const { presenter, opened } = fakeBlobPresenter(); const { presenter, opened } = fakeBlobPresenter();
const store = setup( const store = setup(
{ {
@@ -412,11 +412,11 @@ describe('BriefStore.flushPending (CanDeactivate guard / beforeunload)', () => {
}); });
}); });
// --- RB-22 (CQ-007 expand half): a 404 from GET /brief tolerates by calling the // --- CQ-007's expand half: a 404 from GET /brief tolerates by calling the
// existing reset() command, exactly once. Today's backend never 404s (RB-23 adds // existing reset() command, exactly once. Today's backend never 404s yet;
// that); this fake adapter is what exercises the branch until then. --- // this fake adapter is what exercises the branch until then. ---
describe('BriefStore.load — 404 tolerance (RB-22)', () => { describe('BriefStore.load — 404 tolerance', () => {
const notFound: Result<BriefLoadFailure, BriefView> = { ok: false, error: { tag: 'notFound' } }; const notFound: Result<BriefLoadFailure, BriefView> = { ok: false, error: { tag: 'notFound' } };
const resetOk: Result<string, BriefView> = { ok: true, value: view }; const resetOk: Result<string, BriefView> = { ok: true, value: view };
@@ -431,7 +431,7 @@ describe('BriefStore.load — 404 tolerance (RB-22)', () => {
// Then reset() ran exactly once, and the store ends up loaded from its result. // Then reset() ran exactly once, and the store ends up loaded from its result.
expect(reset).toHaveBeenCalledTimes(1); expect(reset).toHaveBeenCalledTimes(1);
expect(store.model().tag).toBe('loaded'); expect(store.model().tag).toBe('Loaded');
}); });
it('a second 404 does not drive a second reset()', async () => { it('a second 404 does not drive a second reset()', async () => {
@@ -447,6 +447,6 @@ describe('BriefStore.load — 404 tolerance (RB-22)', () => {
// Then reset() ran exactly once — the once-only bound holds across calls, not // Then reset() ran exactly once — the once-only bound holds across calls, not
// just within one — and the second 404 surfaces as an ordinary load failure. // just within one — and the second 404 surfaces as an ordinary load failure.
expect(reset).toHaveBeenCalledTimes(1); expect(reset).toHaveBeenCalledTimes(1);
expect(store.model()).toEqual({ tag: 'failed', reason: BRIEF_LOAD_FAILED }); expect(store.model()).toEqual({ tag: 'Failed', reason: BRIEF_LOAD_FAILED });
}); });
}); });
@@ -1,10 +1,9 @@
import { Injectable, computed, inject, signal } from '@angular/core'; import { Injectable, computed, inject, signal } from '@angular/core';
import { Result } from '@shared/kernel/fp'; import { Result } from '@shared/kernel/fp';
import { createStore } from '@shared/application/store'; import { createStore } from '@shared/application/store';
import { ActionState, SaveState } from '@shared/application/action-state';
import { createHistory } from '@shared/application/history'; import { createHistory } from '@shared/application/history';
import { createDebouncedSave } from '@shared/application/debounced-save'; import { SaveState, createDebouncedSave } from '@shared/application/debounced-save';
import { machineRemoteData } from '@shared/application/machine-remote-data'; import { fromLoadLifecycle } from '@shared/application/remote-data';
import { import {
Brief, Brief,
CaseContext, CaseContext,
@@ -29,7 +28,7 @@ import { BLOB_PRESENTER } from '@shared/application/blob-presenter';
* outcome. Mirrors `BigProfileStore`. All of `canEdit`/`canApprove`/`canReject`/ * outcome. Mirrors `BigProfileStore`. All of `canEdit`/`canApprove`/`canReject`/
* `canSend`, `diagnostics`, `unresolved`, `canSubmit` are DERIVED here — never * `canSend`, `diagnostics`, `unresolved`, `canSubmit` are DERIVED here — never
* stored. The permission flags come from the server's decision DTO (PRD-0002 phase * 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. * P1) via `BriefState.Loaded.decisions` — this store never computes them itself.
*/ */
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class BriefStore implements PendingSave { export class BriefStore implements PendingSave {
@@ -41,18 +40,23 @@ export class BriefStore implements PendingSave {
readonly model = this.store.model; readonly model = this.store.model;
private actionState = signal<ActionState>({ tag: 'Idle' }); /** The one-shot action lifecycle now lives on the machine's `Loaded.action` (RD-12);
readonly busy = computed(() => this.actionState().tag === 'Busy'); these stay as plain `computed`s so the render seam (four `busy = input(...)`
components, two page templates) keeps a byte-identical boolean/string API. */
readonly busy = computed(() => {
const s = this.model();
return s.tag === 'Loaded' && s.action.tag === 'Busy';
});
readonly lastError = computed(() => { readonly lastError = computed(() => {
const s = this.actionState(); const s = this.model();
return s.tag === 'Failed' ? s.error : null; return s.tag === 'Loaded' && s.action.tag === 'Failed' ? s.action.error : null;
}); });
/** Surfaced autosave state for the indicator + aria-live region. */ /** Surfaced autosave state for the indicator + aria-live region. */
readonly saveState = signal<SaveState>({ tag: 'Idle' }); readonly saveState = signal<SaveState>({ tag: 'Idle' });
/** Undo/redo is SHELL state, not machine state (WP-27): a `createHistory` stack of /** Undo/redo is SHELL state, not machine state: a `createHistory` stack of
`Brief` snapshots (WP-31 extracted the mechanics). Only CONTENT edits are recorded `Brief` snapshots (the mechanics live in a shared helper). Only CONTENT edits are recorded
(they flow through `edit()`); status transitions never enter history, or undo would (they flow through `edit()`); status transitions never enter history, or undo would
replay workflow state. Restore re-dispatches the existing `Seed` Msg — zero machine replay workflow state. Restore re-dispatches the existing `Seed` Msg — zero machine
changes. */ changes. */
@@ -60,7 +64,7 @@ export class BriefStore implements PendingSave {
readonly canUndo = this.history.canUndo; readonly canUndo = this.history.canUndo;
readonly canRedo = this.history.canRedo; readonly canRedo = this.history.canRedo;
/** The letter as it stood when it was REJECTED, captured shell-side (WP-27). The /** The letter as it stood when it was REJECTED, captured shell-side. The
approver diffs it against the resubmitted letter. POC limit: in-memory only, so a 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. */ full page reload loses it — a real system would persist the rejected revision. */
private rejectionSnapshot = signal<Brief | null>(null); private rejectionSnapshot = signal<Brief | null>(null);
@@ -77,7 +81,7 @@ export class BriefStore implements PendingSave {
); );
readonly hasRejectionDiff = computed(() => this.blockDiffs().size > 0); readonly hasRejectionDiff = computed(() => this.blockDiffs().size > 0);
/** The org template the letter renders with (WP-24). Server-owned appearance data, /** The org template the letter renders with. Server-owned appearance data,
not letter state — held beside the machine, never inside it (`brief.machine.ts` 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. */ stays untouched by design). Set from every server view that carries it. */
readonly orgTemplate = signal<OrgTemplate | null>(null); readonly orgTemplate = signal<OrgTemplate | null>(null);
@@ -95,11 +99,11 @@ export class BriefStore implements PendingSave {
/** The load lifecycle as `RemoteData`, for `<app-async>` — the machine keeps /** The load lifecycle as `RemoteData`, for `<app-async>` — the machine keeps
owning the letter's own domain lifecycle (draft/submitted/approved/…); this is 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. */ purely a projection of its loading/failed tags onto the shared async seam. */
readonly remoteData = computed(() => machineRemoteData(this.model())); readonly remoteData = computed(() => fromLoadLifecycle(this.model()));
private brief = computed<Brief | null>(() => { private brief = computed<Brief | null>(() => {
const s = this.model(); const s = this.model();
return s.tag === 'loaded' ? s.brief : null; return s.tag === 'Loaded' ? s.brief : null;
}); });
readonly canEdit = computed(() => this.decisions()?.canEdit ?? false); readonly canEdit = computed(() => this.decisions()?.canEdit ?? false);
@@ -111,7 +115,7 @@ export class BriefStore implements PendingSave {
private decisions = computed(() => { private decisions = computed(() => {
const s = this.model(); const s = this.model();
return s.tag === 'loaded' ? s.decisions : null; return s.tag === 'Loaded' ? s.decisions : null;
}); });
readonly diagnostics = computed(() => (this.brief() ? allDiagnostics(this.brief()!) : [])); readonly diagnostics = computed(() => (this.brief() ? allDiagnostics(this.brief()!) : []));
readonly unresolved = computed(() => (this.brief() ? unresolvedPlaceholders(this.brief()!) : [])); readonly unresolved = computed(() => (this.brief() ? unresolvedPlaceholders(this.brief()!) : []));
@@ -121,7 +125,7 @@ export class BriefStore implements PendingSave {
return !!b && canSubmit(b) && !hasBlockingErrors(this.diagnostics()); return !!b && canSubmit(b) && !hasBlockingErrors(this.diagnostics());
}); });
/** True once a 404-triggered recovery has been attempted (RB-22, CQ-007's expand /** True once a 404-triggered recovery has been attempted (CQ-007's expand
half — see `recoverFromMissingBrief`). This is the structural once-only bound: half — see `recoverFromMissingBrief`). This is the structural once-only bound:
a repeated 404 falls straight to the `error` branch below and can never reach a repeated 404 falls straight to the `error` branch below and can never reach
`adapter.reset()` a second time, regardless of how many times `load()` runs. */ `adapter.reset()` a second time, regardless of how many times `load()` runs. */
@@ -182,7 +186,7 @@ export class BriefStore implements PendingSave {
} }
private restore(step: (current: Brief) => Brief | undefined) { private restore(step: (current: Brief) => Brief | undefined) {
const s = this.model(); const s = this.model();
if (s.tag !== 'loaded') return; if (s.tag !== 'Loaded') return;
const target = step(s.brief); const target = step(s.brief);
if (target === undefined) return; if (target === undefined) return;
this.store.dispatch({ tag: 'Seed', state: { ...s, brief: target } }); this.store.dispatch({ tag: 'Seed', state: { ...s, brief: target } });
@@ -196,7 +200,7 @@ export class BriefStore implements PendingSave {
} }
// 600ms debounced autosave (the server is the store of record). Timer mechanics live in // 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). // the shared helper; `flushSave` below is the store-specific write + save-state.
private debouncedSave = createDebouncedSave({ private debouncedSave = createDebouncedSave({
canSave: () => this.canEdit(), canSave: () => this.canEdit(),
flush: () => this.flushSave(), flush: () => this.flushSave(),
@@ -212,31 +216,33 @@ export class BriefStore implements PendingSave {
if (r.ok) { if (r.ok) {
this.saveState.set({ tag: 'Saved' }); this.saveState.set({ tag: 'Saved' });
} else { } else {
this.actionState.set({ tag: 'Failed', error: r.error }); // The autosave failure legitimately surfaces in two places: the small save
// indicator below (kept as-is) and the action error line (RD-12).
this.store.dispatch({ tag: 'ActionFailed', error: r.error });
this.saveState.set({ tag: 'Error' }); this.saveState.set({ tag: 'Error' });
} }
} }
/** Retry a failed autosave — reuses the existing flush path, no new state (WP-27). */ /** Retry a failed autosave — reuses the existing flush path, no new state. */
retrySave() { retrySave() {
void this.flushSave(); void this.flushSave();
} }
/** Demo "start over": recreate the brief server-side and load the fresh view. */ /** Demo "start over": recreate the brief server-side and load the fresh view. */
async resetDemo() { async resetDemo() {
this.actionState.set({ tag: 'Busy' }); this.store.dispatch({ tag: 'ActionStarted' });
this.debouncedSave.cancel(); this.debouncedSave.cancel();
const r = await this.adapter.reset(); const r = await this.adapter.reset();
this.saveState.set({ tag: 'Idle' }); this.saveState.set({ tag: 'Idle' });
if (r.ok) { if (r.ok) {
this.actionState.set({ tag: 'Idle' }); this.store.dispatch({ tag: 'ActionFinished' });
this.orgTemplate.set(r.value.orgTemplate); this.orgTemplate.set(r.value.orgTemplate);
this.caseContext.set(r.value.caseContext); this.caseContext.set(r.value.caseContext);
this.history.clear(); this.history.clear();
this.rejectionSnapshot.set(null); this.rejectionSnapshot.set(null);
this.store.dispatch({ tag: 'BriefLoaded', ...r.value }); this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
} else { } else {
this.actionState.set({ tag: 'Failed', error: r.error }); this.store.dispatch({ tag: 'ActionFailed', error: r.error });
} }
} }
@@ -249,13 +255,13 @@ export class BriefStore implements PendingSave {
letter in a new tab via `BLOB_PRESENTER.open` — see its doc comment for why the letter in a new tab via `BLOB_PRESENTER.open` — see its doc comment for why the
object URL is never revoked. */ object URL is never revoked. */
async previewLetter() { async previewLetter() {
this.actionState.set({ tag: 'Busy' }); this.store.dispatch({ tag: 'ActionStarted' });
const r = await this.previewAdapter.preview(); const r = await this.previewAdapter.preview();
if (!r.ok) { if (!r.ok) {
this.actionState.set({ tag: 'Failed', error: r.error }); this.store.dispatch({ tag: 'ActionFailed', error: r.error });
return; return;
} }
this.actionState.set({ tag: 'Idle' }); this.store.dispatch({ tag: 'ActionFinished' });
this.blobPresenter.open(r.value); this.blobPresenter.open(r.value);
} }
@@ -269,7 +275,8 @@ export class BriefStore implements PendingSave {
async revealBigNummer() { async revealBigNummer() {
const r = await this.revealAdapter.reveal(true); const r = await this.revealAdapter.reveal(true);
if (!r.ok) { if (!r.ok) {
this.actionState.set({ tag: 'Failed', error: r.error }); // Never sets Busy — an existing asymmetry (RD-12), not fixed here.
this.store.dispatch({ tag: 'ActionFailed', error: r.error });
return; return;
} }
this.caseContext.update((c) => (c ? { ...c, bigNummer: r.value } : c)); this.caseContext.update((c) => (c ? { ...c, bigNummer: r.value } : c));
@@ -278,15 +285,15 @@ export class BriefStore implements PendingSave {
// A transition: flush any pending save, call the server (authoritative), then mirror // A transition: flush any pending save, call the server (authoritative), then mirror
// the returned status through the pure reducer's guarded transition. // the returned status through the pure reducer's guarded transition.
private async transition(action: () => Promise<Result<string, BriefView>>) { private async transition(action: () => Promise<Result<string, BriefView>>) {
this.actionState.set({ tag: 'Busy' }); this.store.dispatch({ tag: 'ActionStarted' });
this.debouncedSave.cancel(); this.debouncedSave.cancel();
await this.flushSave(); await this.flushSave();
const r = await action(); const r = await action();
if (!r.ok) { if (!r.ok) {
this.actionState.set({ tag: 'Failed', error: r.error }); this.store.dispatch({ tag: 'ActionFailed', error: r.error });
return; return;
} }
this.actionState.set({ tag: 'Idle' }); this.store.dispatch({ tag: 'ActionFinished' });
this.applyServerStatus(r.value); this.applyServerStatus(r.value);
} }
@@ -304,7 +311,7 @@ export class BriefStore implements PendingSave {
this.store.dispatch({ tag: 'Approved', by: s.approvedBy, at: s.approvedAt, decisions }); this.store.dispatch({ tag: 'Approved', by: s.approvedBy, at: s.approvedAt, decisions });
break; break;
case 'rejected': case 'rejected':
// Capture the letter as-rejected for the resubmission diff (WP-27). This is the // Capture the letter as-rejected for the resubmission diff. This is the
// "before" snapshot the approver later compares against. // "before" snapshot the approver later compares against.
this.rejectionSnapshot.set(brief); this.rejectionSnapshot.set(brief);
this.store.dispatch({ this.store.dispatch({
@@ -32,7 +32,7 @@ const subOrgs: SubOrgSummary[] = [
{ subOrgId: 'cibg-registers', orgName: 'CIBG', publishedVersion: 1 }, { subOrgId: 'cibg-registers', orgName: 'CIBG', publishedVersion: 1 },
]; ];
/** A recording fake of BLOB_PRESENTER (RB-28/TE-006) — records every call instead of /** A recording fake of BLOB_PRESENTER (TE-006) — records every call instead of
touching the DOM, so a spec can assert a command's success path directly. */ touching the DOM, so a spec can assert a command's success path directly. */
function fakeBlobPresenter() { function fakeBlobPresenter() {
const opened: Blob[] = []; const opened: Blob[] = [];
@@ -70,10 +70,10 @@ function setup(
return TestBed.inject(OrgTemplateStore); return TestBed.inject(OrgTemplateStore);
} }
// --- RB-28 (TE-006): proefbrief() ends in BLOB_PRESENTER.open, not a raw // --- TE-006: proefbrief() ends in BLOB_PRESENTER.open, not a raw
// window.open(URL.createObjectURL(...)) call, so both outcomes are assertable. --- // window.open(URL.createObjectURL(...)) call, so both outcomes are assertable. ---
describe('OrgTemplateStore.proefbrief (RB-28)', () => { describe('OrgTemplateStore.proefbrief', () => {
it('opens the rendered proefbrief via BLOB_PRESENTER on success', async () => { it('opens the rendered proefbrief via BLOB_PRESENTER on success', async () => {
// Given a loaded sub-org template. // Given a loaded sub-org template.
const { presenter, opened } = fakeBlobPresenter(); const { presenter, opened } = fakeBlobPresenter();
@@ -1,8 +1,7 @@
import { Injectable, computed, effect, inject, signal } from '@angular/core'; import { Injectable, computed, effect, inject, signal } from '@angular/core';
import { createStore } from '@shared/application/store'; import { createStore } from '@shared/application/store';
import { ActionState, SaveState } from '@shared/application/action-state'; import { SaveState, createDebouncedSave } from '@shared/application/debounced-save';
import { createDebouncedSave } from '@shared/application/debounced-save'; import { fromLoadLifecycle } from '@shared/application/remote-data';
import { machineRemoteData } from '@shared/application/machine-remote-data';
import { UploadAdapter, uploadContentUrl } from '@shared/infrastructure/upload.adapter'; import { UploadAdapter, uploadContentUrl } from '@shared/infrastructure/upload.adapter';
import { UploadShellService } from '@shared/application/upload-shell.service'; import { UploadShellService } from '@shared/application/upload-shell.service';
import { UploadMsg, initialUpload, rejectReason } from '@shared/domain/upload.machine'; import { UploadMsg, initialUpload, rejectReason } from '@shared/domain/upload.machine';
@@ -13,6 +12,7 @@ import {
SubOrgSummary, SubOrgSummary,
} from '@brief/domain/org-template'; } from '@brief/domain/org-template';
import { import {
OrgTemplateActionState,
OrgTemplateMsg, OrgTemplateMsg,
OrgTemplateState, OrgTemplateState,
initial, initial,
@@ -22,13 +22,13 @@ import { OrgTemplateAdapter } from '@brief/infrastructure/org-template.adapter';
import { PendingSave, registerPendingSave } from '@shared/application/pending-saves'; import { PendingSave, registerPendingSave } from '@shared/application/pending-saves';
import { BLOB_PRESENTER } from '@shared/application/blob-presenter'; import { BLOB_PRESENTER } from '@shared/application/blob-presenter';
type LoadedState = Extract<OrgTemplateState, { tag: 'loaded' }>; type LoadedState = Extract<OrgTemplateState, { tag: 'Loaded' }>;
const LOGO_CATEGORY = 'org-logo'; const LOGO_CATEGORY = 'org-logo';
const NO_SUBORGS = $localize`:@@orgTemplate.noSubOrgs:Er zijn geen organisatiesjablonen om te beheren.`; 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 * Root singleton for the admin org-template editor. The Elm machine owns the
* editable draft; commands here do the debounced save, publish (impact-confirm), * editable draft; commands here do the debounced save, publish (impact-confirm),
* rollback and proefbrief, then dispatch the outcome — the reducer stays pure. The * rollback and proefbrief, then dispatch the outcome — the reducer stays pure. The
* logo upload reuses the shared upload transport; its completion mutates the draft * logo upload reuses the shared upload transport; its completion mutates the draft
@@ -47,22 +47,28 @@ export class OrgTemplateStore implements PendingSave {
readonly subOrgs = signal<readonly SubOrgSummary[]>([]); readonly subOrgs = signal<readonly SubOrgSummary[]>([]);
readonly selectedSubOrgId = signal<string | null>(null); readonly selectedSubOrgId = signal<string | null>(null);
private actionState = signal<ActionState>({ tag: 'Idle' }); /** The one-shot action lifecycle and the publish impact-confirm gate now live on
readonly busy = computed(() => this.actionState().tag === 'Busy'); the machine's `Loaded.action` as one four-variant union (RD-13); these stay as
plain `computed`s so the render seam (the editor organism's `input()`s, the
page template) keeps a byte-identical boolean/string API. */
private action = computed<OrgTemplateActionState>(() => this.loaded()?.action ?? { tag: 'Idle' });
readonly busy = computed(() => this.action().tag === 'Busy');
readonly lastError = computed(() => { readonly lastError = computed(() => {
const s = this.actionState(); const a = this.action();
return s.tag === 'Failed' ? s.error : null; return a.tag === 'Failed' ? a.error : null;
}); });
/** The publish impact-confirm gate (PRD §7h: show N affected letters before POST).
Before RD-13 this was an independent boolean, so it could be `true` at the same
time `busy` was `true` — representable and meaningless. It is now derived from
the same union `busy` reads, so the two are mutually exclusive by construction. */
readonly pendingPublish = computed(() => this.action().tag === 'ConfirmingPublish');
readonly saveState = signal<SaveState>({ tag: 'Idle' }); readonly saveState = signal<SaveState>({ tag: 'Idle' });
/** The publish impact-confirm gate (PRD §7h: show N affected letters before POST). */ readonly remoteData = computed(() => fromLoadLifecycle(this.model()));
readonly pendingPublish = signal(false);
readonly remoteData = computed(() => machineRemoteData(this.model()));
private loaded = computed<LoadedState | null>(() => { private loaded = computed<LoadedState | null>(() => {
const s = this.model(); const s = this.model();
return s.tag === 'loaded' ? s : null; return s.tag === 'Loaded' ? s : null;
}); });
readonly draft = computed<OrgTemplate | null>(() => this.loaded()?.draft ?? null); readonly draft = computed<OrgTemplate | null>(() => this.loaded()?.draft ?? null);
readonly uploadState = computed(() => this.loaded()?.upload ?? initialUpload); readonly uploadState = computed(() => this.loaded()?.upload ?? initialUpload);
@@ -101,7 +107,7 @@ export class OrgTemplateStore implements PendingSave {
// the length guard makes it idempotent (no dispatch loop). // the length guard makes it idempotent (no dispatch loop).
effect(() => { effect(() => {
const s = this.model(); const s = this.model();
if (s.tag !== 'loaded' || s.upload.categories.length > 0) return; if (s.tag !== 'Loaded' || s.upload.categories.length > 0) return;
const status = this.categoriesRes.status(); const status = this.categoriesRes.status();
if (status === 'resolved' || status === 'local') if (status === 'resolved' || status === 'local')
this.dispatchUpload({ this.dispatchUpload({
@@ -145,7 +151,7 @@ export class OrgTemplateStore implements PendingSave {
this.debouncedSave.schedule(); this.debouncedSave.schedule();
} }
// 600ms debounced autosave (same idiom as BriefStore, WP-31). Timer mechanics live in the // 600ms debounced autosave (same idiom as BriefStore). Timer mechanics live in the
// shared helper; `flushSave` below is the store-specific write + save-state. // shared helper; `flushSave` below is the store-specific write + save-state.
private debouncedSave = createDebouncedSave({ private debouncedSave = createDebouncedSave({
canSave: () => this.loaded() !== null, canSave: () => this.loaded() !== null,
@@ -165,60 +171,64 @@ export class OrgTemplateStore implements PendingSave {
this.store.dispatch({ tag: 'DraftSaved', savedDraft: draft }); this.store.dispatch({ tag: 'DraftSaved', savedDraft: draft });
} else { } else {
this.saveState.set({ tag: 'Error' }); this.saveState.set({ tag: 'Error' });
this.actionState.set({ tag: 'Failed', error: r.error }); this.store.dispatch({ tag: 'ActionFailed', error: r.error });
} }
} }
// --- publish (impact-confirm) / rollback / proefbrief --- // --- publish (impact-confirm) / rollback / proefbrief ---
// RD-13: `requestPublish`/`cancelPublish` are the only two commands here that do
// NOT guard on `loaded()` — as dispatches they no-op outside `Loaded` by
// construction (the reducer's own guard), so behaviour is unchanged.
requestPublish() { requestPublish() {
this.pendingPublish.set(true); this.store.dispatch({ tag: 'PublishRequested' });
} }
cancelPublish() { cancelPublish() {
this.pendingPublish.set(false); this.store.dispatch({ tag: 'PublishCancelled' });
} }
async confirmPublish() { async confirmPublish() {
const s = this.loaded(); const s = this.loaded();
if (!s) return; if (!s) return;
this.pendingPublish.set(false); // ActionStarted overwrites `action` straight to Busy, so ConfirmingPublish and
this.actionState.set({ tag: 'Busy' }); // Busy are never simultaneously true (RD-13).
this.store.dispatch({ tag: 'ActionStarted' });
this.debouncedSave.cancel(); this.debouncedSave.cancel();
await this.flushSave(); // publish the saved draft — flush any pending edit first await this.flushSave(); // publish the saved draft — flush any pending edit first
const r = await this.adapter.publish(s.subOrgId); const r = await this.adapter.publish(s.subOrgId);
if (!r.ok) { if (!r.ok) {
this.actionState.set({ tag: 'Failed', error: r.error }); this.store.dispatch({ tag: 'ActionFailed', error: r.error });
return; return;
} }
this.actionState.set({ tag: 'Idle' }); this.store.dispatch({ tag: 'ActionFinished' });
await this.selectSubOrg(s.subOrgId); // reload: new version, history, unsentBriefs = 0 await this.selectSubOrg(s.subOrgId); // reload: new version, history, unsentBriefs = 0
} }
async rollback(version: number) { async rollback(version: number) {
const s = this.loaded(); const s = this.loaded();
if (!s) return; if (!s) return;
this.actionState.set({ tag: 'Busy' }); this.store.dispatch({ tag: 'ActionStarted' });
this.debouncedSave.cancel(); this.debouncedSave.cancel();
const r = await this.adapter.rollback(s.subOrgId, version); const r = await this.adapter.rollback(s.subOrgId, version);
if (!r.ok) { if (!r.ok) {
this.actionState.set({ tag: 'Failed', error: r.error }); this.store.dispatch({ tag: 'ActionFailed', error: r.error });
return; return;
} }
this.actionState.set({ tag: 'Idle' }); this.store.dispatch({ tag: 'ActionFinished' });
this.store.dispatch({ tag: 'DraftLoaded', view: r.value }); // old version copied into draft this.store.dispatch({ tag: 'DraftLoaded', view: r.value }); // old version copied into draft
} }
async proefbrief() { async proefbrief() {
const s = this.loaded(); const s = this.loaded();
if (!s) return; if (!s) return;
this.actionState.set({ tag: 'Busy' }); this.store.dispatch({ tag: 'ActionStarted' });
this.debouncedSave.cancel(); this.debouncedSave.cancel();
await this.flushSave(); // the proefbrief renders the server's draft await this.flushSave(); // the proefbrief renders the server's draft
const r = await this.adapter.proefbrief(s.subOrgId); const r = await this.adapter.proefbrief(s.subOrgId);
if (!r.ok) { if (!r.ok) {
this.actionState.set({ tag: 'Failed', error: r.error }); this.store.dispatch({ tag: 'ActionFailed', error: r.error });
return; return;
} }
this.actionState.set({ tag: 'Idle' }); this.store.dispatch({ tag: 'ActionFinished' });
this.blobPresenter.open(r.value); this.blobPresenter.open(r.value);
} }
+1 -1
View File
@@ -2,7 +2,7 @@ import { Brief, LetterBlock, allBlocks } from './brief';
/** /**
* The rejection diff as a PURE function over two immutable `Brief` values — the whole * 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 * teaching payload here: because state is one value, "what changed since the letter
* was rejected" is just a fold over two snapshots, no change-tracking bookkeeping. * was rejected" is just a fold over two snapshots, no change-tracking bookkeeping.
* *
* Blocks are matched by `blockId` (stable `local-N`/seed ids): * Blocks are matched by `blockId` (stable `local-N`/seed ids):
@@ -76,7 +76,7 @@ const loaded = (status: BriefStatus = { tag: 'draft' }, sections?: Brief['sectio
}); });
const sectionBlocks = (s: BriefState, key: string) => const sectionBlocks = (s: BriefState, key: string) =>
s.tag === 'loaded' ? s.brief.sections.find((x) => x.sectionKey === key)!.blocks : []; s.tag === 'Loaded' ? s.brief.sections.find((x) => x.sectionKey === key)!.blocks : [];
const passageIds = (s: BriefState, key: string) => const passageIds = (s: BriefState, key: string) =>
sectionBlocks(s, key) sectionBlocks(s, key)
@@ -92,12 +92,12 @@ describe('brief.machine reduce', () => {
availablePassages: [], availablePassages: [],
decisions, decisions,
}).tag, }).tag,
).toBe('loaded'); ).toBe('Loaded');
}); });
it('BriefLoadFailed moves loading to failed with the reason', () => { it('BriefLoadFailed moves loading to failed with the reason', () => {
expect(reduce(initialLoading(), { tag: 'BriefLoadFailed', reason: 'x' })).toEqual({ expect(reduce(initialLoading(), { tag: 'BriefLoadFailed', reason: 'x' })).toEqual({
tag: 'failed', tag: 'Failed',
reason: 'x', reason: 'x',
}); });
}); });
@@ -210,7 +210,7 @@ describe('brief.machine reduce', () => {
comments: 'graag aanpassen', comments: 'graag aanpassen',
}); });
const next = reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'slot' }); const next = reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'slot' });
expect(next.tag === 'loaded' && next.brief.status.tag).toBe('draft'); expect(next.tag === 'Loaded' && next.brief.status.tag).toBe('draft');
expect(sectionBlocks(next, 'slot')).toHaveLength(1); expect(sectionBlocks(next, 'slot')).toHaveLength(1);
}); });
@@ -220,7 +220,7 @@ describe('brief.machine reduce', () => {
// fill the required section via the besluit, then submit // fill the required section via the besluit, then submit
const filled = reduce(loaded(), besluit('positief')); const filled = reduce(loaded(), besluit('positief'));
const submitted = reduce(filled, { tag: 'Submitted', by: 'u1', at: 't', decisions }); const submitted = reduce(filled, { tag: 'Submitted', by: 'u1', at: 't', decisions });
expect(submitted.tag === 'loaded' && submitted.brief.status).toEqual({ expect(submitted.tag === 'Loaded' && submitted.brief.status).toEqual({
tag: 'submitted', tag: 'submitted',
submittedBy: 'u1', submittedBy: 'u1',
submittedAt: 't', submittedAt: 't',
@@ -232,7 +232,7 @@ describe('brief.machine reduce', () => {
// approve from draft is a no-op // approve from draft is a no-op
expect(reduce(loaded(), { tag: 'Approved', by: 'u2', at: 't', decisions })).toEqual(loaded()); expect(reduce(loaded(), { tag: 'Approved', by: 'u2', at: 't', decisions })).toEqual(loaded());
const approved = reduce(submitted, { tag: 'Approved', by: 'u2', at: 't2', decisions }); const approved = reduce(submitted, { tag: 'Approved', by: 'u2', at: 't2', decisions });
expect(approved.tag === 'loaded' && approved.brief.status).toEqual({ expect(approved.tag === 'Loaded' && approved.brief.status).toEqual({
tag: 'approved', tag: 'approved',
approvedBy: 'u2', approvedBy: 'u2',
approvedAt: 't2', approvedAt: 't2',
@@ -248,7 +248,7 @@ describe('brief.machine reduce', () => {
comments: 'nee', comments: 'nee',
decisions, decisions,
}); });
expect(rejected.tag === 'loaded' && rejected.brief.status).toEqual({ expect(rejected.tag === 'Loaded' && rejected.brief.status).toEqual({
tag: 'rejected', tag: 'rejected',
rejectedBy: 'u2', rejectedBy: 'u2',
rejectedAt: 't2', rejectedAt: 't2',
@@ -262,7 +262,40 @@ describe('brief.machine reduce', () => {
// send from submitted is a no-op // send from submitted is a no-op
expect(reduce(submitted, { tag: 'Sent', at: 't', decisions })).toBe(submitted); expect(reduce(submitted, { tag: 'Sent', at: 't', decisions })).toBe(submitted);
const sent = reduce(approved, { tag: 'Sent', at: 't3', decisions }); const sent = reduce(approved, { tag: 'Sent', at: 't3', decisions });
expect(sent.tag === 'loaded' && sent.brief.status).toEqual({ tag: 'sent', sentAt: 't3' }); expect(sent.tag === 'Loaded' && sent.brief.status).toEqual({ tag: 'sent', sentAt: 't3' });
});
// --- RD-12: the action lifecycle lives on `Loaded.action`, driven by three msgs ---
it('ActionStarted moves a loaded brief to Busy', () => {
const s = reduce(loaded(), { tag: 'ActionStarted' });
expect(s.tag === 'Loaded' && s.action).toEqual({ tag: 'Busy' });
});
it('ActionFailed carries the error', () => {
const s = reduce(loaded(), { tag: 'ActionFailed', error: 'niet gelukt' });
expect(s.tag === 'Loaded' && s.action).toEqual({ tag: 'Failed', error: 'niet gelukt' });
});
it('ActionFinished returns to Idle', () => {
const busy = reduce(loaded(), { tag: 'ActionStarted' });
const s = reduce(busy, { tag: 'ActionFinished' });
expect(s.tag === 'Loaded' && s.action).toEqual({ tag: 'Idle' });
});
it('BriefLoaded resets a stale action error to Idle', () => {
const failed = reduce(loaded(), { tag: 'ActionFailed', error: 'niet gelukt' });
const reloaded = reduce(failed, {
tag: 'BriefLoaded',
brief: briefWith({ tag: 'draft' }),
availablePassages: lib,
decisions,
});
expect(reloaded.tag === 'Loaded' && reloaded.action).toEqual({ tag: 'Idle' });
});
it('an action message is a no-op when the brief is not loaded', () => {
expect(reduce(initialLoading(), { tag: 'ActionStarted' })).toEqual(initialLoading());
}); });
it('a status transition replaces decisions with the fresh server value', () => { it('a status transition replaces decisions with the fresh server value', () => {
@@ -280,10 +313,10 @@ describe('brief.machine reduce', () => {
at: 't2', at: 't2',
decisions: staleApprover, decisions: staleApprover,
}); });
expect(approved.tag === 'loaded' && approved.decisions).toEqual(staleApprover); expect(approved.tag === 'Loaded' && approved.decisions).toEqual(staleApprover);
}); });
}); });
function initialLoading(): BriefState { function initialLoading(): BriefState {
return { tag: 'loading' }; return { tag: 'Loading' };
} }
+30 -10
View File
@@ -36,17 +36,22 @@ import { passagesForBesluit } from './besluit';
* structurally impossible (a pasted `{{…}}` is caught by the linter as `malformed`). * structurally impossible (a pasted `{{…}}` is caught by the linter as `malformed`).
*/ */
/** The one-shot action lifecycle (submit/approve/reject/send/preview/reveal/reset),
owned by the reducer instead of an imperative store-level signal (RD-12). */
export type BriefActionState = { tag: 'Idle' } | { tag: 'Busy' } | { tag: 'Failed'; error: string };
export type BriefState = export type BriefState =
| { tag: 'loading' } | { tag: 'Loading' }
| { | {
tag: 'loaded'; tag: 'Loaded';
brief: Brief; brief: Brief;
availablePassages: readonly LibraryPassage[]; availablePassages: readonly LibraryPassage[];
decisions: BriefDecisions; decisions: BriefDecisions;
action: BriefActionState;
} }
| { tag: 'failed'; reason: string }; | { tag: 'Failed'; reason: string };
export const initial: BriefState = { tag: 'loading' }; export const initial: BriefState = { tag: 'Loading' };
export type BriefMsg = export type BriefMsg =
| { | {
@@ -65,7 +70,10 @@ export type BriefMsg =
| { tag: 'Approved'; by: string; at: string; decisions: BriefDecisions } // submitted → approved | { tag: 'Approved'; by: string; at: string; decisions: BriefDecisions } // submitted → approved
| { tag: 'Rejected'; by: string; at: string; comments: string; decisions: BriefDecisions } // submitted → rejected | { tag: 'Rejected'; by: string; at: string; comments: string; decisions: BriefDecisions } // submitted → rejected
| { tag: 'Sent'; at: string; decisions: BriefDecisions } // approved → sent | { tag: 'Sent'; at: string; decisions: BriefDecisions } // approved → sent
| { tag: 'Seed'; state: BriefState }; | { tag: 'Seed'; state: BriefState }
| { tag: 'ActionStarted' } // a one-shot action (submit/approve/preview/…) began
| { tag: 'ActionFinished' } // it completed successfully
| { tag: 'ActionFailed'; error: string }; // it failed, carrying the message to show
/** Edits are allowed only in these statuses; editing a rejected letter reopens it. */ /** Edits are allowed only in these statuses; editing a rejected letter reopens it. */
function isEditable(status: BriefStatus): boolean { function isEditable(status: BriefStatus): boolean {
@@ -110,7 +118,7 @@ function mapBlocks(brief: Brief, f: (blocks: readonly LetterBlock[]) => LetterBl
/** Apply an edit to the brief, guarded by status. A rejected letter reopens to draft. */ /** Apply an edit to the brief, guarded by status. A rejected letter reopens to draft. */
function withEdit(s: BriefState, f: (b: Brief) => Brief): BriefState { function withEdit(s: BriefState, f: (b: Brief) => Brief): BriefState {
if (s.tag !== 'loaded' || !isEditable(s.brief.status)) return s; if (s.tag !== 'Loaded' || !isEditable(s.brief.status)) return s;
let brief = f(s.brief); let brief = f(s.brief);
if (brief.status.tag === 'rejected') brief = { ...brief, status: { tag: 'draft' } }; if (brief.status.tag === 'rejected') brief = { ...brief, status: { tag: 'draft' } };
return { ...s, brief }; return { ...s, brief };
@@ -189,13 +197,16 @@ export function reduce(s: BriefState, m: BriefMsg): BriefState {
switch (m.tag) { switch (m.tag) {
case 'BriefLoaded': case 'BriefLoaded':
return { return {
tag: 'loaded', tag: 'Loaded',
brief: m.brief, brief: m.brief,
availablePassages: m.availablePassages, availablePassages: m.availablePassages,
decisions: m.decisions, decisions: m.decisions,
// A fresh load clears a stale action error rather than letting it outlive
// the reload (RD-12, decision 4).
action: { tag: 'Idle' },
}; };
case 'BriefLoadFailed': case 'BriefLoadFailed':
return { tag: 'failed', reason: m.reason }; return { tag: 'Failed', reason: m.reason };
case 'Seed': case 'Seed':
return m.state; return m.state;
@@ -203,7 +214,7 @@ export function reduce(s: BriefState, m: BriefMsg): BriefState {
// drafter's free text. `availablePassages` lives on the loaded state, so this stays pure. // drafter's free text. `availablePassages` lives on the loaded state, so this stays pure.
case 'BesluitSelected': case 'BesluitSelected':
return withEdit(s, (b) => return withEdit(s, (b) =>
s.tag === 'loaded' && isSectionEditable(b, 'kern') s.tag === 'Loaded' && isSectionEditable(b, 'kern')
? composeKern(b, s.availablePassages, m.besluit, m.reasons) ? composeKern(b, s.availablePassages, m.besluit, m.reasons)
: b, : b,
); );
@@ -260,6 +271,15 @@ export function reduce(s: BriefState, m: BriefMsg): BriefState {
case 'Sent': case 'Sent':
return transition(s, 'approved', () => ({ tag: 'sent', sentAt: m.at }), m.decisions); return transition(s, 'approved', () => ({ tag: 'sent', sentAt: m.at }), m.decisions);
// The action lifecycle (RD-12): a no-op unless a brief is loaded, since there is
// nothing to attach the action state to otherwise.
case 'ActionStarted':
return s.tag === 'Loaded' ? { ...s, action: { tag: 'Busy' } } : s;
case 'ActionFinished':
return s.tag === 'Loaded' ? { ...s, action: { tag: 'Idle' } } : s;
case 'ActionFailed':
return s.tag === 'Loaded' ? { ...s, action: { tag: 'Failed', error: m.error } } : s;
default: default:
return assertNever(m); return assertNever(m);
} }
@@ -275,6 +295,6 @@ function transition(
decisions: BriefDecisions, decisions: BriefDecisions,
guard: (b: Brief) => boolean = () => true, guard: (b: Brief) => boolean = () => true,
): BriefState { ): BriefState {
if (s.tag !== 'loaded' || s.brief.status.tag !== from || !guard(s.brief)) return s; if (s.tag !== 'Loaded' || s.brief.status.tag !== from || !guard(s.brief)) return s;
return { ...s, brief: { ...s.brief, status: next() }, decisions }; return { ...s, brief: { ...s.brief, status: next() }, decisions };
} }
@@ -26,7 +26,7 @@ const view = (over: Partial<OrgTemplateAdminView> = {}): OrgTemplateAdminView =>
}); });
const loaded = (): OrgTemplateState => const loaded = (): OrgTemplateState =>
reduce({ tag: 'loading' }, { tag: 'DraftLoaded', view: view() }); reduce({ tag: 'Loading' }, { tag: 'DraftLoaded', view: view() });
const logoCategory: DocumentCategory = { const logoCategory: DocumentCategory = {
categoryId: 'org-logo', categoryId: 'org-logo',
@@ -41,7 +41,7 @@ const logoCategory: DocumentCategory = {
describe('org-template.machine', () => { describe('org-template.machine', () => {
it('DraftLoaded moves to loaded with the draft, clean', () => { it('DraftLoaded moves to loaded with the draft, clean', () => {
const s = expectTag(loaded(), 'loaded'); const s = expectTag(loaded(), 'Loaded');
expect(s.draft.orgName).toBe('CIBG'); expect(s.draft.orgName).toBe('CIBG');
expect(s.subOrgId).toBe('cibg-registers'); expect(s.subOrgId).toBe('cibg-registers');
expect(s.unsentBriefs).toBe(2); expect(s.unsentBriefs).toBe(2);
@@ -49,14 +49,14 @@ describe('org-template.machine', () => {
}); });
it('LoadFailed carries the reason', () => { it('LoadFailed carries the reason', () => {
const s = reduce({ tag: 'loading' }, { tag: 'LoadFailed', reason: 'boom' }); const s = reduce({ tag: 'Loading' }, { tag: 'LoadFailed', reason: 'boom' });
expect(s).toEqual({ tag: 'failed', reason: 'boom' }); expect(s).toEqual({ tag: 'Failed', reason: 'boom' });
}); });
it('FieldEdited edits the draft and marks dirty', () => { it('FieldEdited edits the draft and marks dirty', () => {
const s = expectTag( const s = expectTag(
reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'CIBG Nieuw' }), reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'CIBG Nieuw' }),
'loaded', 'Loaded',
); );
expect(s.draft.orgName).toBe('CIBG Nieuw'); expect(s.draft.orgName).toBe('CIBG Nieuw');
expect(s.dirty).toBe(true); expect(s.dirty).toBe(true);
@@ -65,7 +65,7 @@ describe('org-template.machine', () => {
it('MarginEdited edits one edge and marks dirty', () => { it('MarginEdited edits one edge and marks dirty', () => {
const s = expectTag( const s = expectTag(
reduce(loaded(), { tag: 'MarginEdited', edge: 'topMm', value: 40 }), reduce(loaded(), { tag: 'MarginEdited', edge: 'topMm', value: 40 }),
'loaded', 'Loaded',
); );
expect(s.draft.margins.topMm).toBe(40); expect(s.draft.margins.topMm).toBe(40);
expect(s.draft.margins.leftMm).toBe(20); expect(s.draft.margins.leftMm).toBe(20);
@@ -75,9 +75,9 @@ describe('org-template.machine', () => {
it('DraftSaved clears dirty when the saved draft is the current one', () => { it('DraftSaved clears dirty when the saved draft is the current one', () => {
const edited = expectTag( const edited = expectTag(
reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'X' }), reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'X' }),
'loaded', 'Loaded',
); );
const s = expectTag(reduce(edited, { tag: 'DraftSaved', savedDraft: edited.draft }), 'loaded'); const s = expectTag(reduce(edited, { tag: 'DraftSaved', savedDraft: edited.draft }), 'Loaded');
expect(s.dirty).toBe(false); expect(s.dirty).toBe(false);
expect(s.draft.orgName).toBe('X'); expect(s.draft.orgName).toBe('X');
}); });
@@ -85,20 +85,20 @@ describe('org-template.machine', () => {
it('DraftSaved keeps dirty when an edit landed during the save round-trip', () => { it('DraftSaved keeps dirty when an edit landed during the save round-trip', () => {
const editing = expectTag( const editing = expectTag(
reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'X' }), reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'X' }),
'loaded', 'Loaded',
); );
const savedDraft = editing.draft; const savedDraft = editing.draft;
// a further edit changes the draft reference before the save resolves // a further edit changes the draft reference before the save resolves
const raced = reduce(editing, { tag: 'FieldEdited', field: 'orgName', value: 'Y' }); const raced = reduce(editing, { tag: 'FieldEdited', field: 'orgName', value: 'Y' });
const s = expectTag(reduce(raced, { tag: 'DraftSaved', savedDraft }), 'loaded'); const s = expectTag(reduce(raced, { tag: 'DraftSaved', savedDraft }), 'Loaded');
expect(s.dirty).toBe(true); expect(s.dirty).toBe(true);
}); });
it('edits are no-ops in non-loaded states', () => { it('edits are no-ops in non-loaded states', () => {
expect( expect(
reduce({ tag: 'loading' }, { tag: 'FieldEdited', field: 'orgName', value: 'x' }), reduce({ tag: 'Loading' }, { tag: 'FieldEdited', field: 'orgName', value: 'x' }),
).toEqual({ ).toEqual({
tag: 'loading', tag: 'Loading',
}); });
}); });
@@ -122,7 +122,7 @@ describe('org-template.machine', () => {
tag: 'Upload', tag: 'Upload',
msg: { type: 'UploadComplete', localId: 'a', documentId: 'doc-1' }, msg: { type: 'UploadComplete', localId: 'a', documentId: 'doc-1' },
}), }),
'loaded', 'Loaded',
); );
expect(done.draft.logoDocumentId).toBe('doc-1'); expect(done.draft.logoDocumentId).toBe('doc-1');
expect(done.dirty).toBe(true); expect(done.dirty).toBe(true);
@@ -138,7 +138,7 @@ describe('org-template.machine', () => {
tag: 'Upload', tag: 'Upload',
msg: { type: 'UploadRemoved', localId: 'a' }, msg: { type: 'UploadRemoved', localId: 'a' },
}), }),
'loaded', 'Loaded',
); );
expect(removed.draft.logoDocumentId).toBeUndefined(); expect(removed.draft.logoDocumentId).toBeUndefined();
expect(removed.dirty).toBe(true); expect(removed.dirty).toBe(true);
@@ -154,10 +154,50 @@ describe('org-template.machine', () => {
tag: 'DraftLoaded', tag: 'DraftLoaded',
view: view({ draft: { ...template, subOrgId: 'cibg-vakbekwaamheid' } }), view: view({ draft: { ...template, subOrgId: 'cibg-vakbekwaamheid' } }),
}), }),
'loaded', 'Loaded',
); );
expect(switched.upload.categories).toHaveLength(1); expect(switched.upload.categories).toHaveLength(1);
expect(switched.upload.uploads).toHaveLength(0); expect(switched.upload.uploads).toHaveLength(0);
expect(switched.subOrgId).toBe('cibg-vakbekwaamheid'); expect(switched.subOrgId).toBe('cibg-vakbekwaamheid');
}); });
// --- the action lifecycle + publish impact-confirm gate, folded into one union (RD-13) ---
it('PublishRequested moves a loaded template to ConfirmingPublish', () => {
const s = expectTag(reduce(loaded(), { tag: 'PublishRequested' }), 'Loaded');
expect(s.action).toEqual({ tag: 'ConfirmingPublish' });
});
it('PublishCancelled returns to Idle', () => {
const confirming = reduce(loaded(), { tag: 'PublishRequested' });
const s = expectTag(reduce(confirming, { tag: 'PublishCancelled' }), 'Loaded');
expect(s.action).toEqual({ tag: 'Idle' });
});
it('ActionStarted from ConfirmingPublish goes to Busy, so confirming and busy cannot coexist', () => {
const confirming = expectTag(reduce(loaded(), { tag: 'PublishRequested' }), 'Loaded');
expect(confirming.action.tag).toBe('ConfirmingPublish');
const s = expectTag(reduce(confirming, { tag: 'ActionStarted' }), 'Loaded');
expect(s.action).toEqual({ tag: 'Busy' });
});
it('ActionFailed carries the error', () => {
const busy = reduce(loaded(), { tag: 'ActionStarted' });
const s = expectTag(reduce(busy, { tag: 'ActionFailed', error: 'mislukt' }), 'Loaded');
expect(s.action).toEqual({ tag: 'Failed', error: 'mislukt' });
});
it('DraftLoaded resets a stale action error to Idle', () => {
const failed = reduce(loaded(), { tag: 'ActionFailed', error: 'mislukt' });
const s = expectTag(reduce(failed, { tag: 'DraftLoaded', view: view() }), 'Loaded');
expect(s.action).toEqual({ tag: 'Idle' });
});
it('an action message is a no-op when the template is not loaded', () => {
expect(reduce({ tag: 'Loading' }, { tag: 'PublishRequested' })).toEqual({ tag: 'Loading' });
expect(reduce({ tag: 'Loading' }, { tag: 'ActionStarted' })).toEqual({ tag: 'Loading' });
expect(reduce({ tag: 'Loading' }, { tag: 'ActionFailed', error: 'x' })).toEqual({
tag: 'Loading',
});
});
}); });
@@ -3,12 +3,19 @@ import { Margins, OrgTemplate, OrgTemplateAdminView, OrgTemplateVersion } from '
import { UploadMsg, UploadState, initialUpload, reduceUpload } from '@shared/domain/upload.machine'; import { UploadMsg, UploadState, initialUpload, reduceUpload } from '@shared/domain/upload.machine';
/** /**
* The admin org-template editor as one Elm-style machine (WP-26, PRD Brief v2 §5) — * The admin org-template editor as one Elm-style machine (PRD Brief v2 §5) —
* the same idiom as the wizards. The DRAFT org template is form state (edited in * 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`. * 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 * `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 * the composable upload sub-machine folded in, exactly like the wizards fold
* `reduceUpload` — its `UploadComplete`/`UploadRemoved` also mutate `draft.logoDocumentId`. * `reduceUpload` — its `UploadComplete`/`UploadRemoved` also mutate `draft.logoDocumentId`.
*
* `action` (RD-13) owns the one-shot action lifecycle AND the publish impact-confirm
* gate as ONE four-variant union, replacing two independent store-level signals
* (`actionState` + `pendingPublish`). Before RD-13, `pendingPublish === true && busy
* === true` was representable and meaningless — the confirm dialog could show while a
* publish was already in flight. A single field with one tag at a time makes that
* combination unrepresentable.
*/ */
/** The org-identity text fields editable directly on the letter canvas. */ /** The org-identity text fields editable directly on the letter canvas. */
@@ -21,11 +28,22 @@ export type OrgTemplateTextField =
| 'signatureRole' | 'signatureRole'
| 'signatureClosing'; | 'signatureClosing';
/** The one-shot action lifecycle (publish/rollback/proefbrief), plus the publish
impact-confirm gate, owned by the reducer instead of two independent store-level
signals (RD-13). `ConfirmingPublish` is a variant of this SAME union, so
"confirming a publish while one is already in flight" is unrepresentable — no
state can ever carry both at once. */
export type OrgTemplateActionState =
| { tag: 'Idle' }
| { tag: 'ConfirmingPublish' }
| { tag: 'Busy' }
| { tag: 'Failed'; error: string };
export type OrgTemplateState = export type OrgTemplateState =
| { tag: 'loading' } | { tag: 'Loading' }
| { tag: 'failed'; reason: string } | { tag: 'Failed'; reason: string }
| { | {
tag: 'loaded'; tag: 'Loaded';
subOrgId: string; subOrgId: string;
draft: OrgTemplate; draft: OrgTemplate;
publishedVersion: number; publishedVersion: number;
@@ -34,9 +52,10 @@ export type OrgTemplateState =
dirty: boolean; dirty: boolean;
/** Logo upload sub-state (single file, `org-logo` category). */ /** Logo upload sub-state (single file, `org-logo` category). */
upload: UploadState; upload: UploadState;
action: OrgTemplateActionState;
}; };
export const initial: OrgTemplateState = { tag: 'loading' }; export const initial: OrgTemplateState = { tag: 'Loading' };
export type OrgTemplateMsg = export type OrgTemplateMsg =
| { tag: 'Loading' } | { tag: 'Loading' }
@@ -47,22 +66,27 @@ export type OrgTemplateMsg =
/** Carries the draft that was saved: clears `dirty` only if no edit landed during /** 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. */ the round-trip (reference-equal), so a concurrent edit keeps its pending save. */
| { tag: 'DraftSaved'; savedDraft: OrgTemplate } | { tag: 'DraftSaved'; savedDraft: OrgTemplate }
| { tag: 'Upload'; msg: UploadMsg }; | { tag: 'Upload'; msg: UploadMsg }
| { tag: 'PublishRequested' } // opens the publish impact-confirm gate
| { tag: 'PublishCancelled' } // closes it without publishing
| { tag: 'ActionStarted' } // a one-shot action (publish/rollback/proefbrief) began
| { tag: 'ActionFinished' } // it completed successfully
| { tag: 'ActionFailed'; error: string }; // it failed, carrying the message to show
/** Edit the loaded draft; a no-op in any non-loaded state (illegal by construction). */ /** Edit the loaded draft; a no-op in any non-loaded state (illegal by construction). */
function editDraft(s: OrgTemplateState, f: (draft: OrgTemplate) => OrgTemplate): OrgTemplateState { function editDraft(s: OrgTemplateState, f: (draft: OrgTemplate) => OrgTemplate): OrgTemplateState {
return s.tag === 'loaded' ? { ...s, draft: f(s.draft), dirty: true } : s; return s.tag === 'Loaded' ? { ...s, draft: f(s.draft), dirty: true } : s;
} }
export function reduce(s: OrgTemplateState, m: OrgTemplateMsg): OrgTemplateState { export function reduce(s: OrgTemplateState, m: OrgTemplateMsg): OrgTemplateState {
switch (m.tag) { switch (m.tag) {
case 'Loading': case 'Loading':
return { tag: 'loading' }; return { tag: 'Loading' };
case 'LoadFailed': case 'LoadFailed':
return { tag: 'failed', reason: m.reason }; return { tag: 'Failed', reason: m.reason };
case 'DraftLoaded': case 'DraftLoaded':
return { return {
tag: 'loaded', tag: 'Loaded',
subOrgId: m.view.draft.subOrgId, subOrgId: m.view.draft.subOrgId,
draft: m.view.draft, draft: m.view.draft,
publishedVersion: m.view.publishedVersion, publishedVersion: m.view.publishedVersion,
@@ -71,16 +95,19 @@ export function reduce(s: OrgTemplateState, m: OrgTemplateMsg): OrgTemplateState
dirty: false, dirty: false,
// Keep the loaded logo category across sub-org switches (it's the same // 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. // `org-logo` category, loaded once); drop only any in-flight/finished uploads.
upload: s.tag === 'loaded' ? { ...s.upload, uploads: [], rejections: {} } : initialUpload, upload: s.tag === 'Loaded' ? { ...s.upload, uploads: [], rejections: {} } : initialUpload,
// A fresh load clears a stale action error rather than letting it outlive
// the reload (RD-13, same as brief's RD-12).
action: { tag: 'Idle' },
}; };
case 'FieldEdited': case 'FieldEdited':
return editDraft(s, (d) => ({ ...d, [m.field]: m.value })); return editDraft(s, (d) => ({ ...d, [m.field]: m.value }));
case 'MarginEdited': case 'MarginEdited':
return editDraft(s, (d) => ({ ...d, margins: { ...d.margins, [m.edge]: m.value } })); return editDraft(s, (d) => ({ ...d, margins: { ...d.margins, [m.edge]: m.value } }));
case 'DraftSaved': case 'DraftSaved':
return s.tag === 'loaded' && s.draft === m.savedDraft ? { ...s, dirty: false } : s; return s.tag === 'Loaded' && s.draft === m.savedDraft ? { ...s, dirty: false } : s;
case 'Upload': { case 'Upload': {
if (s.tag !== 'loaded') return s; if (s.tag !== 'Loaded') return s;
const upload = reduceUpload(s.upload, m.msg); const upload = reduceUpload(s.upload, m.msg);
// A completed/removed logo upload also updates the draft's logoDocumentId. // A completed/removed logo upload also updates the draft's logoDocumentId.
if (m.msg.type === 'UploadComplete') if (m.msg.type === 'UploadComplete')
@@ -96,6 +123,22 @@ export function reduce(s: OrgTemplateState, m: OrgTemplateMsg): OrgTemplateState
} }
return { ...s, upload }; return { ...s, upload };
} }
// The action lifecycle (RD-13): a no-op unless a template is loaded, since there
// is nothing to attach the action state to otherwise. `ConfirmingPublish` and
// `Busy` are variants of one field, so ActionStarted overwriting it to `Busy` is
// what makes the two mutually exclusive by construction — not by convention.
case 'PublishRequested':
return s.tag === 'Loaded' ? { ...s, action: { tag: 'ConfirmingPublish' } } : s;
case 'PublishCancelled':
return s.tag === 'Loaded' ? { ...s, action: { tag: 'Idle' } } : s;
case 'ActionStarted':
return s.tag === 'Loaded' ? { ...s, action: { tag: 'Busy' } } : s;
case 'ActionFinished':
return s.tag === 'Loaded' ? { ...s, action: { tag: 'Idle' } } : s;
case 'ActionFailed':
return s.tag === 'Loaded' ? { ...s, action: { tag: 'Failed', error: m.error } } : s;
default: default:
return assertNever(m); return assertNever(m);
} }
@@ -1,9 +1,9 @@
/** /**
* The organization template (Brief v2 PRD §3, WP-23/24): the SECOND template axis — * The organization template (Brief v2 PRD §3): the SECOND template axis —
* appearance/identity per sub-organization (letterhead, footer, signature, margins). * appearance/identity per sub-organization (letterhead, footer, signature, margins).
* Orthogonal to the case-type template (sections + placeholders); the two only meet * 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, * at render time, on the letter canvas. Server-owned: the FE renders it verbatim,
* never edits it here (the admin editor is WP-26). * never edits it here (the admin editor does).
*/ */
export interface Margins { export interface Margins {
@@ -30,7 +30,7 @@ export interface OrgTemplate {
readonly version: number; readonly version: number;
} }
// --- admin editor (WP-26) --- // --- admin editor ---
/** A published snapshot in the version history: who is faked, `publishedAt` is real. */ /** A published snapshot in the version history: who is faked, `publishedAt` is real. */
export interface OrgTemplateVersion { export interface OrgTemplateVersion {
@@ -0,0 +1,51 @@
import { Brief } from './brief';
/** 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). Production content —
the letter the org-template editor previews — not a test fixture, so it lives here
rather than in `brief.testing.ts` (dependency-cruiser's no-testing-in-production
rule forbids production code from reaching any `*.testing.ts`). */
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.',
},
],
},
],
},
},
],
},
],
};
@@ -38,9 +38,9 @@ import { Mark, Paragraph, RichTextBlock, RichTextNode } from '@shared/kernel/ric
* (ProblemDetails → error string, plus the Idempotency-Key mint), then parses the * (ProblemDetails → error string, plus the Idempotency-Key mint), then parses the
* returned brief. `load` (the only read) does its own try/catch instead of the * returned brief. `load` (the only read) does its own try/catch instead of the
* shared `runResult` fold, because it needs one extra bit `runResult` throws away: * shared `runResult` fold, because it needs one extra bit `runResult` throws away:
* whether the failure was an HTTP 404 (see `BriefLoadFailure` — RB-22, CQ-007's * whether the failure was an HTTP 404 (see `BriefLoadFailure` — CQ-007's
* expand half). Today's backend never 404s `GET /brief` (RB-23 adds that), so the * expand half). Today's backend never 404s `GET /brief`, so the
* `notFound` branch is unreached until RB-23 ships; this adapter is ready in advance. * `notFound` branch is unreached until it does; this adapter is ready in advance.
*/ */
export interface BriefView { export interface BriefView {
@@ -66,7 +66,7 @@ export const BRIEF_ACTION_FAILED = $localize`:@@brief.action.failed:De actie is
/** True when the thrown value carries an HTTP 404 status — matches both the /** True when the thrown value carries an HTTP 404 status — matches both the
generic `SwaggerException` (today's shape, since `GET /brief` declares no 404 generic `SwaggerException` (today's shape, since `GET /brief` declares no 404
response yet) and a parsed `ProblemDetails` (RFC 7807 `status`, the shape once response yet) and a parsed `ProblemDetails` (RFC 7807 `status`, the shape once
RB-23 gives the endpoint a documented 404 response). */ the endpoint gets a documented 404 response). */
function isHttpNotFound(e: unknown): boolean { function isHttpNotFound(e: unknown): boolean {
return !!e && typeof e === 'object' && (e as { status?: unknown }).status === 404; return !!e && typeof e === 'object' && (e as { status?: unknown }).status === 404;
} }
@@ -14,19 +14,19 @@ export const PREVIEW_FAILED = $localize`:@@brief.preview.failed:De voorvertoning
* to keep the NSwag-generated client JSON-only (same seam as uploads) — so this is a * 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 * hand-written fetch, not the `ApiClient`. That also means it bypasses `HttpClient`'s
* `roleInterceptor` AND `subjectInterceptor`, so both `X-Role` and `X-Subject` are set * `roleInterceptor` AND `subjectInterceptor`, so both `X-Role` and `X-Subject` are set
* here explicitly (WP-74 — without `X-Subject` this always previewed * here explicitly (without `X-Subject` this always previewed
* `DocumentStore.DemoOwner`'s letter regardless of who was actually logged in). Both are * `DocumentStore.DemoOwner`'s letter regardless of who was actually logged in). Both are
* dev-only identity stand-ins (`role.ts`/`subject.ts`) and are sent only under * dev-only identity stand-ins (`role.ts`/`subject.ts`) and are sent only under
* `isDevMode()`, mirroring how the interceptors themselves are only registered in dev * `isDevMode()`, mirroring how the interceptors themselves are only registered in dev
* (`app.config.ts`) — a production build sends neither header from this call (BIO-012). * (`app.config.ts`) — a production build sends neither header from this call (BIO-012).
* *
* `cache: 'no-store'` (WP-74): the endpoint has no `Cache-Control`, only a CORS-driven * `cache: 'no-store'`: the endpoint has no `Cache-Control`, only a CORS-driven
* `Vary: Origin`, and its content changes at the SAME URL as the letter moves * `Vary: Origin`, and its content changes at the SAME URL as the letter moves
* draft → sent. Explicitly bypassing the HTTP cache is the correct default for any * draft → sent. Explicitly bypassing the HTTP cache is the correct default for any
* mutable resource served under one unversioned URL — independent of WP-74's * mutable resource served under one unversioned URL — independent of the
* identity work, and not a complete fix by itself: see the KNOWN GAP note below. * identity work above, and not a complete fix by itself: see the KNOWN GAP note below.
* *
* KNOWN GAP (WP-74, not fixed here): under a non-`DocumentStore.DemoOwner` `X-Subject`, * KNOWN GAP (not fixed here): under a non-`DocumentStore.DemoOwner` `X-Subject`,
* this repo's own e2e run against a real backend observed this endpoint's SENT * this repo's own e2e run against a real backend observed this endpoint's SENT
* response still carrying the draft watermark, even though (a) the outgoing request * response still carrying the draft watermark, even though (a) the outgoing request
* carried the correct `X-Subject`, and (b) `curl` against the same backend at the * carried the correct `X-Subject`, and (b) `curl` against the same backend at the
@@ -34,7 +34,7 @@ export const PREVIEW_FAILED = $localize`:@@brief.preview.failed:De voorvertoning
* did not change the outcome, so it is very unlikely a client-side caching artifact — * did not change the outcome, so it is very unlikely a client-side caching artifact —
* it looks like a genuine backend-side staleness/race in `BriefStore`'s SQLite-backed * it looks like a genuine backend-side staleness/race in `BriefStore`'s SQLite-backed
* read path, reproducible for MULTIPLE distinct owners and NOT reproducible for * read path, reproducible for MULTIPLE distinct owners and NOT reproducible for
* `DemoOwner`, which needs backend-side investigation (out of WP-74's file scope — * `DemoOwner`, which needs backend-side investigation (out of this file's scope —
* see `e2e/brief-v2.spec.ts`'s header comment, which keeps that spec on the shared * see `e2e/brief-v2.spec.ts`'s header comment, which keeps that spec on the shared
* `zorgverlener` identity until this is root-caused). * `zorgverlener` identity until this is root-caused).
*/ */
@@ -1,10 +1,10 @@
import { Component, ElementRef, computed, input, output, viewChild } from '@angular/core'; import { Component, ElementRef, computed, input, output, viewChild } from '@angular/core';
import { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component'; import { PlaceholderOption } from '@shared/ui/molecules/rich-text-editor/rich-text-editor.component';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { HeadingComponent } from '@shared/ui/heading/heading.component'; import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { MaskedValueComponent } from '@shared/ui/masked-value/masked-value.component'; import { MaskedValueComponent } from '@shared/ui/atoms/masked-value/masked-value.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { StepperComponent } from '@shared/ui/stepper/stepper.component'; import { StepperComponent } from '@shared/ui/molecules/stepper/stepper.component';
import { Besluit, Brief, CaseContext, LibraryPassage } from '@brief/domain/brief'; import { Besluit, Brief, CaseContext, LibraryPassage } from '@brief/domain/brief';
import { besluitGuidance, inferSelection } from '@brief/domain/besluit'; import { besluitGuidance, inferSelection } from '@brief/domain/besluit';
import { Diagnostic } from '@brief/domain/placeholders'; import { Diagnostic } from '@brief/domain/placeholders';
@@ -1,8 +1,11 @@
import { Component, computed, input, linkedSignal, output } from '@angular/core'; import { Component, computed, input, linkedSignal, output } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { CheckboxComponent } from '@shared/ui/checkbox/checkbox.component'; import { CheckboxComponent } from '@shared/ui/atoms/checkbox/checkbox.component';
import { RadioGroupComponent, RadioOption } from '@shared/ui/radio-group/radio-group.component'; import {
import { HeadingComponent } from '@shared/ui/heading/heading.component'; RadioGroupComponent,
RadioOption,
} from '@shared/ui/atoms/radio-group/radio-group.component';
import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { Besluit, LibraryPassage } from '@brief/domain/brief'; import { Besluit, LibraryPassage } from '@brief/domain/brief';
import { redenenFor } from '@brief/domain/besluit'; import { redenenFor } from '@brief/domain/besluit';
+6 -6
View File
@@ -1,8 +1,8 @@
import { Component, computed, inject } from '@angular/core'; import { Component, computed, inject } from '@angular/core';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { ASYNC } from '@shared/ui/async/async.component'; import { ASYNC } from '@shared/ui/molecules/async/async.component';
import { BriefStore } from '@brief/application/brief.store'; import { BriefStore } from '@brief/application/brief.store';
import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-composer.component'; import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-composer.component';
import { BehandelSchermComponent } from '@brief/ui/behandel-scherm/behandel-scherm.component'; import { BehandelSchermComponent } from '@brief/ui/behandel-scherm/behandel-scherm.component';
@@ -167,19 +167,19 @@ export class BriefPage {
void this.store.resetDemo(); void this.store.resetDemo();
} }
/** Typed narrowing for the `<app-async>` loaded slot — see WP-06: a structural /** Typed narrowing for the `<app-async>` loaded slot: a structural
directive's context can't inherit a generic from a sibling host input, so the directive's context can't inherit a generic from a sibling host input, so the
Success value is unwrapped here instead of through `let-`. */ Success value is unwrapped here instead of through `let-`. */
protected readonly loaded = computed(() => { protected readonly loaded = computed(() => {
const s = this.model(); const s = this.model();
return s.tag === 'loaded' ? s : undefined; return s.tag === 'Loaded' ? s : undefined;
}); });
protected reload() { protected reload() {
void this.store.load(); void this.store.load();
} }
/** Ctrl/Cmd+Z = undo, Ctrl/Cmd+Shift+Z = redo (WP-27). Ignored while focus is in the /** Ctrl/Cmd+Z = undo, Ctrl/Cmd+Shift+Z = redo. Ignored while focus is in the
rich-text editor or a form control, so the browser's own text undo keeps working 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). */ there — our shell-level undo is for structural edits (add/remove/reorder blocks). */
protected onKey(e: KeyboardEvent) { protected onKey(e: KeyboardEvent) {
@@ -1,5 +1,5 @@
import { Component, computed, input, output } from '@angular/core'; import { Component, computed, input, output } from '@angular/core';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { Diagnostic } from '@brief/domain/placeholders'; import { Diagnostic } from '@brief/domain/placeholders';
/** Molecule: lists all letter diagnostics grouped by severity. Errors block /** Molecule: lists all letter diagnostics grouped by severity. Errors block
@@ -3,8 +3,8 @@ import { RichTextBlock } from '@shared/kernel/rich-text';
import { import {
RichTextEditorComponent, RichTextEditorComponent,
PlaceholderOption, PlaceholderOption,
} from '@shared/ui/rich-text-editor/rich-text-editor.component'; } from '@shared/ui/molecules/rich-text-editor/rich-text-editor.component';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { LetterBlock } from '@brief/domain/brief'; import { LetterBlock } from '@brief/domain/brief';
/** Molecule: one block in a section — its editor plus provenance + block controls. /** Molecule: one block in a section — its editor plus provenance + block controls.
@@ -1,3 +1,6 @@
/* eslint-disable max-lines */ // 77 lines of CSS + one letter's markup; splitting it into
// letterhead/body/signature/footer makes "what does the letter look like" a five-file
// question for no behavioural seam. Deliberate, not deferred.
import { import {
Component, Component,
DestroyRef, DestroyRef,
@@ -11,9 +14,7 @@ import {
signal, signal,
viewChild, viewChild,
} from '@angular/core'; } from '@angular/core';
import { NgTemplateOutlet } from '@angular/common'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
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 { formatDatumNl } from '@shared/kernel/datum';
import { Paragraph } from '@shared/kernel/rich-text'; import { Paragraph } from '@shared/kernel/rich-text';
import { Brief, LetterBlock } from '@brief/domain/brief'; import { Brief, LetterBlock } from '@brief/domain/brief';
@@ -21,6 +22,7 @@ import { OrgTemplate } from '@brief/domain/org-template';
import { OrgTemplateTextField } from '@brief/domain/org-template.machine'; import { OrgTemplateTextField } from '@brief/domain/org-template.machine';
import { Diagnostic } from '@brief/domain/placeholders'; import { Diagnostic } from '@brief/domain/placeholders';
import { BlockDiffKind } from '@brief/domain/brief-diff'; import { BlockDiffKind } from '@brief/domain/brief-diff';
import { LetterLineComponent } from './letter-line.component';
/** A run of consecutive lines to render together: a list (bullet/number) or a single plain line. */ /** A run of consecutive lines to render together: a list (bullet/number) or a single plain line. */
type PreviewSegment = { type PreviewSegment = {
@@ -39,12 +41,6 @@ function groupParagraphs(paras: readonly Paragraph[]): PreviewSegment[] {
return out; 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. */ /** A4 height in CSS px (1in = 96px = 25.4mm) — for the approximate page-break marks. */
const A4_HEIGHT_PX = (297 * 96) / 25.4; const A4_HEIGHT_PX = (297 * 96) / 25.4;
@@ -52,12 +48,14 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
footer around the case-type template's sections. `editableRegions` picks who edits footer around the case-type template's sections. `editableRegions` picks who edits
what: `'content'` hosts the editable letter-sections in place (drafter), `'none'` what: `'content'` hosts the editable letter-sections in place (drafter), `'none'`
renders everything read-only (approver/locked, absorbs the old letter-preview), renders everything read-only (approver/locked, absorbs the old letter-preview),
`'template'` reserves the org-identity regions for the admin editor (WP-26). `'template'` reserves the org-identity regions for the admin editor.
Letter typography/geometry come from the shared `public/letter.css` contract — Letter typography/geometry come from the shared `public/letter.css` contract —
the same file the backend preview renderer inlines (WP-25). */ the same file the backend preview renderer inlines. Each rendered line is its own
`app-letter-line` (RD-26), replacing the outlet-template indirection that stood
in for it. */
@Component({ @Component({
selector: 'app-letter-canvas', selector: 'app-letter-canvas',
imports: [NgTemplateOutlet, ButtonComponent, PlaceholderChipComponent], imports: [ButtonComponent, LetterLineComponent],
styles: [ styles: [
` `
:host { :host {
@@ -81,7 +79,7 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
color: var(--rhc-color-foreground-subtle); color: var(--rhc-color-foreground-subtle);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
/* Rejection-diff badge (WP-27): a small pill above a changed/added block. */ /* Rejection-diff badge: a small pill above a changed/added block. */
.diff-block.diff-changed { .diff-block.diff-changed {
border-inline-start: 3px solid var(--rhc-color-oranje-500); border-inline-start: 3px solid var(--rhc-color-oranje-500);
padding-inline-start: var(--rhc-space-max-sm); padding-inline-start: var(--rhc-space-max-sm);
@@ -97,7 +95,7 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
background: var(--rhc-color-oranje-500); background: var(--rhc-color-oranje-500);
} }
.diff-badge.added { .diff-badge.added {
/* added = white on groen-700 (6.4:1); dark text on any green fails 4.5:1 (WP-29 axe). */ /* added = white on groen-700 (6.4:1); dark text on any green fails 4.5:1 (axe). */
color: var(--rhc-color-wit); color: var(--rhc-color-wit);
background: var(--rhc-color-groen-700); background: var(--rhc-color-groen-700);
} }
@@ -136,37 +134,19 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
`, `,
], ],
template: ` 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') { @if (editableRegions() !== 'template') {
<div class="toolbar"> <div class="toolbar">
<div class="zoom" role="group" [attr.aria-label]="zoomGroupLabel()"> <div
class="zoom"
role="group"
aria-label="Zoomniveau"
i18n-aria-label="@@brief.canvas.zoom"
>
<app-button <app-button
variant="subtle" variant="subtle"
[disabled]="zoomLevel() <= 0.5" [disabled]="zoomLevel() <= 0.5"
[attr.aria-label]="zoomOutLabel()" aria-label="Uitzoomen"
i18n-aria-label="@@brief.canvas.zoomOut"
(click)="zoomBy(-0.1)" (click)="zoomBy(-0.1)"
>−</app-button >−</app-button
> >
@@ -174,13 +154,14 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
<app-button <app-button
variant="subtle" variant="subtle"
[disabled]="zoomLevel() >= 1.5" [disabled]="zoomLevel() >= 1.5"
[attr.aria-label]="zoomInLabel()" aria-label="Inzoomen"
i18n-aria-label="@@brief.canvas.zoomIn"
(click)="zoomBy(0.1)" (click)="zoomBy(0.1)"
>+</app-button >+</app-button
> >
<app-button variant="subtle" (click)="zoomLevel.set(1)">{{ <app-button variant="subtle" (click)="zoomLevel.set(1)" i18n="@@brief.canvas.zoomReset"
zoomResetLabel() >100%</app-button
}}</app-button> >
</div> </div>
@if (editableRegions() === 'none') { @if (editableRegions() === 'none') {
<app-button <app-button
@@ -188,7 +169,13 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
(click)="showSample.set(!showSample())" (click)="showSample.set(!showSample())"
[attr.aria-pressed]="showSample()" [attr.aria-pressed]="showSample()"
> >
{{ showSample() ? hideSampleLabel() : showSampleLabel() }} @if (showSample()) {
<ng-container i18n="@@brief.preview.hideSample">Testwaarden verbergen</ng-container>
} @else {
<ng-container i18n="@@brief.preview.showSample"
>Voorbeeld met testwaarden</ng-container
>
}
</app-button> </app-button>
} }
</div> </div>
@@ -200,20 +187,27 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
(robijn footer background) — the letter surface must stay letter.css-only. --> (robijn footer background) — the letter surface must stay letter.css-only. -->
<div class="letter__letterhead"> <div class="letter__letterhead">
@if (logoUrl()) { @if (logoUrl()) {
<img class="org-logo" [src]="logoUrl()" [alt]="logoAlt()" /> <img
class="org-logo"
[src]="logoUrl()"
alt="Logo van de organisatie"
i18n-alt="@@brief.canvas.logoAlt"
/>
} }
@if (editing()) { @if (editing()) {
<input <input
class="tmpl-input org-wordmark" class="tmpl-input org-wordmark"
[value]="orgTemplate().orgName" [value]="orgTemplate().orgName"
[attr.aria-label]="orgNameLabel()" aria-label="Organisatienaam"
i18n-aria-label="@@brief.canvas.orgName"
(input)="emitEdit('orgName', $event)" (input)="emitEdit('orgName', $event)"
/> />
<textarea <textarea
class="tmpl-textarea return-address" class="tmpl-textarea return-address"
rows="2" rows="2"
[value]="orgTemplate().returnAddress" [value]="orgTemplate().returnAddress"
[attr.aria-label]="returnAddressLabel()" aria-label="Retouradres"
i18n-aria-label="@@brief.canvas.returnAddress"
(input)="emitEdit('returnAddress', $event)" (input)="emitEdit('returnAddress', $event)"
></textarea> ></textarea>
} @else { } @else {
@@ -223,11 +217,11 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
<address class="address-window">{{ recipientText() }}</address> <address class="address-window">{{ recipientText() }}</address>
<dl class="reference"> <dl class="reference">
<div> <div>
<dt>{{ referenceLabel() }}</dt> <dt i18n="@@brief.canvas.reference">Ons kenmerk</dt>
<dd>{{ brief().briefId }}</dd> <dd>{{ brief().briefId }}</dd>
</div> </div>
<div> <div>
<dt>{{ dateLabel() }}</dt> <dt i18n="@@brief.canvas.date">Datum</dt>
<dd>{{ letterDate }}</dd> <dd>{{ letterDate }}</dd>
</div> </div>
</dl> </dl>
@@ -241,18 +235,27 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
@let diffKind = showDiff() ? blockDiffs().get(block.blockId) : undefined; @let diffKind = showDiff() ? blockDiffs().get(block.blockId) : undefined;
<div class="diff-block" [class.diff-changed]="!!diffKind"> <div class="diff-block" [class.diff-changed]="!!diffKind">
@if (diffKind) { @if (diffKind) {
<span class="diff-badge" [class.added]="diffKind === 'added'">{{ <span class="diff-badge" [class.added]="diffKind === 'added'">
diffLabel(diffKind) @if (diffKind === 'added') {
}}</span> <ng-container i18n="@@brief.diff.added">nieuw</ng-container>
} @else {
<ng-container i18n="@@brief.diff.changed"
>gewijzigd sinds afwijzing</ng-container
>
}
</span>
} }
@for (seg of segmentsOf(block); track $index) { @for (seg of segmentsOf(block); track $index) {
@if (seg.list === 'bullet') { @if (seg.list === 'bullet') {
<ul> <ul>
@for (para of seg.items; track $index) { @for (para of seg.items; track $index) {
<li> <li>
<ng-container <app-letter-line
[ngTemplateOutlet]="line" [nodes]="para.nodes"
[ngTemplateOutletContext]="{ $implicit: para.nodes }" [showSample]="showSample()"
[placeholders]="brief().placeholders"
[diagnostics]="diagnostics()"
[sampleDate]="letterDate"
/> />
</li> </li>
} }
@@ -261,18 +264,24 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
<ol> <ol>
@for (para of seg.items; track $index) { @for (para of seg.items; track $index) {
<li> <li>
<ng-container <app-letter-line
[ngTemplateOutlet]="line" [nodes]="para.nodes"
[ngTemplateOutletContext]="{ $implicit: para.nodes }" [showSample]="showSample()"
[placeholders]="brief().placeholders"
[diagnostics]="diagnostics()"
[sampleDate]="letterDate"
/> />
</li> </li>
} }
</ol> </ol>
} @else { } @else {
<p> <p>
<ng-container <app-letter-line
[ngTemplateOutlet]="line" [nodes]="seg.items[0].nodes"
[ngTemplateOutletContext]="{ $implicit: seg.items[0].nodes }" [showSample]="showSample()"
[placeholders]="brief().placeholders"
[diagnostics]="diagnostics()"
[sampleDate]="letterDate"
/> />
</p> </p>
} }
@@ -288,19 +297,22 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
<input <input
class="tmpl-input" class="tmpl-input"
[value]="orgTemplate().signatureClosing" [value]="orgTemplate().signatureClosing"
[attr.aria-label]="signatureClosingLabel()" aria-label="Afsluiting"
i18n-aria-label="@@brief.canvas.signatureClosing"
(input)="emitEdit('signatureClosing', $event)" (input)="emitEdit('signatureClosing', $event)"
/> />
<input <input
class="tmpl-input signature-name" class="tmpl-input signature-name"
[value]="orgTemplate().signatureName" [value]="orgTemplate().signatureName"
[attr.aria-label]="signatureNameLabel()" aria-label="Naam ondertekenaar"
i18n-aria-label="@@brief.canvas.signatureName"
(input)="emitEdit('signatureName', $event)" (input)="emitEdit('signatureName', $event)"
/> />
<input <input
class="tmpl-input" class="tmpl-input"
[value]="orgTemplate().signatureRole" [value]="orgTemplate().signatureRole"
[attr.aria-label]="signatureRoleLabel()" aria-label="Functie ondertekenaar"
i18n-aria-label="@@brief.canvas.signatureRole"
(input)="emitEdit('signatureRole', $event)" (input)="emitEdit('signatureRole', $event)"
/> />
} @else { } @else {
@@ -316,13 +328,15 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
class="tmpl-textarea footer-contact" class="tmpl-textarea footer-contact"
rows="2" rows="2"
[value]="orgTemplate().footerContact" [value]="orgTemplate().footerContact"
[attr.aria-label]="footerContactLabel()" aria-label="Contactgegevens (voettekst)"
i18n-aria-label="@@brief.canvas.footerContact"
(input)="emitEdit('footerContact', $event)" (input)="emitEdit('footerContact', $event)"
></textarea> ></textarea>
<input <input
class="tmpl-input footer-legal" class="tmpl-input footer-legal"
[value]="orgTemplate().footerLegal" [value]="orgTemplate().footerLegal"
[attr.aria-label]="footerLegalLabel()" aria-label="Juridische voettekst"
i18n-aria-label="@@brief.canvas.footerLegal"
(input)="emitEdit('footerLegal', $event)" (input)="emitEdit('footerLegal', $event)"
/> />
} @else { } @else {
@@ -333,7 +347,7 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
@for (top of pageBreaks(); track $index) { @for (top of pageBreaks(); track $index) {
<div class="letter__page-break" [style.top.px]="top" aria-hidden="true"> <div class="letter__page-break" [style.top.px]="top" aria-hidden="true">
<span>{{ pageBreakCaption() }}</span> <span i18n="@@brief.canvas.pageBreak">±pagina-einde — afdrukvoorbeeld is leidend</span>
</div> </div>
} }
</div> </div>
@@ -344,12 +358,12 @@ export class LetterCanvasComponent {
brief = input.required<Brief>(); brief = input.required<Brief>();
orgTemplate = input.required<OrgTemplate>(); orgTemplate = input.required<OrgTemplate>();
/** Who edits what on the surface: read-only ('none', the drafter preview + approver /** 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. */ view) or admin editor ('template'). Authoring moved to letter-editor. */
editableRegions = input<'template' | 'none'>('none'); editableRegions = input<'template' | 'none'>('none');
diagnostics = input<readonly Diagnostic[]>([]); diagnostics = input<readonly Diagnostic[]>([]);
/** Initial zoom; the in-canvas controls take over from here (WP-27). */ /** Initial zoom; the in-canvas controls take over from here. */
zoom = input(1); zoom = input(1);
/** Blocks changed/added/removed since the letter was rejected (WP-27); badged when /** Blocks changed/added/removed since the letter was rejected; badged when
`showDiff` is on. Removed blocks aren't in the map's rendered set — they no longer `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. */ exist in the letter — the composer surfaces them as a count. */
blockDiffs = input<ReadonlyMap<string, BlockDiffKind>>(new Map()); blockDiffs = input<ReadonlyMap<string, BlockDiffKind>>(new Map());
@@ -359,30 +373,13 @@ export class LetterCanvasComponent {
/** An in-place edit to an org-identity field (only in `editableRegions='template'`). */ /** An in-place edit to an org-identity field (only in `editableRegions='template'`). */
templateEdit = output<{ field: OrgTemplateTextField; value: string }>(); templateEdit = output<{ field: OrgTemplateTextField; value: string }>();
showSampleLabel = input($localize`:@@brief.preview.showSample:Voorbeeld met testwaarden`); /** The one label kept as an `input()` rather than inlined `i18n` (RD-26, decision 2):
hideSampleLabel = input($localize`:@@brief.preview.hideSample:Testwaarden verbergen`); its message embeds a literal `\n`. As template text that `\n` becomes a source
pageBreakCaption = input( line break, a different string to Angular's extractor — so inlining it would
$localize`:@@brief.canvas.pageBreak:±pagina-einde — afdrukvoorbeeld is leidend`, change the extracted source text, unlike the other 19 labels this ticket inlines. */
);
recipientText = input( recipientText = input(
$localize`:@@brief.canvas.recipient:Adres van de geadresseerde\n(wordt ingevuld bij verzending)`, $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 showSample = signal(false);
protected letterDate = formatDatumNl(new Date()); protected letterDate = formatDatumNl(new Date());
@@ -394,9 +391,6 @@ export class LetterCanvasComponent {
// clamp 0.5–1.5; round to avoid float drift accumulating on repeated clicks. // 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); 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. */ /** Admin edit-in-place: the org-identity regions render as controls. */
protected editing = computed(() => this.editableRegions() === 'template'); protected editing = computed(() => this.editableRegions() === 'template');
@@ -417,25 +411,9 @@ export class LetterCanvasComponent {
}; };
}); });
// --- read-only rendering helpers (migrated from the superseded letter-preview) --- // --- read-only rendering helper (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 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) --- // --- approximate page-break marks (PRD §2b: honest "±", print preview is leading) ---
@@ -444,7 +422,7 @@ export class LetterCanvasComponent {
constructor() { constructor() {
// ponytail: whole-surface height / A4-interval — ignores that a break never truly // 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. // falls mid-line; the caption says "±" and the server preview is authoritative.
const observer = new ResizeObserver(([entry]) => { const observer = new ResizeObserver(([entry]) => {
// ~1cm tolerance so a letter ending on a page boundary gets no edge-hugging mark. // ~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); const pages = Math.ceil((entry.target.scrollHeight - 40) / A4_HEIGHT_PX);
@@ -127,12 +127,12 @@ export const ReadOnlyZonderBevindingen: Story = {
args: { editableRegions: 'none', diagnostics: [] }, args: { editableRegions: 'none', diagnostics: [] },
}; };
/** Admin editor focus (consumer arrives in WP-26): body read-only, no "not yours" tint. */ /** Admin editor focus: body read-only, no "not yours" tint. */
export const TemplateMode: Story = { args: { editableRegions: 'template' } }; export const TemplateMode: Story = { args: { editableRegions: 'template' } };
export const Zoomed: Story = { args: { editableRegions: 'none', zoom: 0.6 } }; export const Zoomed: Story = { args: { editableRegions: 'none', zoom: 0.6 } };
/** Approver's "Toon wijzigingen": blocks changed/added since rejection are badged (WP-27). */ /** Approver's "Toon wijzigingen": blocks changed/added since rejection are badged. */
export const WithDiff: Story = { export const WithDiff: Story = {
args: { args: {
editableRegions: 'none', editableRegions: 'none',
@@ -150,14 +150,14 @@ export const PageBreak: Story = {
args: { editableRegions: 'none', brief: longBrief, diagnostics: [] }, args: { editableRegions: 'none', brief: longBrief, diagnostics: [] },
}; };
// Inline SVG so the story needs no backend/upload round-trip (WP-26 logo upload). // Inline SVG so the story needs no backend/upload round-trip (the logo upload).
const sampleLogo = const sampleLogo =
'data:image/svg+xml;utf8,' + 'data:image/svg+xml;utf8,' +
encodeURIComponent( 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>', '<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. */ /** Published org logo: the letterhead shows it above the org name. */
export const MetLogo: Story = { export const MetLogo: Story = {
args: { editableRegions: 'none', diagnostics: [], logoUrl: sampleLogo }, args: { editableRegions: 'none', diagnostics: [], logoUrl: sampleLogo },
}; };
@@ -0,0 +1,104 @@
import { Component, computed, input } from '@angular/core';
import { PlaceholderChipComponent } from '@shared/ui/atoms/placeholder-chip/placeholder-chip.component';
import { RichTextNode } from '@shared/kernel/rich-text';
import { Diagnostic, PlaceholderDef } from '@brief/domain/placeholders';
// Illustrative values for the "Voorbeeld" toggle — what send resolves server-side.
const SAMPLE_VALUES: Record<string, string> = {
naam_zorgverlener: 'J. Jansen',
big_nummer: '12345678901',
};
/** `brief().placeholders` keyed by `key` for O(1) lookup from a node. */
export function placeholderDefs(
placeholders: readonly PlaceholderDef[],
): Map<string, PlaceholderDef> {
return new Map(placeholders.map((p) => [p.key, p]));
}
/** The worst (error over warning) diagnostic severity per placeholder key. */
export function worstSeverities(
diagnostics: readonly Diagnostic[],
): Map<string, 'error' | 'warning'> {
const m = new Map<string, 'error' | 'warning'>();
for (const d of 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;
}
export function resolveLabel(defs: ReadonlyMap<string, PlaceholderDef>, key: string): string {
return defs.get(key)?.label ?? key;
}
export function resolveAuto(defs: ReadonlyMap<string, PlaceholderDef>, key: string): boolean {
return defs.get(key)?.autoResolvable ?? false;
}
export function resolveState(
worst: ReadonlyMap<string, 'error' | 'warning'>,
key: string,
): 'ok' | 'warning' | 'error' {
return worst.get(key) ?? 'ok';
}
/** The "Voorbeeld" toggle's stand-in for an auto-resolvable placeholder: a canned
sample, the caller's sample date for `datum`, or the field's own label. */
export function resolveSample(
defs: ReadonlyMap<string, PlaceholderDef>,
sampleDate: string,
key: string,
): string {
return SAMPLE_VALUES[key] ?? (key === 'datum' ? sampleDate : resolveLabel(defs, key));
}
/** Organism: one rendered line of letter content — text runs, line breaks and
placeholder chips (an auto-resolvable one swaps to a sample value when
`showSample` is on). Extracted from `letter-canvas` (RD-26): the `#line`
template plus the label/auto/state/sample helpers it needs, so the canvas's
three `ngTemplateOutlet` incantations become one tag each. */
@Component({
selector: 'app-letter-line',
imports: [PlaceholderChipComponent],
template: `
@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)"
/>
}
}
}
}
`,
})
export class LetterLineComponent {
nodes = input.required<readonly RichTextNode[]>();
showSample = input(false);
placeholders = input<readonly PlaceholderDef[]>([]);
diagnostics = input<readonly Diagnostic[]>([]);
/** The canvas's `formatDatumNl(new Date())`, passed down so every line agrees. */
sampleDate = input('');
private defs = computed(() => placeholderDefs(this.placeholders()));
private worst = computed(() => worstSeverities(this.diagnostics()));
protected labelFor = (key: string) => resolveLabel(this.defs(), key);
protected autoFor = (key: string) => resolveAuto(this.defs(), key);
protected stateFor = (key: string) => resolveState(this.worst(), key);
protected sampleFor = (key: string) => resolveSample(this.defs(), this.sampleDate(), key);
}
@@ -0,0 +1,107 @@
import { describe, expect, it } from 'vitest';
import { PlaceholderDef, Diagnostic } from '@brief/domain/placeholders';
import {
placeholderDefs,
resolveAuto,
resolveLabel,
resolveSample,
resolveState,
worstSeverities,
} from './letter-line.component';
const DEFS: readonly PlaceholderDef[] = [
{ key: 'naam_zorgverlener', label: 'Naam zorgverlener', autoResolvable: true },
{ key: 'reden_besluit', label: 'Reden besluit', autoResolvable: false },
];
const LOCATION = { blockId: 'b1', paragraphIndex: 0, nodeIndex: 0 };
function diagnostic(placeholderKey: string, severity: 'error' | 'warning'): Diagnostic {
return { severity, code: 'unresolved-at-send', message: 'x', placeholderKey, location: LOCATION };
}
describe('placeholderDefs', () => {
it('keys the placeholder list by its key', () => {
const defs = placeholderDefs(DEFS);
expect(defs.get('naam_zorgverlener')?.label).toBe('Naam zorgverlener');
expect(defs.get('unknown')).toBeUndefined();
});
});
describe('worstSeverities', () => {
it('ignores a diagnostic with no placeholder key', () => {
const worst = worstSeverities([
{ severity: 'error', code: 'malformed', message: 'x', location: LOCATION },
]);
expect(worst.size).toBe(0);
});
it('keeps error over a warning already recorded for the same key', () => {
const worst = worstSeverities([
diagnostic('naam_zorgverlener', 'warning'),
diagnostic('naam_zorgverlener', 'error'),
]);
expect(worst.get('naam_zorgverlener')).toBe('error');
});
it('does not let a later warning downgrade an error', () => {
const worst = worstSeverities([
diagnostic('naam_zorgverlener', 'error'),
diagnostic('naam_zorgverlener', 'warning'),
]);
expect(worst.get('naam_zorgverlener')).toBe('error');
});
});
describe('resolveLabel', () => {
it('returns the field label for a known key', () => {
expect(resolveLabel(placeholderDefs(DEFS), 'reden_besluit')).toBe('Reden besluit');
});
it('falls back to the bare key when the field is unknown', () => {
expect(resolveLabel(placeholderDefs(DEFS), 'unknown')).toBe('unknown');
});
});
describe('resolveAuto', () => {
it('reads autoResolvable off the field', () => {
const defs = placeholderDefs(DEFS);
expect(resolveAuto(defs, 'naam_zorgverlener')).toBe(true);
expect(resolveAuto(defs, 'reden_besluit')).toBe(false);
});
it('defaults to false for an unknown key', () => {
expect(resolveAuto(placeholderDefs(DEFS), 'unknown')).toBe(false);
});
});
describe('resolveState', () => {
it('defaults to ok when the key has no diagnostic', () => {
expect(resolveState(worstSeverities([]), 'naam_zorgverlener')).toBe('ok');
});
it('surfaces the worst recorded severity', () => {
const worst = worstSeverities([diagnostic('naam_zorgverlener', 'warning')]);
expect(resolveState(worst, 'naam_zorgverlener')).toBe('warning');
});
});
describe('resolveSample', () => {
it('prefers the canned sample value over the label', () => {
expect(resolveSample(placeholderDefs(DEFS), '4 september 2026', 'naam_zorgverlener')).toBe(
'J. Jansen',
);
});
it('resolves datum to the caller-supplied sample date', () => {
expect(resolveSample(placeholderDefs(DEFS), '4 september 2026', 'datum')).toBe(
'4 september 2026',
);
});
it('falls back to the field label for anything else', () => {
expect(resolveSample(placeholderDefs(DEFS), '4 september 2026', 'reden_besluit')).toBe(
'Reden besluit',
);
});
});
@@ -1,8 +1,8 @@
import { Component, computed, input, output, signal } from '@angular/core'; import { Component, computed, input, output, signal } from '@angular/core';
import { HeadingComponent } from '@shared/ui/heading/heading.component'; import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { StatusBadgeComponent } from '@shared/ui/status-badge/status-badge.component'; import { StatusBadgeComponent } from '@shared/ui/atoms/status-badge/status-badge.component';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { Brief } from '@brief/domain/brief'; import { Brief } from '@brief/domain/brief';
import { OrgTemplate } from '@brief/domain/org-template'; import { OrgTemplate } from '@brief/domain/org-template';
import { Diagnostic } from '@brief/domain/placeholders'; import { Diagnostic } from '@brief/domain/placeholders';
@@ -137,7 +137,7 @@ export class LetterComposerComponent {
canReject = input(false); canReject = input(false);
canSend = input(false); canSend = input(false);
busy = input(false); busy = input(false);
/** Rejection diff (WP-27): the changed/added/removed blocks and their count. The /** Rejection diff: the changed/added/removed blocks and their count. The
"Toon wijzigingen" toggle only appears when there's something to show. */ "Toon wijzigingen" toggle only appears when there's something to show. */
blockDiffs = input<ReadonlyMap<string, BlockDiffKind>>(new Map()); blockDiffs = input<ReadonlyMap<string, BlockDiffKind>>(new Map());
removedCount = input(0); removedCount = input(0);
@@ -181,7 +181,7 @@ export const Sent: Story = {
}), }),
}; };
/** Approver's "Toon wijzigingen" (WP-27): a resubmitted letter with blocks changed, /** Approver's "Toon wijzigingen": a resubmitted letter with blocks changed,
added and removed since the last rejection. */ added and removed since the last rejection. */
export const RejectionDiff: Story = { export const RejectionDiff: Story = {
render: () => render: () =>
@@ -1,5 +1,5 @@
import { Component, computed, input, output } from '@angular/core'; import { Component, computed, input, output } from '@angular/core';
import { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component'; import { PlaceholderOption } from '@shared/ui/molecules/rich-text-editor/rich-text-editor.component';
import { Brief } from '@brief/domain/brief'; import { Brief } from '@brief/domain/brief';
import { BriefMsg } from '@brief/domain/brief.machine'; import { BriefMsg } from '@brief/domain/brief.machine';
import { LetterSectionComponent } from '@brief/ui/letter-section/letter-section.component'; import { LetterSectionComponent } from '@brief/ui/letter-section/letter-section.component';
@@ -1,8 +1,8 @@
import { Component, input, output } from '@angular/core'; import { Component, input, output } from '@angular/core';
import { RichTextBlock } from '@shared/kernel/rich-text'; import { RichTextBlock } from '@shared/kernel/rich-text';
import { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component'; import { PlaceholderOption } from '@shared/ui/molecules/rich-text-editor/rich-text-editor.component';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { HeadingComponent } from '@shared/ui/heading/heading.component'; import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { LetterSection } from '@brief/domain/brief'; import { LetterSection } from '@brief/domain/brief';
import { BriefMsg } from '@brief/domain/brief.machine'; import { BriefMsg } from '@brief/domain/brief.machine';
import { LetterBlockComponent } from '@brief/ui/letter-block/letter-block.component'; import { LetterBlockComponent } from '@brief/ui/letter-block/letter-block.component';
@@ -0,0 +1,77 @@
import { Component, computed, input, output } from '@angular/core';
import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { FileInputComponent } from '@shared/ui/atoms/upload/file-input/file-input.component';
import { SingleUploadComponent } from '@shared/ui/molecules/upload/single-upload/single-upload.component';
import { UploadState } from '@shared/domain/upload.machine';
const LOGO_CATEGORY = 'org-logo';
/**
* Organism: the org-template editor's logo-upload block, split out of
* `org-template-editor.component.ts` (RD-25) — one of its two self-contained
* mutation clusters. Presentational: every mutation is an output the parent
* re-emits unchanged.
*/
@Component({
selector: 'app-logo-upload',
imports: [HeadingComponent, AlertComponent, FileInputComponent, SingleUploadComponent],
styles: [
`
:host {
display: block;
}
.section {
margin-block-start: var(--rhc-space-max-xl);
}
`,
],
template: `
<section class="section">
<app-heading [level]="3" i18n="@@orgTemplate.logo">Logo</app-heading>
@if (logoCategory()) {
<app-file-input
inputId="org-logo-input"
[accept]="logoCategory()!.acceptedTypes"
[maxSizeMb]="logoCategory()!.maxSizeMb"
i18n-label="@@orgTemplate.logo"
label="Logo"
(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>
`,
})
export class LogoUploadComponent {
logoUrl = input<string | null>(null);
uploadState = input.required<UploadState>();
previewUrlFor = input<(documentId: string) => string | undefined>();
logoSelected = output<File[]>();
logoRemoved = output<string>();
logoRetry = output<string>();
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]);
}
@@ -1,12 +1,9 @@
import { Component, computed, input, output } from '@angular/core'; import { Component, computed, input, output } from '@angular/core';
import { DatePipe } from '@angular/common'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { HeadingComponent } from '@shared/ui/heading/heading.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.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/domain/upload.machine'; import { UploadState } from '@shared/domain/upload.machine';
import { Brief } from '@brief/domain/brief'; import { Brief } from '@brief/domain/brief';
import { SAMPLE_LETTER_BRIEF } from '@brief/domain/sample-letter';
import { import {
MARGIN_MAX_MM, MARGIN_MAX_MM,
MARGIN_MIN_MM, MARGIN_MIN_MM,
@@ -17,74 +14,29 @@ import {
} from '@brief/domain/org-template'; } from '@brief/domain/org-template';
import { OrgTemplateTextField } from '@brief/domain/org-template.machine'; import { OrgTemplateTextField } from '@brief/domain/org-template.machine';
import { LetterCanvasComponent } from '@brief/ui/letter-canvas/letter-canvas.component'; import { LetterCanvasComponent } from '@brief/ui/letter-canvas/letter-canvas.component';
import { LogoUploadComponent } from './logo-upload.component';
import { VersionHistoryComponent } from './version-history.component';
const LOGO_CATEGORY = 'org-logo';
const EDGES: readonly (keyof Margins)[] = ['topMm', 'rightMm', 'bottomMm', 'leftMm']; 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 * Organism: the admin org-template editor. The mirror of the drafter's
* composer — the letter canvas runs in `editableRegions='template'` so the * composer — the letter canvas runs in `editableRegions='template'` so the
* letterhead/signature/footer are edited in place, while the content is a read-only * 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. * sample. Margins and the publish bar sit around it; the logo uploader and version
* Presentational: every mutation is an output the store turns into a command. * history are their own children (`app-logo-upload`, `app-version-history`, RD-25) —
* each a self-contained mutation cluster. Presentational: every mutation is an
* output the store turns into a command, whether sourced here or re-emitted from
* a child.
*/ */
@Component({ @Component({
selector: 'app-org-template-editor', selector: 'app-org-template-editor',
imports: [ imports: [
DatePipe,
HeadingComponent,
ButtonComponent, ButtonComponent,
AlertComponent, AlertComponent,
FileInputComponent,
SingleUploadComponent,
LetterCanvasComponent, LetterCanvasComponent,
LogoUploadComponent,
VersionHistoryComponent,
], ],
styles: [ styles: [
` `
@@ -122,22 +74,6 @@ export const SAMPLE_LETTER_BRIEF: Brief = {
.margins input { .margins input {
width: 6rem; 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 { .bar {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -153,7 +89,7 @@ export const SAMPLE_LETTER_BRIEF: Brief = {
template: ` template: `
<div class="toolbar"> <div class="toolbar">
<label class="field"> <label class="field">
<span>{{ subOrgLabel() }}</span> <span i18n="@@orgTemplate.subOrg">Organisatieonderdeel</span>
<select class="form-select" (change)="onSelectSubOrg($event)"> <select class="form-select" (change)="onSelectSubOrg($event)">
@for (o of subOrgs(); track o.subOrgId) { @for (o of subOrgs(); track o.subOrgId) {
<option [value]="o.subOrgId" [selected]="o.subOrgId === selectedSubOrgId()"> <option [value]="o.subOrgId" [selected]="o.subOrgId === selectedSubOrgId()">
@@ -190,80 +126,62 @@ export const SAMPLE_LETTER_BRIEF: Brief = {
} }
</fieldset> </fieldset>
<section class="section"> <app-logo-upload
<app-heading [level]="3">{{ logoHeading() }}</app-heading> [logoUrl]="logoUrl()"
@if (logoCategory()) { [uploadState]="uploadState()"
<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()" [previewUrlFor]="previewUrlFor()"
(remove)="logoRemoved.emit(u.localId)" (logoSelected)="logoSelected.emit($event)"
(retry)="logoRetry.emit(u.localId)" (logoRemoved)="logoRemoved.emit($event)"
></li> (logoRetry)="logoRetry.emit($event)"
} />
</ul>
}
</section>
<section class="section"> <app-version-history
<app-heading [level]="3">{{ historyHeading() }}</app-heading> [history]="history()"
@if (history().length === 0) { [publishedVersion]="publishedVersion()"
<p class="published">{{ noHistory() }}</p> [busy]="busy()"
} @else { (rollback)="rollback.emit($event)"
<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"> <div class="bar">
<span class="published">{{ publishedLabel() }} {{ publishedVersion() }}</span> <span class="published"
><span i18n="@@orgTemplate.published">Gepubliceerde versie:</span>
{{ publishedVersion() }}</span
>
@if (pendingPublish()) { @if (pendingPublish()) {
<app-alert type="warning">{{ impactText() }}</app-alert> <app-alert type="warning">{{ impactText() }}</app-alert>
<app-button variant="primary" [disabled]="busy()" (click)="confirmPublish.emit()"> <app-button
{{ confirmLabel() }} variant="primary"
</app-button> [disabled]="busy()"
<app-button variant="subtle" [disabled]="busy()" (click)="cancelPublish.emit()"> (click)="confirmPublish.emit()"
{{ cancelLabel() }} i18n="@@orgTemplate.publish.confirm"
</app-button> >Bevestigen</app-button
>
<app-button
variant="subtle"
[disabled]="busy()"
(click)="cancelPublish.emit()"
i18n="@@orgTemplate.publish.cancel"
>Annuleren</app-button
>
} @else { } @else {
<app-button <app-button
variant="primary" variant="primary"
[disabled]="!draftValid() || busy()" [disabled]="!draftValid() || busy()"
(click)="requestPublish.emit()" (click)="requestPublish.emit()"
i18n="@@orgTemplate.publish"
>Publiceren</app-button
> >
{{ publishLabel() }}
</app-button>
@if (!draftValid()) { @if (!draftValid()) {
<span class="published">{{ invalidHint() }}</span> <span class="published">{{ invalidHint() }}</span>
} }
} }
<app-button variant="secondary" [disabled]="busy()" (click)="proefbrief.emit()"> <app-button
{{ proefbriefLabel() }} variant="secondary"
</app-button> [disabled]="busy()"
(click)="proefbrief.emit()"
i18n="@@orgTemplate.proefbrief"
>Proefbrief</app-button
>
</div> </div>
`, `,
}) })
@@ -299,14 +217,6 @@ export class OrgTemplateEditorComponent {
protected readonly MIN = MARGIN_MIN_MM; protected readonly MIN = MARGIN_MIN_MM;
protected readonly MAX = MARGIN_MAX_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) { protected onSelectSubOrg(event: Event) {
this.selectSubOrg.emit((event.target as HTMLSelectElement).value); this.selectSubOrg.emit((event.target as HTMLSelectElement).value);
} }
@@ -333,20 +243,9 @@ export class OrgTemplateEditorComponent {
$localize`:@@orgTemplate.publish.impact:Dit raakt ${this.unsentBriefs()}:count: nog niet verzonden brieven. Publiceren?`, $localize`:@@orgTemplate.publish.impact:Dit raakt ${this.unsentBriefs()}:count: nog niet verzonden brieven. Publiceren?`,
); );
protected subOrgLabel = input($localize`:@@orgTemplate.subOrg:Organisatieonderdeel`);
protected marginsLegend = input( protected marginsLegend = input(
$localize`:@@orgTemplate.margins:Marges (mm, tussen ${MARGIN_MIN_MM}:min: en ${MARGIN_MAX_MM}:max:)`, $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( protected invalidHint = input(
$localize`:@@orgTemplate.invalid:Vul organisatienaam en ondertekenaar in; marges tussen ${MARGIN_MIN_MM}:min: en ${MARGIN_MAX_MM}:max: mm.`, $localize`:@@orgTemplate.invalid:Vul organisatienaam en ondertekenaar in; marges tussen ${MARGIN_MIN_MM}:min: en ${MARGIN_MAX_MM}:max: mm.`,
); );
@@ -87,12 +87,12 @@ const sampleLogo =
'<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>', '<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. */ /** Published logo: the letterhead canvas shows it above the org name. */
export const MetLogo: Story = { export const MetLogo: Story = {
args: { logoUrl: sampleLogo }, args: { logoUrl: sampleLogo },
}; };
/** Client-side upload rejection (existing `rejectReason`, WP-26 AC5) — type/size caught /** Client-side upload rejection (existing `rejectReason`) — type/size caught
before the file ever reaches the backend. */ before the file ever reaches the backend. */
export const LogoUploadFout: Story = { export const LogoUploadFout: Story = {
args: { args: {
@@ -0,0 +1,78 @@
import { Component, input, output } from '@angular/core';
import { DatePipe } from '@angular/common';
import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { OrgTemplateVersion } from '@brief/domain/org-template';
/**
* Organism: the org-template editor's version-history block, split out of
* `org-template-editor.component.ts` (RD-25) — one of its two self-contained
* mutation clusters. Presentational: `rollback` is the parent's own output,
* re-emitted unchanged.
*/
@Component({
selector: 'app-version-history',
imports: [DatePipe, HeadingComponent, ButtonComponent],
styles: [
`
:host {
display: block;
}
.section {
margin-block-start: var(--rhc-space-max-xl);
}
.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);
}
.published {
color: var(--rhc-color-foreground-subtle);
}
`,
],
template: `
<section class="section">
<app-heading [level]="3" i18n="@@orgTemplate.history">Versiegeschiedenis</app-heading>
@if (history().length === 0) {
<p class="published" i18n="@@orgTemplate.history.none">Nog niets gepubliceerd.</p>
} @else {
<ul class="history-list">
@for (v of history(); track v.version) {
<li class="history-row">
<span
><span i18n="@@orgTemplate.version">Versie</span> {{ v.version }} ·
{{ v.publishedAt | date: 'longDate' }}</span
>
<app-button
variant="subtle"
[disabled]="busy()"
(click)="rollback.emit(v.version)"
i18n="@@orgTemplate.rollback"
>Terugzetten in concept</app-button
>
</li>
}
</ul>
}
</section>
`,
})
export class VersionHistoryComponent {
history = input<readonly OrgTemplateVersion[]>([]);
publishedVersion = input(0);
busy = input(false);
rollback = output<number>();
}
@@ -1,13 +1,13 @@
import { Component, computed, effect, inject } from '@angular/core'; import { Component, computed, effect, inject } from '@angular/core';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { ASYNC } from '@shared/ui/async/async.component'; import { ASYNC } from '@shared/ui/molecules/async/async.component';
import { AccessStore } from '@shared/application/access.store'; import { AccessStore } from '@shared/application/access.store';
import { OrgTemplateStore } from '@brief/application/org-template.store'; import { OrgTemplateStore } from '@brief/application/org-template.store';
import { OrgTemplateEditorComponent } from '@brief/ui/org-template-editor/org-template-editor.component'; 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 /** Page: thin container for the admin org-template editor. Deny-by-default
capability gate (`orgtemplate:edit`) — a denial alert for non-admins, the editor 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. */ for admins. Loads once the capability resolves; wires store commands to the organism. */
@Component({ @Component({
@@ -1,8 +1,8 @@
import { Component, computed, input, output, signal } from '@angular/core'; import { Component, computed, input, output, signal } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { CheckboxComponent } from '@shared/ui/checkbox/checkbox.component'; import { CheckboxComponent } from '@shared/ui/atoms/checkbox/checkbox.component';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component'; import { TextInputComponent } from '@shared/ui/atoms/text-input/text-input.component';
import { textOf } from '@shared/kernel/rich-text'; import { textOf } from '@shared/kernel/rich-text';
import { LibraryPassage } from '@brief/domain/brief'; import { LibraryPassage } from '@brief/domain/brief';
@@ -10,8 +10,8 @@ import { LibraryPassage } from '@brief/domain/brief';
inserts ALL checked passages at once (a single message upstream) — there is no inserts ALL checked passages at once (a single message upstream) — there is no
single-insert path. Presentational: emits the chosen passages in list order. single-insert path. Presentational: emits the chosen passages in list order.
Superseded by `besluit-panel` (WP-27's guided drafting): no consumer left in Superseded by `besluit-panel`'s guided drafting: no consumer left in
`src/app` outside its own story (WP-28 audit). Kept for now rather than deleted `src/app` outside its own story. Kept for now rather than deleted
in-flight of an unrelated WP; a future cleanup can remove it. */ in-flight of an unrelated WP; a future cleanup can remove it. */
@Component({ @Component({
selector: 'app-passage-picker', selector: 'app-passage-picker',
@@ -87,7 +87,7 @@ export class PassagePickerComponent {
protected checked = signal<Record<string, boolean>>({}); protected checked = signal<Record<string, boolean>>({});
protected query = signal(''); protected query = signal('');
/** Client-side filter on label + rendered content text — the library is small, so no /** 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`). */ server search. Placeholder keys are searchable too (see `textOf`). */
protected filtered = computed(() => { protected filtered = computed(() => {
const q = this.query().trim().toLowerCase(); const q = this.query().trim().toLowerCase();
if (!q) return this.passages(); if (!q) return this.passages();
@@ -1,7 +1,7 @@
import { Component, input, output, signal } from '@angular/core'; import { Component, input, output, signal } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
/** Molecule: shows the rejection comments (drafter view) or collects them from the /** Molecule: shows the rejection comments (drafter view) or collects them from the
approver. The approver rejects WITH comments; they never edit the letter. */ approver. The approver rejects WITH comments; they never edit the letter. */
@@ -8,6 +8,7 @@ import {
back, back,
gaNaarStap, gaNaarStap,
submit, submit,
primary,
resolve, resolve,
reduce, reduce,
WizardState, WizardState,
@@ -104,6 +105,25 @@ describe('wizard.machine', () => {
}); });
}); });
describe('primary', () => {
it('Primary advances to the next step from a non-final step', () => {
const s = toStep2('4160', '200'); // Editing, step 2 — not the final step
expect(reduce(s, { tag: 'Primary' })).toEqual(reduce(s, { tag: 'Next' }));
expect(expectTag(primary(s), 'Editing').step).toBe(3);
});
it('Primary submits from the final step', () => {
const s = toStep3('4160', '200'); // Editing, step 3 — the final step
expect(reduce(s, { tag: 'Primary' })).toEqual(reduce(s, { tag: 'Submit' }));
expect(primary(s).tag).toBe('Submitting');
});
it('Primary is a no-op from a non-editing state', () => {
const submitting = submit(toStep3('4160', '200'));
expect(primary(submitting)).toBe(submitting);
});
});
describe('reduce (message-driven)', () => { describe('reduce (message-driven)', () => {
it('drives the full happy path via messages', () => { it('drives the full happy path via messages', () => {
let s: WizardState = initial; let s: WizardState = initial;
@@ -7,6 +7,7 @@ import {
reduceUpload, reduceUpload,
requiredCategoriesSatisfied, requiredCategoriesSatisfied,
deliveryRefs, deliveryRefs,
digitalDocumentIds,
} from '@shared/domain/upload.machine'; } from '@shared/domain/upload.machine';
/** What the user is typing (raw, possibly invalid). */ /** What the user is typing (raw, possibly invalid). */
@@ -53,7 +54,7 @@ export function hasProgress(s: Extract<WizardState, { tag: 'Editing' }>): boolea
!!s.draft.uren || !!s.draft.uren ||
!!s.draft.jaren || !!s.draft.jaren ||
!!s.draft.punten || !!s.draft.punten ||
deliveryRefs(s.upload).some((r) => r.channel === 'digital' && !!r.documentId) digitalDocumentIds(s.upload).length > 0
); );
} }
@@ -121,6 +122,13 @@ export function submit(s: WizardState): WizardState {
return result.ok ? { tag: 'Submitting', data: result.value } : { ...s, errors: result.error }; return result.ok ? { tag: 'Submitting', data: result.value } : { ...s, errors: result.error };
} }
/** The primary button's action: advance, or submit from the last step. No-op
outside Editing — this is the one decision the old component-side handler used to make. */
export function primary(s: WizardState): WizardState {
if (s.tag !== 'Editing') return s;
return s.step === 3 ? submit(s) : next(s);
}
/** Route an upload sub-message through the pure upload reducer (Editing only). */ /** Route an upload sub-message through the pure upload reducer (Editing only). */
export function upload(s: WizardState, msg: UploadMsg): WizardState { export function upload(s: WizardState, msg: UploadMsg): WizardState {
if (s.tag !== 'Editing') return s; if (s.tag !== 'Editing') return s;
@@ -152,6 +160,7 @@ export type WizardMsg =
| { tag: 'Back' } | { tag: 'Back' }
| { tag: 'GaNaarStap'; step: 1 | 2 | 3 } | { tag: 'GaNaarStap'; step: 1 | 2 | 3 }
| { tag: 'Submit' } | { tag: 'Submit' }
| { tag: 'Primary' }
| { tag: 'Retry' } | { tag: 'Retry' }
| { tag: 'SubmitConfirmed' } | { tag: 'SubmitConfirmed' }
| { tag: 'SubmitFailed'; error: string } | { tag: 'SubmitFailed'; error: string }
@@ -170,6 +179,8 @@ export function reduce(s: WizardState, m: WizardMsg): WizardState {
return gaNaarStap(s, m.step); return gaNaarStap(s, m.step);
case 'Submit': case 'Submit':
return submit(s); return submit(s);
case 'Primary':
return primary(s);
case 'Retry': case 'Retry':
return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s; return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s;
case 'SubmitConfirmed': case 'SubmitConfirmed':
@@ -147,7 +147,7 @@ describe('intake acceptance journeys', () => {
}); });
}); });
it('raising uren above the threshold after answering scholing drops both fields (WP-69 §6)', () => { it('raising uren above the threshold after answering scholing drops both fields', () => {
// Given a journey that answered the scholing question while uren was low. // Given a journey that answered the scholing question while uren was low.
const atReview = givenIntake( const atReview = givenIntake(
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }, { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
@@ -170,7 +170,7 @@ describe('intake acceptance journeys', () => {
); );
// Then the submission succeeds, and BOTH the stale answer and its punten are gone — // Then the submission succeeds, and BOTH the stale answer and its punten are gone —
// exactly the crafted-POST-shaped payload WP-69's server rule rejects. // exactly the crafted-POST-shaped payload the server rule rejects.
expect(done.tag).toBe('Submitted'); expect(done.tag).toBe('Submitted');
expect(done.tag === 'Submitted' && done.data.aanvullendeScholing).toBeUndefined(); expect(done.tag === 'Submitted' && done.data.aanvullendeScholing).toBeUndefined();
expect(done.tag === 'Submitted' && done.data.punten).toBeUndefined(); expect(done.tag === 'Submitted' && done.data.punten).toBeUndefined();
@@ -10,6 +10,7 @@ import {
back, back,
gaNaarStap, gaNaarStap,
submit, submit,
primary,
resolve, resolve,
reduce, reduce,
IntakeState, IntakeState,
@@ -170,7 +171,7 @@ describe('submit', () => {
expect(withScholing.data.punten).toBe(200); expect(withScholing.data.punten).toBe(200);
}); });
it('does not require punten for a hidden question (WP-69 §6)', () => { it('does not require punten for a hidden question', () => {
// scholingGevolgd is a stale 'ja' from when uren was low, but uren is now above // scholingGevolgd is a stale 'ja' from when uren was low, but uren is now above
// threshold — the template hides the question, so punten must not be required either. // threshold — the template hides the question, so punten must not be required either.
const staleScholingNoPunten = givenIntake( const staleScholingNoPunten = givenIntake(
@@ -182,7 +183,7 @@ describe('submit', () => {
expect(good.data.aanvullendeScholing).toBeUndefined(); expect(good.data.aanvullendeScholing).toBeUndefined();
}); });
it('drops punten when raising uren hides the question (WP-69 §6)', () => { it('drops punten when raising uren hides the question', () => {
// Same stale answer, but this time punten was also filled in while uren was low. // Same stale answer, but this time punten was also filled in while uren was low.
const staleScholingWithPunten = givenIntake( const staleScholingWithPunten = givenIntake(
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }, { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
@@ -207,6 +208,33 @@ describe('submit', () => {
}); });
}); });
describe('primary', () => {
// Same fixture as the 'submit' describe block above: buitenland answered 'nee',
// uren high enough to skip the scholing question.
const highUren = givenIntake(
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
{ tag: 'SetAnswer', key: 'uren', value: '4160' },
);
it('Primary advances to the next step from a non-final step', () => {
expect(currentStep(expectTag(highUren, 'Answering'))).toBe('buitenland'); // not the final step
expect(reduce(highUren, { tag: 'Primary' })).toEqual(reduce(highUren, { tag: 'Next' }));
expect(expectTag(primary(highUren), 'Answering').cursor).toBe(1);
});
it('Primary submits from the final step', () => {
const atReview = reduce(reduce(highUren, { tag: 'Next' }), { tag: 'Next' });
expect(currentStep(expectTag(atReview, 'Answering'))).toBe('review'); // the final step
expect(reduce(atReview, { tag: 'Primary' })).toEqual(reduce(atReview, { tag: 'Submit' }));
expect(primary(atReview).tag).toBe('Submitting');
});
it('Primary is a no-op from a non-editing state', () => {
const submitting = submit(reduce(reduce(highUren, { tag: 'Next' }), { tag: 'Next' }));
expect(primary(submitting)).toBe(submitting);
});
});
describe('reduce (message-driven happy path)', () => { describe('reduce (message-driven happy path)', () => {
it('drives abroad branch end to end', () => { it('drives abroad branch end to end', () => {
let s: IntakeState = initial; let s: IntakeState = initial;
@@ -60,7 +60,7 @@ export const STEPS: StepId[] = ['buitenland', 'werk', 'review'];
// #endregion showcase:steps // #endregion showcase:steps
/** Per-field error map: one message per question, since a step holds several. */ /** Per-field error map: one message per question, since a step holds several. */
type Errors = Partial<Record<keyof Answers, string>>; export type Errors = Partial<Record<keyof Answers, string>>;
export type IntakeState = export type IntakeState =
| { | {
@@ -116,7 +116,7 @@ function validateStep(step: StepId, a: Answers, scholingThreshold: number): Resu
// visible (lageUren) AND scholing was followed — matching the template's // visible (lageUren) AND scholing was followed — matching the template's
// `@if (scholingZichtbaar())`. Without the `lageUren` guard, answering 'ja' and then // `@if (scholingZichtbaar())`. Without the `lageUren` guard, answering 'ja' and then
// raising uren above the threshold left an error on a field the template no longer // raising uren above the threshold left an error on a field the template no longer
// renders (WP-69 §6). // renders.
if (lageUren(a, scholingThreshold) && a.scholingGevolgd === 'ja') { if (lageUren(a, scholingThreshold) && a.scholingGevolgd === 'ja') {
const p = parseUren(a.punten ?? ''); const p = parseUren(a.punten ?? '');
if (!p.ok) errors.punten = p.error; if (!p.ok) errors.punten = p.error;
@@ -149,8 +149,8 @@ function validateAll(a: Answers, scholingThreshold: number): Result<Errors, Vali
const aanvullendeScholing = lageUren(a, scholingThreshold) const aanvullendeScholing = lageUren(a, scholingThreshold)
? a.scholingGevolgd === 'ja' ? a.scholingGevolgd === 'ja'
: undefined; : undefined;
// Punten are derived from aanvullendeScholing, NOT the raw scholingGevolgd answer (WP-69 // Punten are derived from aanvullendeScholing, NOT the raw scholingGevolgd answer —
// §6) — a stale 'ja' left over from when uren was low, after uren was raised above the // a stale 'ja' left over from when uren was low, after uren was raised above the
// threshold, must not leak a punten value into the parsed, submitted ValidIntake. // threshold, must not leak a punten value into the parsed, submitted ValidIntake.
const punten = aanvullendeScholing === true ? parseUren(a.punten ?? '') : undefined; const punten = aanvullendeScholing === true ? parseUren(a.punten ?? '') : undefined;
return ok({ return ok({
@@ -200,6 +200,13 @@ export function submit(s: IntakeState): IntakeState {
return r.ok ? { tag: 'Submitting', data: r.value } : { ...s, errors: r.error }; return r.ok ? { tag: 'Submitting', data: r.value } : { ...s, errors: r.error };
} }
/** The primary button's action: advance, or submit from the review step. No-op
outside Answering — this is the one decision the old component-side handler used to make. */
export function primary(s: IntakeState): IntakeState {
if (s.tag !== 'Answering') return s;
return currentStep(s) === 'review' ? submit(s) : next(s);
}
export function resolve(s: IntakeState, r: Result<string, void>): IntakeState { export function resolve(s: IntakeState, r: Result<string, void>): IntakeState {
if (s.tag !== 'Submitting') return s; if (s.tag !== 'Submitting') return s;
return r.ok return r.ok
@@ -213,6 +220,7 @@ export type IntakeMsg =
| { tag: 'Back' } | { tag: 'Back' }
| { tag: 'GaNaarStap'; cursor: number } | { tag: 'GaNaarStap'; cursor: number }
| { tag: 'Submit' } | { tag: 'Submit' }
| { tag: 'Primary' }
| { tag: 'Retry' } | { tag: 'Retry' }
| { tag: 'SubmitConfirmed' } | { tag: 'SubmitConfirmed' }
| { tag: 'SubmitFailed'; error: string } | { tag: 'SubmitFailed'; error: string }
@@ -231,6 +239,8 @@ export function reduce(s: IntakeState, m: IntakeMsg): IntakeState {
return gaNaarStap(s, m.cursor); return gaNaarStap(s, m.cursor);
case 'Submit': case 'Submit':
return submit(s); return submit(s);
case 'Primary':
return primary(s);
case 'Retry': case 'Retry':
return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s; return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s;
case 'SubmitConfirmed': case 'SubmitConfirmed':
@@ -1,15 +1,16 @@
import { Component, computed, inject, input } from '@angular/core'; import { Component, computed, inject, input } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component'; import { FormFieldComponent } from '@shared/ui/molecules/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component'; import { TextInputComponent } from '@shared/ui/atoms/text-input/text-input.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { import {
WizardShellComponent, WizardShellComponent,
WizardError, WizardError,
WizardStatus, WizardPhase,
naarStapLabel, naarStapLabel,
} from '@shared/layout/wizard-shell/wizard-shell.component'; } from '@shared/layout/wizard-shell/wizard-shell.component';
import { ConfirmationComponent } from '@shared/ui/confirmation/confirmation.component'; import { toWizardErrors } from '@shared/layout/wizard-shell/wizard-errors';
import { ConfirmationComponent } from '@shared/ui/molecules/confirmation/confirmation.component';
import { createStore } from '@shared/application/store'; import { createStore } from '@shared/application/store';
import { whenTag } from '@shared/kernel/fp'; import { whenTag } from '@shared/kernel/fp';
import { BigProfileStore } from '@registratie/application/big-profile.store'; import { BigProfileStore } from '@registratie/application/big-profile.store';
@@ -22,9 +23,9 @@ import {
hasProgress, hasProgress,
} from '@herregistratie/domain/herregistratie.machine'; } from '@herregistratie/domain/herregistratie.machine';
import { createDraftSync } from '@registratie/application/draft-sync'; import { createDraftSync } from '@registratie/application/draft-sync';
import { DocumentUploadComponent } from '@shared/ui/upload/document-upload/document-upload.component'; import { DocumentUploadComponent } from '@shared/ui/organisms/upload/document-upload/document-upload.component';
import { createUploadController } from '@shared/application/upload-controller'; import { createUploadController } from '@shared/application/upload-controller';
import { UploadState, initialUpload, deliveryRefs } from '@shared/domain/upload.machine'; import { UploadState, initialUpload, digitalDocumentIds } from '@shared/domain/upload.machine';
/** Organism: multi-step herregistratie wizard. ALL state lives in one signal /** Organism: multi-step herregistratie wizard. ALL state lives in one signal
driven by the pure `reduce` function (see herregistratie.machine.ts) via an driven by the pure `reduce` function (see herregistratie.machine.ts) via an
@@ -50,15 +51,14 @@ import { UploadState, initialUpload, deliveryRefs } from '@shared/domain/upload.
[stepTitle]="stepTitle()" [stepTitle]="stepTitle()"
i18n-processName="@@herregWizard.processName" i18n-processName="@@herregWizard.processName"
processName="Herregistratie aanvragen" processName="Herregistratie aanvragen"
[status]="shellStatus()" [phase]="phase()"
[primaryLabel]="primaryLabel()" [primaryLabel]="primaryLabel()"
[canGoBack]="step() > 1" [canGoBack]="step() > 1"
[errors]="errorList()" [errors]="errorList()"
[errorMessage]="errorMessage()" (primary)="dispatch({ tag: 'Primary' })"
(primary)="onPrimary()"
(back)="dispatch({ tag: 'Back' })" (back)="dispatch({ tag: 'Back' })"
(cancel)="restart()" (cancel)="restart()"
(retry)="onRetry()" (retry)="dispatch({ tag: 'Retry' })"
(goToStep)="goToStep($event)" (goToStep)="goToStep($event)"
> >
@switch (step()) { @switch (step()) {
@@ -148,37 +148,31 @@ import { UploadState, initialUpload, deliveryRefs } from '@shared/domain/upload.
}) })
export class HerregistratieWizardComponent { export class HerregistratieWizardComponent {
private profile = inject(BigProfileStore); private profile = inject(BigProfileStore);
private store = createStore<WizardState, WizardMsg>(initial, reduce);
/** Preview/download link for a completed upload; delegates to the upload
controller (application layer), which knows the dev-simulation `demo-*` ids
have no stored bytes and returns no link for them. */
protected previewUrlFor = (documentId: string): string | undefined =>
this.uploadCtl.previewUrlFor(documentId);
/** Optional seed so Storybook / the showcase can mount any state directly. */ /** Optional seed so Storybook / the showcase can mount any state directly. */
seed = input<WizardState>(initial); seed = input<WizardState>(initial);
// --- The store: all state in one signal, changed only by a pure reduce -----
// The effect fires once, on the `Editing -> Submitting` transition. `Seed` is exempt,
// so a story that mounts straight into `Submitting` does not call the network.
// `draftSync` is declared below (both callbacks are deferred, so the cycle is safe).
private store = createStore<WizardState, WizardMsg>(initial, reduce, {
Submitting: async (s, store) => {
this.profile.beginHerregistratie();
const r = await this.draftSync.submit({ uren: s.data.uren, documents: s.data.documents });
if (r.ok) {
store.dispatch({ tag: 'SubmitConfirmed' });
this.profile.confirmHerregistratie();
} else {
store.dispatch({ tag: 'SubmitFailed', error: r.error });
this.profile.rollbackHerregistratie();
}
},
});
readonly state = this.store.model; // public so the showcase can highlight the live state readonly state = this.store.model; // public so the showcase can highlight the live state
protected dispatch = this.store.dispatch; protected dispatch = this.store.dispatch;
// Backend draft-sync (new persistence for this wizard): create a Concept on first // --- Static copy: stepper labels and per-step headings ---------------------
// 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 = [ readonly stepLabels = [
$localize`:@@herregWizard.step.werkervaring:Werkervaring`, $localize`:@@herregWizard.step.werkervaring:Werkervaring`,
$localize`:@@herregWizard.step.nascholing:Nascholing`, $localize`:@@herregWizard.step.nascholing:Nascholing`,
@@ -190,6 +184,7 @@ export class HerregistratieWizardComponent {
$localize`:@@herregWizard.title.documenten:Documenten aanleveren`, $localize`:@@herregWizard.title.documenten:Documenten aanleveren`,
]; ];
// --- State projections: one narrow, then read-only views of it -------------
private editing = computed(() => whenTag(this.state(), 'Editing')); private editing = computed(() => whenTag(this.state(), 'Editing'));
protected step = computed(() => this.editing()?.step ?? 1); protected step = computed(() => this.editing()?.step ?? 1);
protected draft = computed<Draft>( protected draft = computed<Draft>(
@@ -200,12 +195,35 @@ export class HerregistratieWizardComponent {
protected errJaren = computed(() => this.editing()?.errors.jaren ?? ''); protected errJaren = computed(() => this.editing()?.errors.jaren ?? '');
protected errPunten = computed(() => this.editing()?.errors.punten ?? ''); protected errPunten = computed(() => this.editing()?.errors.punten ?? '');
protected errDocumenten = computed(() => this.editing()?.errors.documenten ?? ''); protected errDocumenten = computed(() => this.editing()?.errors.documenten ?? '');
protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? '');
// --- Controllers: persistence and uploads ----------------------------------
// Create a Concept on first progress, then debounced-sync the snapshot.
// `?aanvraag=<id>` resumes it.
private draftSync = createDraftSync({
type: 'herregistratie',
snapshot: () => {
const s = this.state();
if (s.tag !== 'Editing' || !hasProgress(s)) return null;
return {
draft: s,
stepIndex: s.step - 1,
stepCount: this.stepLabels.length,
documentIds: digitalDocumentIds(s.upload),
};
},
onResume: (draft) => this.dispatch({ tag: 'Seed', state: draft as WizardState }),
enabled: () => this.seed() === initial,
});
protected uploadCtl = createUploadController({ protected uploadCtl = createUploadController({
wizardId: 'herregistratie', wizardId: 'herregistratie',
getUpload: () => this.upload(), getUpload: () => this.upload(),
dispatch: (msg) => this.dispatch({ tag: 'Upload', msg }), dispatch: (msg) => this.dispatch({ tag: 'Upload', msg }),
}); });
/** Preview/download link for a completed upload; delegates to the upload
controller (application layer), which knows the dev-simulation `demo-*` ids
have no stored bytes and returns no link for them. */
protected previewUrlFor = (documentId: string): string | undefined =>
this.uploadCtl.previewUrlFor(documentId);
// --- Presentational wiring for the shared wizard shell --------------------- // --- Presentational wiring for the shared wizard shell ---------------------
protected stepTitle = computed(() => this.stepTitles[this.step() - 1]); protected stepTitle = computed(() => this.stepTitles[this.step() - 1]);
@@ -215,33 +233,32 @@ export class HerregistratieWizardComponent {
? naarStapLabel(step + 1, this.stepLabels[step]) ? naarStapLabel(step + 1, this.stepLabels[step])
: $localize`:@@herregWizard.indienen:Herregistratie aanvragen`; : $localize`:@@herregWizard.indienen:Herregistratie aanvragen`;
}); });
/** Maps this machine's own tags onto the shell's `WizardPhase` vocabulary,
composing the localized failure prefix so the `Failed` message arrives intact. */
protected phase = computed<WizardPhase>(() => {
const s = this.state();
switch (s.tag) {
case 'Editing':
return { tag: 'Editing' };
case 'Submitting':
return { tag: 'Submitting' };
case 'Submitted':
return { tag: 'Submitted' };
case 'Failed':
return {
tag: 'Failed',
message: $localize`:@@wizard.indienenMislukt:Indienen mislukt:` + ` ${s.error}`,
};
}
});
/** Current step's field errors, flattened for the shell's error summary. */
protected errorList = computed<WizardError[]>(() => toWizardErrors(this.editing()?.errors ?? {}));
// --- Event handlers: narrow a child event into a message -------------------
/** Stepper emits a 0-based index for an earlier (visited) step. */ /** Stepper emits a 0-based index for an earlier (visited) step. */
protected goToStep(index: number) { protected goToStep(index: number) {
this.dispatch({ tag: 'GaNaarStap', step: (index + 1) as 1 | 2 | 3 }); 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() { constructor() {
// An explicit seed (stories/tests) wins; otherwise resume the backend draft // An explicit seed (stories/tests) wins; otherwise resume the backend draft
@@ -252,37 +269,9 @@ export class HerregistratieWizardComponent {
); );
} }
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. */ /** Reset the wizard to a fresh, empty start. */
restart() { restart() {
this.draftSync.reset(); this.draftSync.reset();
this.dispatch({ tag: 'Seed', state: initial }); 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();
}
}
} }
@@ -1,7 +1,7 @@
import { Component, computed, inject } from '@angular/core'; import { Component, computed, inject } from '@angular/core';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { ASYNC } from '@shared/ui/async/async.component'; import { ASYNC } from '@shared/ui/molecules/async/async.component';
import { map } from '@shared/application/remote-data'; import { map } from '@shared/application/remote-data';
import { BigProfileStore } from '@registratie/application/big-profile.store'; import { BigProfileStore } from '@registratie/application/big-profile.store';
import { HerregistratieWizardComponent } from '@herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component'; import { HerregistratieWizardComponent } from '@herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component';
@@ -0,0 +1,77 @@
import { Component, input, output } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FormFieldComponent } from '@shared/ui/molecules/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/atoms/text-input/text-input.component';
import { RadioGroupComponent, JA_NEE } from '@shared/ui/atoms/radio-group/radio-group.component';
import { Answers, Errors } from '@herregistratie/domain/intake.machine';
/** Step: the intake wizard's first screen (foreign work in the last 5 years).
Pure & presentational — values in via `answers`/`errors`, every keystroke out
via `answerChange`. No store, no services, no internal state; the parent owns
the Model and decides what a change means. */
@Component({
selector: 'app-intake-buitenland-step',
imports: [FormsModule, FormFieldComponent, TextInputComponent, RadioGroupComponent],
template: `
<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)="answerChange.emit({ key: 'buitenlandGewerkt', value: $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)="answerChange.emit({ key: 'land', value: $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)="answerChange.emit({ key: 'buitenlandseUren', value: $event })"
name="buitenlandseUren"
i18n-placeholder="@@intake.q.buitenlandseUrenPlaceholder"
placeholder="bijv. 800"
/>
</app-form-field>
</fieldset>
}
`,
})
export class BuitenlandStep {
answers = input.required<Answers>();
errors = input.required<Errors>();
answerChange = output<{ key: keyof Answers; value: string }>();
readonly jaNee = JA_NEE;
protected err = (k: keyof Answers) => this.errors()[k] ?? '';
}
@@ -1,19 +1,13 @@
import { Component, computed, effect, inject, input, untracked } from '@angular/core'; import { Component, computed, effect, inject, input, untracked } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component'; import { ConfirmationComponent } from '@shared/ui/molecules/confirmation/confirmation.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 { import {
WizardShellComponent, WizardShellComponent,
WizardError, WizardError,
WizardStatus, WizardPhase,
naarStapLabel, naarStapLabel,
} from '@shared/layout/wizard-shell/wizard-shell.component'; } from '@shared/layout/wizard-shell/wizard-shell.component';
import { toWizardErrors } from '@shared/layout/wizard-shell/wizard-errors';
import { createStore } from '@shared/application/store'; import { createStore } from '@shared/application/store';
import { whenTag } from '@shared/kernel/fp'; import { whenTag } from '@shared/kernel/fp';
import { BigProfileStore } from '@registratie/application/big-profile.store'; import { BigProfileStore } from '@registratie/application/big-profile.store';
@@ -21,35 +15,35 @@ import {
IntakeState, IntakeState,
IntakeMsg, IntakeMsg,
Answers, Answers,
Errors,
StepId, StepId,
initial, initial,
reduce, reduce,
STEPS, STEPS,
lageUren,
hasProgress, hasProgress,
SCHOLING_THRESHOLD_DEFAULT, SCHOLING_THRESHOLD_DEFAULT,
} from '@herregistratie/domain/intake.machine'; } from '@herregistratie/domain/intake.machine';
import { createDraftSync } from '@registratie/application/draft-sync'; import { createDraftSync } from '@registratie/application/draft-sync';
import { IntakePolicyStore } from '@herregistratie/application/intake-policy.store'; import { IntakePolicyStore } from '@herregistratie/application/intake-policy.store';
import { BuitenlandStep } from './buitenland.step';
import { WerkStep } from './werk.step';
import { ReviewStep } from './review.step';
/** Organism: a BRANCHING intake questionnaire. All state lives in one signal /** Organism: a BRANCHING intake questionnaire. All state lives in one signal
driven by the pure `reduce` (intake.machine.ts). Which step renders is derived driven by the pure `reduce` (intake.machine.ts). Which step renders is derived
from the answers via `visibleSteps`, never stored — so editing an earlier from the answers via `visibleSteps`, never stored — so editing an earlier
answer immediately changes the remaining steps. Answers are persisted to answer immediately changes the remaining steps. The draft persists to the
sessionStorage so a page reload keeps the user's progress (cleared on tab close). */ backend as a Concept aanvraag (createDraftSync), so a reload — or a "Verder
gaan" from the dashboard via `?aanvraag=<id>` — resumes progress. */
@Component({ @Component({
selector: 'app-intake-wizard', selector: 'app-intake-wizard',
imports: [ imports: [
FormsModule,
FormFieldComponent,
TextInputComponent,
RadioGroupComponent,
ButtonComponent, ButtonComponent,
AlertComponent,
DataRowComponent,
ReviewSectionComponent,
ConfirmationComponent, ConfirmationComponent,
WizardShellComponent, WizardShellComponent,
BuitenlandStep,
WerkStep,
ReviewStep,
], ],
template: ` template: `
<app-wizard-shell <app-wizard-shell
@@ -58,193 +52,38 @@ import { IntakePolicyStore } from '@herregistratie/application/intake-policy.sto
[stepTitle]="stepTitle()" [stepTitle]="stepTitle()"
i18n-processName="@@intake.processName" i18n-processName="@@intake.processName"
processName="Herregistratie-intake" processName="Herregistratie-intake"
[status]="shellStatus()" [phase]="phase()"
[primaryLabel]="primaryLabel()" [primaryLabel]="primaryLabel()"
[canGoBack]="cursor() > 0" [canGoBack]="cursor() > 0"
[errors]="errorList()" [errors]="errorList()"
[errorMessage]="errorMessage()" (primary)="dispatch({ tag: 'Primary' })"
(primary)="onPrimary()"
(back)="dispatch({ tag: 'Back' })" (back)="dispatch({ tag: 'Back' })"
(cancel)="restart()" (cancel)="restart()"
(retry)="onRetry()" (retry)="dispatch({ tag: 'Retry' })"
(goToStep)="dispatch({ tag: 'GaNaarStap', cursor: $event })" (goToStep)="dispatch({ tag: 'GaNaarStap', cursor: $event })"
> >
@switch (step()) { @switch (step()) {
@case ('buitenland') { @case ('buitenland') {
<fieldset> <app-intake-buitenland-step
<app-form-field [answers]="answers()"
i18n-label="@@intake.q.buitenland" [errors]="errors()"
label="Heeft u de afgelopen 5 jaar buiten Nederland gewerkt?" (answerChange)="dispatch({ tag: 'SetAnswer', key: $event.key, value: $event.value })"
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') { @case ('werk') {
<fieldset> <app-intake-werk-step
<app-form-field [answers]="answers()"
i18n-label="@@intake.q.urenNl" [errors]="errors()"
label="Gewerkte uren in Nederland (afgelopen 5 jaar)" [scholingThreshold]="scholingThreshold()"
fieldId="uren" (answerChange)="dispatch({ tag: 'SetAnswer', key: $event.key, value: $event.value })"
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') { @case ('review') {
<app-alert type="info" i18n="@@intake.review.controleer" <app-intake-review-step
>Controleer uw antwoorden en dien de aanvraag in.</app-alert [answers]="answers()"
> [scholingThreshold]="scholingThreshold()"
<app-review-section (edit)="dispatch({ tag: 'GaNaarStap', cursor: $event })"
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>
} }
} }
@@ -268,16 +107,66 @@ export class IntakeWizardComponent {
// Server-owned policy (scholing threshold): fetched from the backend via the // Server-owned policy (scholing threshold): fetched from the backend via the
// application facade, not hardcoded. The backend stays the authority on submit. // application facade, not hardcoded. The backend stays the authority on submit.
private policyStore = inject(IntakePolicyStore); private policyStore = inject(IntakePolicyStore);
private store = createStore<IntakeState, IntakeMsg>(initial, reduce);
/** Optional seed so Storybook / the showcase can mount any state directly. */ /** Optional seed so Storybook / the showcase can mount any state directly. */
seed = input<IntakeState>(initial); seed = input<IntakeState>(initial);
readonly jaNee = JA_NEE; // --- The store: all state in one signal, changed only by a pure reduce -----
// The effect fires once, on the `Answering -> Submitting` transition. `Seed` is exempt,
// so a story that mounts straight into `Submitting` does not call the network.
// `draftSync` is declared below (both callbacks are deferred, so the cycle is safe).
private store = createStore<IntakeState, IntakeMsg>(initial, reduce, {
Submitting: async (s, store) => {
this.profile.beginHerregistratie();
// The scholing answer rides along so the server can re-validate it as the
// authority (IntakePolicy.RejectIncompleteScholing) — undefined members are dropped by
// JSON.stringify, so a wizard above the threshold sends neither field.
const r = await this.draftSync.submit({
uren: s.data.uren,
aanvullendeScholing: s.data.aanvullendeScholing,
scholingPunten: s.data.punten,
});
if (r.ok) {
store.dispatch({ tag: 'SubmitConfirmed' });
this.profile.confirmHerregistratie();
} else {
store.dispatch({ tag: 'SubmitFailed', error: r.error });
this.profile.rollbackHerregistratie();
}
},
});
readonly state = this.store.model; readonly state = this.store.model;
readonly dispatch = this.store.dispatch; readonly dispatch = this.store.dispatch;
// Backend draft-sync (replaces sessionStorage); the intake has no uploads. // --- Static copy: stepper labels and per-step headings ---------------------
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`,
};
// --- State projections: one narrow, then read-only views of it -------------
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,
);
protected errors = computed<Errors>(() => this.answering()?.errors ?? {});
// --- Controllers: persistence and uploads ----------------------------------
// Create a Concept on first progress, then debounced-sync the snapshot.
// `?aanvraag=<id>` resumes it. The intake has no uploads.
private draftSync = createDraftSync({ private draftSync = createDraftSync({
type: 'intake', type: 'intake',
snapshot: () => { snapshot: () => {
@@ -289,64 +178,36 @@ export class IntakeWizardComponent {
enabled: () => this.seed() === initial, 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 --------------------- // --- 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 stepTitle = computed(() => this.stepTitles[this.step()]);
protected primaryLabel = computed(() => { protected primaryLabel = computed(() => {
if (this.step() === 'review') return $localize`:@@intake.indienen:Aanvraag indienen`; if (this.step() === 'review') return $localize`:@@intake.indienen:Aanvraag indienen`;
const next = this.cursor() + 1; const next = this.cursor() + 1;
return naarStapLabel(next + 1, this.stepLabels[next]); return naarStapLabel(next + 1, this.stepLabels[next]);
}); });
protected errorMessage = computed( /** Maps this machine's own tags onto the shell's `WizardPhase` vocabulary,
() => $localize`:@@wizard.indienenMislukt:Indienen mislukt:` + ` ${this.failedError()}`, composing the localized failure prefix so the `Failed` message arrives intact. */
); protected phase = computed<WizardPhase>(() => {
protected shellStatus = computed<WizardStatus>(() => { const s = this.state();
switch (this.state().tag) { switch (s.tag) {
case 'Answering': case 'Answering':
return 'editing'; return { tag: 'Editing' };
case 'Submitting': case 'Submitting':
return 'submitting'; return { tag: 'Submitting' };
case 'Submitted': case 'Submitted':
return 'submitted'; return { tag: 'Submitted' };
case 'Failed': case 'Failed':
return 'failed'; return {
tag: 'Failed',
message: $localize`:@@wizard.indienenMislukt:Indienen mislukt:` + ` ${s.error}`,
};
} }
}); });
/** Current step's field errors, flattened for the shell's error summary. The /** 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. */ field ids match the answer keys, so the summary anchors jump to the field. */
protected errorList = computed<WizardError[]>(() => { protected errorList = computed<WizardError[]>(() =>
const e = this.answering()?.errors ?? {}; toWizardErrors(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() { constructor() {
// An explicit seed (stories/tests) wins; otherwise resume the backend draft // An explicit seed (stories/tests) wins; otherwise resume the backend draft
@@ -364,43 +225,8 @@ export class IntakeWizardComponent {
}); });
} }
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() { restart() {
this.draftSync.reset(); this.draftSync.reset();
this.dispatch({ tag: 'Seed', state: initial }); 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();
// WP-69: the scholing answer rides along so the server can re-validate it as the
// authority (IntakePolicy.RejectIncompleteScholing) — undefined members are dropped by
// JSON.stringify, so a wizard above the threshold sends neither field.
const r = await this.draftSync.submit({
uren: s.data.uren,
aanvullendeScholing: s.data.aanvullendeScholing,
scholingPunten: s.data.punten,
});
if (r.ok) {
this.dispatch({ tag: 'SubmitConfirmed' });
this.profile.confirmHerregistratie();
} else {
this.dispatch({ tag: 'SubmitFailed', error: r.error });
this.profile.rollbackHerregistratie();
}
}
} }
@@ -0,0 +1,85 @@
import { Component, computed, input, output } from '@angular/core';
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { DataRowComponent } from '@shared/ui/molecules/data-row/data-row.component';
import { ReviewSectionComponent } from '@shared/ui/molecules/review-section/review-section.component';
import { Answers, lageUren } from '@herregistratie/domain/intake.machine';
/** Step: the intake wizard's review screen. Pure & presentational — values in via
`answers`/`scholingThreshold`, the cursor to jump back to out via `edit`. No
store, no services, no internal state; the parent maps the cursor onto its own
`GaNaarStap` message. */
@Component({
selector: 'app-intake-review-step',
imports: [AlertComponent, DataRowComponent, ReviewSectionComponent],
template: `
<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)="edit.emit(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)="edit.emit(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>
`,
})
export class ReviewStep {
answers = input.required<Answers>();
scholingThreshold = input.required<number>();
edit = output<number>();
protected scholingZichtbaar = computed(() => lageUren(this.answers(), this.scholingThreshold()));
}
@@ -0,0 +1,84 @@
import { Component, computed, input, output } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FormFieldComponent } from '@shared/ui/molecules/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/atoms/text-input/text-input.component';
import { RadioGroupComponent, JA_NEE } from '@shared/ui/atoms/radio-group/radio-group.component';
import { Answers, Errors, lageUren } from '@herregistratie/domain/intake.machine';
/** Step: the intake wizard's second screen (work experience in the Netherlands,
with the inline scholing follow-up). Pure & presentational — values in via
`answers`/`errors`/`scholingThreshold`, every keystroke out via `answerChange`.
No store, no services, no internal state; the parent owns the Model and
decides what a change means. */
@Component({
selector: 'app-intake-werk-step',
imports: [FormsModule, FormFieldComponent, TextInputComponent, RadioGroupComponent],
template: `
<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)="answerChange.emit({ key: 'uren', value: $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)="answerChange.emit({ key: 'scholingGevolgd', value: $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)="answerChange.emit({ key: 'punten', value: $event })"
name="punten"
i18n-placeholder="@@intake.q.puntenPlaceholder"
placeholder="bijv. 200"
/>
</app-form-field>
</fieldset>
}
`,
})
export class WerkStep {
answers = input.required<Answers>();
errors = input.required<Errors>();
scholingThreshold = input.required<number>();
answerChange = output<{ key: keyof Answers; value: string }>();
readonly jaNee = JA_NEE;
protected err = (k: keyof Answers) => this.errors()[k] ?? '';
protected scholingZichtbaar = computed(() => lageUren(this.answers(), this.scholingThreshold()));
}
@@ -1,6 +1,6 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { IntakeWizardComponent } from '@herregistratie/ui/intake-wizard/intake-wizard.component'; import { IntakeWizardComponent } from '@herregistratie/ui/intake-wizard/intake-wizard.component';
/** Page: the branching intake questionnaire. Built entirely from existing /** Page: the branching intake questionnaire. Built entirely from existing
@@ -1,9 +1,9 @@
import { Component, computed, inject } from '@angular/core'; import { Component, computed, inject } from '@angular/core';
import { HeadingComponent } from '@shared/ui/heading/heading.component'; import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { ApplicationListComponent } from '@shared/ui/application-list/application-list.component'; import { ApplicationListComponent } from '@shared/ui/molecules/application-list/application-list.component';
import { ApplicationLinkComponent } from '@shared/ui/application-link/application-link.component'; import { ApplicationLinkComponent } from '@shared/ui/molecules/application-link/application-link.component';
import { AccessStore } from '@shared/application/access.store'; import { AccessStore } from '@shared/application/access.store';
import { ADMIN_LINKS } from '../../../shell/nav.config'; import { HEADER_ADMIN_LINKS } from '@shared/layout/site-header/nav-config';
/** Section: "Beheer" — the admin pages the current principal may reach, capability- /** Section: "Beheer" — the admin pages the current principal may reach, capability-
gated (never role-derived), the same source + filter the site header uses. gated (never role-derived), the same source + filter the site header uses.
@@ -31,5 +31,6 @@ import { ADMIN_LINKS } from '../../../shell/nav.config';
}) })
export class BeheerLinksSection { export class BeheerLinksSection {
private access = inject(AccessStore); private access = inject(AccessStore);
protected adminLinks = computed(() => ADMIN_LINKS.filter((l) => this.access.can(l.cap))); private rawAdminLinks = inject(HEADER_ADMIN_LINKS);
protected adminLinks = computed(() => this.rawAdminLinks.filter((l) => this.access.can(l.cap)));
} }
@@ -1,17 +1,18 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { MijnAanvragenSection } from './dashboard/mijn-aanvragen.section'; import { MijnAanvragenSection } from '@registratie/ui/dashboard/mijn-aanvragen.section';
import { WatMoetIkRegelenSection } from './dashboard/wat-moet-ik-regelen.section'; import { WatMoetIkRegelenSection } from '@registratie/ui/dashboard/wat-moet-ik-regelen.section';
import { MijnRegistratieSection } from './dashboard/mijn-registratie.section'; import { MijnRegistratieSection } from '@registratie/ui/dashboard/mijn-registratie.section';
import { SpecialismenSection } from './dashboard/specialismen.section'; import { SpecialismenSection } from '@registratie/ui/dashboard/specialismen.section';
import { WatWiltUDoenSection } from './dashboard/wat-wilt-u-doen.section'; import { WatWiltUDoenSection } from './wat-wilt-u-doen.section';
import { BeheerLinksSection } from './dashboard/beheer-links.section'; import { BeheerLinksSection } from './beheer-links.section';
/** Page: "Mijn overzicht" — the portal home, following the NL Design System "Mijn /** Page: "Mijn overzicht" — the portal home, following the NL Design System "Mijn
omgeving" pattern. Composition only: each section below answers its own data omgeving" pattern. Composition only: each section below answers its own data
question (own store, own async state) — see `ui/dashboard/*.section.ts`. */ question (own store, own async state) — four sections stay in `registratie/ui/dashboard/`
(they render registratie data), two live here (cross-context navigation). */
@Component({ @Component({
selector: 'app-dashboard-page', selector: 'app-overzicht-page',
imports: [ imports: [
PageShellComponent, PageShellComponent,
MijnAanvragenSection, MijnAanvragenSection,
@@ -39,4 +40,4 @@ import { BeheerLinksSection } from './dashboard/beheer-links.section';
</app-page-shell> </app-page-shell>
`, `,
}) })
export class DashboardPage {} export class OverzichtPage {}
@@ -0,0 +1,28 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { applicationConfig } from '@storybook/angular';
import { provideRouter } from '@angular/router';
import { WatWiltUDoenSection } from './wat-wilt-u-doen.section';
import { FeatureFlagStore } from '@shared/application/feature-flags.store';
const meta: Meta<WatWiltUDoenSection> = {
title: 'Domein/Overzicht/Wat Wilt U Doen',
component: WatWiltUDoenSection,
decorators: [applicationConfig({ providers: [provideRouter([])] })],
};
export default meta;
type Story = StoryObj<WatWiltUDoenSection>;
export const InschrijvingOpen: Story = {
decorators: [
applicationConfig({
providers: [{ provide: FeatureFlagStore, useValue: { enabled: () => true } }],
}),
],
};
export const InschrijvingDicht: Story = {
decorators: [
applicationConfig({
providers: [{ provide: FeatureFlagStore, useValue: { enabled: () => false } }],
}),
],
};
@@ -1,7 +1,7 @@
import { Component, computed, inject } from '@angular/core'; import { Component, computed, inject } from '@angular/core';
import { HeadingComponent } from '@shared/ui/heading/heading.component'; import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { ApplicationListComponent } from '@shared/ui/application-list/application-list.component'; import { ApplicationListComponent } from '@shared/ui/molecules/application-list/application-list.component';
import { ApplicationLinkComponent } from '@shared/ui/application-link/application-link.component'; import { ApplicationLinkComponent } from '@shared/ui/molecules/application-link/application-link.component';
import { FeatureFlagStore } from '@shared/application/feature-flags.store'; import { FeatureFlagStore } from '@shared/application/feature-flags.store';
import { FLAG_INSCHRIJVING_OPEN } from '@shared/domain/feature-flag'; import { FLAG_INSCHRIJVING_OPEN } from '@shared/domain/feature-flag';
@@ -46,8 +46,8 @@ describe('AanvragenStore', () => {
expect(store.lastError()).toBeNull(); expect(store.lastError()).toBeNull();
}); });
// RB-20: a failed cancel must not be silent — the row rolls back AND the store // A failed cancel must not be silent — the row rolls back AND the store
// surfaces the error the page renders. Before RB-20 this only rolled back // surfaces the error the page renders. Before this fix it only rolled back
// (bare `catch { this.state.set(before) }`), so `lastError()` stayed null forever. // (bare `catch { this.state.set(before) }`), so `lastError()` stayed null forever.
it('rolls back the removal and surfaces the error when the cancel fails', async () => { it('rolls back the removal and surfaces the error when the cancel fails', async () => {
const cancel = vi.fn().mockRejectedValue(new Error('boom')); const cancel = vi.fn().mockRejectedValue(new Error('boom'));
@@ -14,7 +14,7 @@ type Err = Error | undefined;
* change-detection timing, HTTP caching, or a resource `reload()`. `reload()` re-fetches * change-detection timing, HTTP caching, or a resource `reload()`. `reload()` re-fetches
* so a page revisit reflects auto-approval (Concept → In behandeling → Goedgekeurd is * so a page revisit reflects auto-approval (Concept → In behandeling → Goedgekeurd is
* computed server-side on read). Cancel goes through `runSubmit` and rolls back plus * computed server-side on read). Cancel goes through `runSubmit` and rolls back plus
* surfaces `lastError` on failure (RB-20). * surfaces `lastError` on failure.
*/ */
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class AanvragenStore { export class AanvragenStore {
@@ -23,7 +23,7 @@ export class AanvragenStore {
private state = signal<RemoteData<Err, Aanvraag[]>>({ tag: 'Loading' }); private state = signal<RemoteData<Err, Aanvraag[]>>({ tag: 'Loading' });
readonly aanvragen = this.state.asReadonly(); readonly aanvragen = this.state.asReadonly();
/** Set on a failed cancel (RB-20): the optimistic removal already rolled back by /** Set on a failed cancel: the optimistic removal already rolled back by
then, this is only the message for the alert the page renders above the list. */ then, this is only the message for the alert the page renders above the list. */
private error = signal<string | null>(null); private error = signal<string | null>(null);
readonly lastError = this.error.asReadonly(); readonly lastError = this.error.asReadonly();
@@ -55,7 +55,7 @@ export class AanvragenStore {
/** Cancel a Concept: drop it now (synchronous, guaranteed), then confirm the DELETE. /** Cancel a Concept: drop it now (synchronous, guaranteed), then confirm the DELETE.
No resync — the delete succeeded, so the optimistic removal is authoritative. On No resync — the delete succeeded, so the optimistic removal is authoritative. On
failure, roll back AND surface the error (RB-20) — a silent reappearance leaves the failure, roll back AND surface the error — a silent reappearance leaves the
user guessing why the block came back. */ user guessing why the block came back. */
async cancel(id: string) { async cancel(id: string) {
const before = this.state(); const before = this.state();
@@ -44,8 +44,8 @@ describe('AdminCasesStore', () => {
expect(s.tag === 'Success' && s.value.map((c) => c.id)).toEqual(['b']); expect(s.tag === 'Success' && s.value.map((c) => c.id)).toEqual(['b']);
}); });
// RB-20: a failed delete must not be silent — the row rolls back AND the store // A failed delete must not be silent — the row rolls back AND the store
// surfaces the error the page renders. Before RB-20 this only rolled back // surfaces the error the page renders. Before this fix it only rolled back
// (bare `catch { this.state.set(before) }`), so `lastError()` stayed null forever. // (bare `catch { this.state.set(before) }`), so `lastError()` stayed null forever.
it('rolls back the removal and surfaces the error when the delete fails', async () => { it('rolls back the removal and surfaces the error when the delete fails', async () => {
const deleteAny = vi.fn().mockRejectedValue(new Error('boom')); const deleteAny = vi.fn().mockRejectedValue(new Error('boom'));
@@ -7,11 +7,11 @@ import { AanvragenAdapter, parseAanvragen } from '@registratie/infrastructure/aa
type Err = Error | undefined; type Err = Error | undefined;
/** /**
* Admin view of ALL cases across owners (WP-36; `cases:manage`) — the back-office * Admin view of ALL cases across owners (`cases:manage`) — the back-office
* counterpart of the user-facing `AanvragenStore`. Same shape: one root singleton * counterpart of the user-facing `AanvragenStore`. Same shape: one root singleton
* owns the list as a writable RemoteData signal, delete removes the row synchronously * owns the list as a writable RemoteData signal, delete removes the row synchronously
* (optimistic), goes through `runSubmit`, and rolls back plus surfaces `lastError` on * (optimistic), goes through `runSubmit`, and rolls back plus surfaces `lastError` on
* failure (RB-20). Admin delete removes any case (any owner, submitted or not — the * failure. Admin delete removes any case (any owner, submitted or not — the
* server enforces the capability). * server enforces the capability).
*/ */
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
@@ -21,7 +21,7 @@ export class AdminCasesStore {
private state = signal<RemoteData<Err, Aanvraag[]>>({ tag: 'Loading' }); private state = signal<RemoteData<Err, Aanvraag[]>>({ tag: 'Loading' });
readonly cases = this.state.asReadonly(); readonly cases = this.state.asReadonly();
/** Set on a failed delete (RB-20): the optimistic removal already rolled back by /** Set on a failed delete: the optimistic removal already rolled back by
then, this is only the message for the alert the page renders above the list. */ then, this is only the message for the alert the page renders above the list. */
private error = signal<string | null>(null); private error = signal<string | null>(null);
readonly lastError = this.error.asReadonly(); readonly lastError = this.error.asReadonly();
@@ -47,7 +47,7 @@ export class AdminCasesStore {
} }
/** Delete a case: drop it now (synchronous), then confirm the DELETE; roll back on error /** Delete a case: drop it now (synchronous), then confirm the DELETE; roll back on error
AND surface it (RB-20) — a silent reappearance leaves the admin guessing why. */ AND surface it — a silent reappearance leaves the admin guessing why. */
async delete(id: string) { async delete(id: string) {
const before = this.state(); const before = this.state();
if (before.tag === 'Success') { if (before.tag === 'Success') {
@@ -52,10 +52,12 @@ export class BigProfileStore {
); );
/** Specialisms/notes stay a separate stream (they have their own empty state). */ /** Specialisms/notes stay a separate stream (they have their own empty state). */
readonly aantekeningen = computed<RemoteData<Err, Aantekening[]>>(() => { readonly aantekeningen = computed<RemoteData<Err, Aantekening[]>>(() =>
const rd = fromResource(this.aantekeningenRes, (v) => !v || v.length === 0); map(
return rd.tag === 'Success' ? { tag: 'Success', value: rd.value ?? [] } : rd; fromResource(this.aantekeningenRes, (v) => !v || v.length === 0),
}); (v) => v ?? [],
),
);
// --- Optimistic herregistratie state, shared with the dashboard ----------- // --- Optimistic herregistratie state, shared with the dashboard -----------
private pending = signal(false); private pending = signal(false);
@@ -96,7 +96,7 @@ describe('createDraftSync', () => {
expect(r.ok).toBe(false); expect(r.ok).toBe(false);
}); });
it('recovers from a create conflict by adopting the existing Concept (WP-35)', async () => { it('recovers from a create conflict by adopting the existing Concept', async () => {
// Server enforces one Concept per type: a stale/cross-tab create is rejected (409), // 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. // and ensureId adopts the existing Concept from the list instead of erroring.
const create = vi.fn().mockRejectedValue({ status: 409 }); const create = vi.fn().mockRejectedValue({ status: 409 });
@@ -63,7 +63,7 @@ export function createDraftSync(deps: DraftSyncDeps) {
if (id) return id; if (id) return id;
ensuring ??= adapter ensuring ??= adapter
.create(deps.type) .create(deps.type)
// WP-35: one Concept per type is server-enforced. Within a tab the resumeGate // 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 // 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 // server's guard (409) — recover by adopting the existing Concept instead of
// erroring. Only recover when one actually exists; otherwise surface the failure. // erroring. Only recover when one actually exists; otherwise surface the failure.
@@ -3,7 +3,7 @@ import { AanvragenAdapter, parseAanvragen } from '@registratie/infrastructure/aa
/** /**
* Read half of the Concept lookup that `createDraftSync` (`draft-sync.ts`) needs * Read half of the Concept lookup that `createDraftSync` (`draft-sync.ts`) needs
* before it can start writing (RB-21 / CQ-001). Free functions that take the adapter * before it can start writing (CQ-001). Free functions that take the adapter
* as a parameter, not `inject()`, so they get a direct spec without Angular TestBed. * as a parameter, not `inject()`, so they get a direct spec without Angular TestBed.
* `createDraftSync` keeps the closure state (`id`, `resumeGate`) and the write path; * `createDraftSync` keeps the closure state (`id`, `resumeGate`) and the write path;
* these two functions only read. * these two functions only read.
@@ -10,8 +10,8 @@
*/ */
export type AanvraagType = 'registratie' | 'herregistratie' | 'intake'; export type AanvraagType = 'registratie' | 'herregistratie' | 'intake';
// Ingediend/MeerInfoGevraagd (ADR-0002/WP-63) are widened into the union so the parse // Ingediend/MeerInfoGevraagd (ADR-0002) are widened into the union so the parse
// boundary + renderers are ready, but no backend path emits them yet — that's WP-65's // boundary + renderers are ready, but no backend path emits them yet — that's the
// behandelaar-facing transition endpoint. // behandelaar-facing transition endpoint.
export type AanvraagStatus = export type AanvraagStatus =
| { tag: 'Concept'; stepIndex: number; stepCount: number } | { tag: 'Concept'; stepIndex: number; stepCount: number }
@@ -29,7 +29,7 @@ export interface Aanvraag {
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
submittedAt?: string; submittedAt?: string;
/** The case owner (a BSN). Only populated by the admin cross-owner list (WP-36); /** The case owner (a BSN). Only populated by the admin cross-owner list;
the user's own list leaves it undefined. */ the user's own list leaves it undefined. */
owner?: string; owner?: string;
} }
@@ -5,7 +5,7 @@ import {
} from '@registratie/domain/value-objects/telefoonnummer'; } from '@registratie/domain/value-objects/telefoonnummer';
/** What the user is typing (raw, possibly invalid). The BRP address is NOT part of /** 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 the form — it is authoritative and shown read-only; only the phone number
is editable here. */ is editable here. */
export interface Draft { export interface Draft {
telefoon: string; telefoon: string;
@@ -17,6 +17,7 @@ import {
setField, setField,
prefillAdres, prefillAdres,
submit, submit,
primary,
resolve, resolve,
reduce, reduce,
} from './registratie-wizard.machine'; } from './registratie-wizard.machine';
@@ -248,6 +249,26 @@ describe('submit', () => {
}); });
}); });
describe('primary', () => {
it('Primary advances to the next step from a non-final step', () => {
const s = toBeroepStepWithDiploma(); // Invullen, beroep step — not the final step
expect(currentStep(expectTag(s, 'Invullen'))).toBe('beroep');
expect(reduce(s, { tag: 'Primary' })).toEqual(reduce(s, { tag: 'Next' }));
expect(currentStep(expectTag(primary(s), 'Invullen'))).toBe('controle');
});
it('Primary submits from the final step', () => {
const s = toControleStep(); // Invullen, controle step — the final step
expect(reduce(s, { tag: 'Primary' })).toEqual(reduce(s, { tag: 'Submit' }));
expect(primary(s).tag).toBe('Indienen');
});
it('Primary is a no-op from a non-editing state', () => {
const indienen = toIndienen();
expect(primary(indienen)).toBe(indienen);
});
});
describe('reduce (message-driven happy path)', () => { describe('reduce (message-driven happy path)', () => {
it('adres and correspondentie set, Next advances from adres to beroep', () => { it('adres and correspondentie set, Next advances from adres to beroep', () => {
// Given the initial wizard. // Given the initial wizard.
@@ -9,6 +9,7 @@ import {
reduceUpload, reduceUpload,
requiredCategoriesSatisfied, requiredCategoriesSatisfied,
deliveryRefs, deliveryRefs,
digitalDocumentIds,
} from '@shared/domain/upload.machine'; } from '@shared/domain/upload.machine';
/** /**
@@ -66,7 +67,7 @@ export type DraftField = 'straat' | 'postcode' | 'woonplaats' | 'email';
/** Per-field error map. `antwoorden` holds per-policy-question errors, keyed by /** Per-field error map. `antwoorden` holds per-policy-question errors, keyed by
question id (a step can show several questions). */ question id (a step can show several questions). */
export interface Errors { export type Errors = {
straat?: string; straat?: string;
postcode?: string; postcode?: string;
woonplaats?: string; woonplaats?: string;
@@ -75,7 +76,7 @@ export interface Errors {
diploma?: string; diploma?: string;
documenten?: string; documenten?: string;
antwoorden?: Record<string, string>; antwoorden?: Record<string, string>;
} };
export type RegistratieState = export type RegistratieState =
| { tag: 'Invullen'; draft: Draft; cursor: number; errors: Errors; upload: UploadState } | { tag: 'Invullen'; draft: Draft; cursor: number; errors: Errors; upload: UploadState }
@@ -109,7 +110,7 @@ export function hasProgress(s: Extract<RegistratieState, { tag: 'Invullen' }>):
!!d.email || !!d.email ||
!!d.diplomaId || !!d.diplomaId ||
!!d.beroep || !!d.beroep ||
deliveryRefs(s.upload).some((r) => r.channel === 'digital' && !!r.documentId) digitalDocumentIds(s.upload).length > 0
); );
} }
@@ -287,6 +288,13 @@ export function submit(s: RegistratieState): RegistratieState {
return r.ok ? { tag: 'Indienen', data: r.value } : { ...s, errors: r.error }; return r.ok ? { tag: 'Indienen', data: r.value } : { ...s, errors: r.error };
} }
/** The primary button's action: advance, or submit from the controle step.
No-op outside Invullen — this is the one decision the old component-side handler used to make. */
export function primary(s: RegistratieState): RegistratieState {
if (s.tag !== 'Invullen') return s;
return currentStep(s) === 'controle' ? submit(s) : next(s);
}
/** Route an upload sub-message through the pure upload reducer (Invullen only). */ /** Route an upload sub-message through the pure upload reducer (Invullen only). */
export function upload(s: RegistratieState, msg: UploadMsg): RegistratieState { export function upload(s: RegistratieState, msg: UploadMsg): RegistratieState {
if (s.tag !== 'Invullen') return s; if (s.tag !== 'Invullen') return s;
@@ -312,6 +320,7 @@ export type RegistratieMsg =
| { tag: 'Back' } | { tag: 'Back' }
| { tag: 'GaNaarStap'; cursor: number } | { tag: 'GaNaarStap'; cursor: number }
| { tag: 'Submit' } | { tag: 'Submit' }
| { tag: 'Primary' }
| { tag: 'Retry' } | { tag: 'Retry' }
| { tag: 'SubmitConfirmed'; referentie: string } | { tag: 'SubmitConfirmed'; referentie: string }
| { tag: 'SubmitFailed'; error: string } | { tag: 'SubmitFailed'; error: string }
@@ -342,6 +351,8 @@ export function reduce(s: RegistratieState, m: RegistratieMsg): RegistratieState
return gaNaarStap(s, m.cursor); return gaNaarStap(s, m.cursor);
case 'Submit': case 'Submit':
return submit(s); return submit(s);
case 'Primary':
return primary(s);
case 'Retry': case 'Retry':
return s.tag === 'Mislukt' ? { tag: 'Indienen', data: s.data } : s; return s.tag === 'Mislukt' ? { tag: 'Indienen', data: s.data } : s;
case 'SubmitConfirmed': case 'SubmitConfirmed':
@@ -32,12 +32,12 @@ export class AanvragenAdapter {
return this.client.aanvragenAll(); return this.client.aanvragenAll();
} }
/** Admin: every case across all owners (WP-36; `cases:manage`). Parsed at the boundary. */ /** Admin: every case across all owners (`cases:manage`). Parsed at the boundary. */
listAll(): Promise<AanvraagSummaryDto[]> { listAll(): Promise<AanvraagSummaryDto[]> {
return this.client.casesAll(); return this.client.casesAll();
} }
/** Admin: delete ANY case (any owner, submitted or not — WP-36). */ /** Admin: delete ANY case (any owner, submitted or not). */
deleteAny(id: string): Promise<void> { deleteAny(id: string): Promise<void> {
return this.client.cases(id); return this.client.cases(id);
} }
@@ -117,7 +117,7 @@ function parseCommon(dto: AanvraagSummaryDto): Result<string, Aanvraag> {
createdAt: dto.createdAt, createdAt: dto.createdAt,
updatedAt: dto.updatedAt, updatedAt: dto.updatedAt,
submittedAt: dto.submittedAt, submittedAt: dto.submittedAt,
owner: dto.owner, // only present on the admin cross-owner list (WP-36) owner: dto.owner, // only present on the admin cross-owner list
}); });
} }
@@ -6,7 +6,7 @@ import { Valid } from '@registratie/domain/change-request.machine';
* Infrastructure adapter for the telefoonwijziging POST (`/api/v1/change-requests`) — * Infrastructure adapter for the telefoonwijziging POST (`/api/v1/change-requests`) —
* the single place the network client lives for contact changes, so the command * the single place the network client lives for contact changes, so the command
* and the UI never touch `ApiClient`. The BRP address is authoritative and not * and the UI never touch `ApiClient`. The BRP address is authoritative and not
* submitted (WP-34); only the phone number is. Returns the server reference; the * submitted; only the phone number is. Returns the server reference; the
* server re-validates and is the authority. * server re-validates and is the authority.
*/ */
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
@@ -1,7 +1,7 @@
import { Component, computed, input, output } from '@angular/core'; import { Component, computed, input, output } from '@angular/core';
import { formatDatumNl } from '@shared/kernel/datum'; import { formatDatumNl } from '@shared/kernel/datum';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { Aanvraag } from '@registratie/domain/aanvraag'; import { Aanvraag } from '@registratie/domain/aanvraag';
import { TYPE_LABELS } from '@registratie/domain/aanvraag-view'; import { TYPE_LABELS } from '@registratie/domain/aanvraag-view';
import { blockActions } from '@registratie/domain/block-actions'; import { blockActions } from '@registratie/domain/block-actions';
@@ -2,11 +2,11 @@ import { Component, computed, inject } from '@angular/core';
import { successOf } from '@shared/application/remote-data'; import { successOf } from '@shared/application/remote-data';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component'; import { SkeletonComponent } from '@shared/ui/atoms/skeleton/skeleton.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { DataBlockComponent } from '@shared/ui/data-block/data-block.component'; import { DataBlockComponent } from '@shared/ui/molecules/data-block/data-block.component';
import { DataRowComponent } from '@shared/ui/data-row/data-row.component'; import { DataRowComponent } from '@shared/ui/molecules/data-row/data-row.component';
import { ASYNC } from '@shared/ui/async/async.component'; import { ASYNC } from '@shared/ui/molecules/async/async.component';
import { AanvragenStore } from '@registratie/application/aanvragen.store'; import { AanvragenStore } from '@registratie/application/aanvragen.store';
import { Aanvraag } from '@registratie/domain/aanvraag'; import { Aanvraag } from '@registratie/domain/aanvraag';
import { detailRows } from '@registratie/domain/aanvraag-view'; import { detailRows } from '@registratie/domain/aanvraag-view';
@@ -1,7 +1,7 @@
import { Component, input, output } from '@angular/core'; import { Component, input, output } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component'; import { FormFieldComponent } from '@shared/ui/molecules/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component'; import { TextInputComponent } from '@shared/ui/atoms/text-input/text-input.component';
export interface AdresValue { export interface AdresValue {
straat: string; straat: string;
@@ -1,18 +1,19 @@
import { Component, computed, effect, inject } from '@angular/core'; import { Component, computed, effect, inject } from '@angular/core';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { DataBlockComponent } from '@shared/ui/data-block/data-block.component'; import { DataBlockComponent } from '@shared/ui/molecules/data-block/data-block.component';
import { DataRowComponent } from '@shared/ui/data-row/data-row.component'; import { DataRowComponent } from '@shared/ui/molecules/data-row/data-row.component';
import { ASYNC } from '@shared/ui/async/async.component'; import { ASYNC } from '@shared/ui/molecules/async/async.component';
import { AccessStore } from '@shared/application/access.store'; import { AccessStore } from '@shared/application/access.store';
import { successOr } from '@shared/application/remote-data';
import { formatDatumNl } from '@shared/kernel/datum'; import { formatDatumNl } from '@shared/kernel/datum';
import { Aanvraag } from '@registratie/domain/aanvraag'; import { Aanvraag } from '@registratie/domain/aanvraag';
import { TYPE_LABELS, statusLabel, referentie } from '@registratie/domain/aanvraag-view'; import { TYPE_LABELS, statusLabel, referentie } from '@registratie/domain/aanvraag-view';
import { AdminCasesStore } from '@registratie/application/admin-cases.store'; import { AdminCasesStore } from '@registratie/application/admin-cases.store';
/** /**
* Admin page: every case across all owners, with an admin delete (WP-36). Lives in * Admin page: every case across all owners, with an admin delete. Lives in
* `registratie` (which owns the Aanvraag aggregate) — the back-office counterpart of the * `registratie` (which owns the Aanvraag aggregate) — the back-office counterpart of the
* user's dashboard, reusing the same view labels + trust-boundary parse. Deny-by-default * user's dashboard, reusing the same view labels + trust-boundary parse. Deny-by-default
* capability gate (`cases:manage`): a denial alert for non-admins, the list for admins. * capability gate (`cases:manage`): a denial alert for non-admins, the list for admins.
@@ -78,10 +79,7 @@ export class AdminCasesPage {
protected access = inject(AccessStore); protected access = inject(AccessStore);
protected canManage = computed(() => this.access.can('cases:manage')); protected canManage = computed(() => this.access.can('cases:manage'));
protected cases = computed(() => { protected cases = computed(() => successOr(this.store.cases(), []));
const rd = this.store.cases();
return rd.tag === 'Success' ? rd.value : [];
});
protected heading = $localize`:@@adminCases.heading:Aanvragen beheren`; protected heading = $localize`:@@adminCases.heading:Aanvragen beheren`;
protected intro = $localize`:@@adminCases.intro:Alle aanvragen in het register. Een aanvraag verwijderen kan niet ongedaan worden gemaakt.`; protected intro = $localize`:@@adminCases.intro:Alle aanvragen in het register. Een aanvraag verwijderen kan niet ongedaan worden gemaakt.`;
@@ -111,7 +109,7 @@ export class AdminCasesPage {
private loadRequested = false; private loadRequested = false;
constructor() { constructor() {
// Load once the capability resolves to allowed (a 403 GET would be wasted otherwise). // Load once the capability resolves to allowed (a 403 GET would be wasted otherwise).
// Depends only on canManage() + a plain flag — never the store model (WP-26 loop lesson). // Depends only on canManage() + a plain flag — never the store model (the loop lesson).
effect(() => { effect(() => {
if (this.canManage() && !this.loadRequested) { if (this.canManage() && !this.loadRequested) {
this.loadRequested = true; this.loadRequested = true;
@@ -1,12 +1,12 @@
import { Component, computed, input } from '@angular/core'; import { Component, computed, input } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { HeadingComponent } from '@shared/ui/heading/heading.component'; import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component'; import { FormFieldComponent } from '@shared/ui/molecules/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component'; import { TextInputComponent } from '@shared/ui/atoms/text-input/text-input.component';
import { DataBlockComponent } from '@shared/ui/data-block/data-block.component'; import { DataBlockComponent } from '@shared/ui/molecules/data-block/data-block.component';
import { DataRowComponent } from '@shared/ui/data-row/data-row.component'; import { DataRowComponent } from '@shared/ui/molecules/data-row/data-row.component';
import { Adres } from '@registratie/domain/person'; import { Adres } from '@registratie/domain/person';
import { createStore } from '@shared/application/store'; import { createStore } from '@shared/application/store';
import { whenTag } from '@shared/kernel/fp'; import { whenTag } from '@shared/kernel/fp';
@@ -20,7 +20,7 @@ import { createSubmitChangeRequest } from '@registratie/application/submit-chang
/** /**
* Organism: contact-change (telefoonwijziging) form. The BRP address is authoritative * Organism: contact-change (telefoonwijziging) form. The BRP address is authoritative
* and shown READ-ONLY (WP-34) — you change your address at the gemeente, not here — so * and shown READ-ONLY — you change your address at the gemeente, not here — so
* only the phone number is editable. Uses the SAME idiom as the wizards: all state in * only the phone number is editable. Uses the SAME idiom as the wizards: all state in
* one signal driven by the pure `reduce` (change-request.machine.ts), submitted via a * one signal driven by the pure `reduce` (change-request.machine.ts), submitted via a
* `submit-*` command returning `Result`. The server re-validates. * `submit-*` command returning `Result`. The server re-validates.
@@ -65,6 +65,26 @@ import { createSubmitChangeRequest } from '@registratie/application/submit-chang
>Nieuwe wijziging doorgeven</app-button >Nieuwe wijziging doorgeven</app-button
> >
</div> </div>
} @else if (state().tag === 'Failed') {
<app-heading [level]="2" i18n="@@changeRequest.heading">Contactgegevens wijzigen</app-heading>
<app-alert type="error"
><ng-container i18n="@@changeRequest.failed">Het indienen is niet gelukt:</ng-container>
{{ failedError() }}</app-alert
>
<app-data-block class="app-section" [ariaLabel]="telefoonLabelText">
<div app-data-row [key]="telefoonLabelText" [value]="telefoon()"></div>
</app-data-block>
<div class="app-section">
<app-button
variant="secondary"
(click)="dispatch({ tag: 'Retry' })"
i18n="@@wizard.opnieuwProberen"
>Opnieuw proberen</app-button
>
</div>
} @else { } @else {
<app-heading [level]="2" i18n="@@changeRequest.heading">Contactgegevens wijzigen</app-heading> <app-heading [level]="2" i18n="@@changeRequest.heading">Contactgegevens wijzigen</app-heading>
@@ -108,13 +128,6 @@ import { createSubmitChangeRequest } from '@registratie/application/submit-chang
</app-form-field> </app-form-field>
</fieldset> </fieldset>
@if (failedError()) {
<app-alert type="error"
><ng-container i18n="@@changeRequest.failed">Het indienen is niet gelukt:</ng-container>
{{ failedError() }}</app-alert
>
}
<app-button type="submit" variant="primary" [disabled]="state().tag === 'Submitting'"> <app-button type="submit" variant="primary" [disabled]="state().tag === 'Submitting'">
{{ state().tag === 'Submitting' ? submitBezigLabel : submitLabel }} {{ state().tag === 'Submitting' ? submitBezigLabel : submitLabel }}
</app-button> </app-button>
@@ -127,7 +140,15 @@ export class ChangeRequestFormComponent {
// adapter); the UI holds only this bound command. Field initializer = injection // adapter); the UI holds only this bound command. Field initializer = injection
// context, like createStore below. // context, like createStore below.
private submit = createSubmitChangeRequest(); private submit = createSubmitChangeRequest();
private store = createStore<ChangeRequestState, ChangeRequestMsg>(initial, reduce); // Effect fires once, on Editing -> Submitting (RD-05's tag-transition rule; `Seed` is
// exempt, so a story mounting straight into `Submitting` does not call the network).
private store = createStore<ChangeRequestState, ChangeRequestMsg>(initial, reduce, {
Submitting: async (s, store) => {
const r = await this.submit(s.data);
if (r.ok) store.dispatch({ tag: 'SubmitConfirmed', referentie: r.value });
else store.dispatch({ tag: 'SubmitFailed', error: r.error });
},
});
/** BRP address, shown read-only. Undefined until the profile loads. */ /** BRP address, shown read-only. Undefined until the profile loads. */
brpAdres = input<Adres | undefined>(undefined); brpAdres = input<Adres | undefined>(undefined);
@@ -148,6 +169,10 @@ export class ChangeRequestFormComponent {
protected readonly postcodeLabel = $localize`:@@address.postcode:Postcode`; protected readonly postcodeLabel = $localize`:@@address.postcode:Postcode`;
protected readonly woonplaatsLabel = $localize`:@@address.woonplaats:Woonplaats`; protected readonly woonplaatsLabel = $localize`:@@address.woonplaats:Woonplaats`;
// Same id as the telefoon form-field's label above, reused for the Failed
// data-block's row key.
protected readonly telefoonLabelText = $localize`:@@changeRequest.telefoonLabel:Telefoonnummer`;
private editing = computed(() => whenTag(this.state(), 'Editing')); private editing = computed(() => whenTag(this.state(), 'Editing'));
protected errors = computed(() => this.editing()?.errors ?? {}); protected errors = computed(() => this.editing()?.errors ?? {});
protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? ''); protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? '');
@@ -168,15 +193,5 @@ export class ChangeRequestFormComponent {
onSubmit() { onSubmit() {
this.dispatch({ tag: 'Submit' }); this.dispatch({ tag: 'Submit' });
this.runIfSubmitting();
}
/** Effect: when we entered Submitting, call the command, then dispatch the outcome. */
private async runIfSubmitting() {
const s = this.state();
if (s.tag !== 'Submitting') return;
const r = await this.submit(s.data);
if (r.ok) this.dispatch({ tag: 'SubmitConfirmed', referentie: r.value });
else this.dispatch({ tag: 'SubmitFailed', error: r.error });
} }
} }
@@ -34,7 +34,7 @@ function storeStub(aanvragen: RemoteData<Error | undefined, Aanvraag[]>, lastErr
} }
const meta: Meta<MijnAanvragenSection> = { const meta: Meta<MijnAanvragenSection> = {
title: 'Domein/Registratie/Dashboard/Mijn Aanvragen', title: 'Domein/Registratie/Mijn Aanvragen',
component: MijnAanvragenSection, component: MijnAanvragenSection,
decorators: [applicationConfig({ providers: [provideRouter([])] })], decorators: [applicationConfig({ providers: [provideRouter([])] })],
}; };
@@ -1,11 +1,12 @@
import { Component, computed, inject } from '@angular/core'; import { Component, computed, inject } from '@angular/core';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component'; import { SkeletonComponent } from '@shared/ui/atoms/skeleton/skeleton.component';
import { HeadingComponent } from '@shared/ui/heading/heading.component'; import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { ApplicationListComponent } from '@shared/ui/application-list/application-list.component'; import { ApplicationListComponent } from '@shared/ui/molecules/application-list/application-list.component';
import { ApplicationLinkComponent } from '@shared/ui/application-link/application-link.component'; import { ApplicationLinkComponent } from '@shared/ui/molecules/application-link/application-link.component';
import { ASYNC } from '@shared/ui/async/async.component'; import { ASYNC } from '@shared/ui/molecules/async/async.component';
import { successOr } from '@shared/application/remote-data';
import { AanvragenStore } from '@registratie/application/aanvragen.store'; import { AanvragenStore } from '@registratie/application/aanvragen.store';
import { Aanvraag, AanvraagType } from '@registratie/domain/aanvraag'; import { Aanvraag, AanvraagType } from '@registratie/domain/aanvraag';
import { import {
@@ -88,10 +89,9 @@ export class MijnAanvragenSection {
protected submittedRow = submittedRow; protected submittedRow = submittedRow;
protected aanvragen = computed<Aanvraag[]>(() => { protected aanvragen = computed<Aanvraag[]>(() =>
const rd = this.store.aanvragen(); sortForDashboard(successOr(this.store.aanvragen(), [])),
return rd.tag === 'Success' ? sortForDashboard(rd.value) : []; );
});
protected concepten_ = computed(() => concepten(this.aanvragen())); protected concepten_ = computed(() => concepten(this.aanvragen()));
protected ingediend_ = computed(() => ingediend(this.aanvragen())); protected ingediend_ = computed(() => ingediend(this.aanvragen()));
@@ -28,7 +28,7 @@ function storeStub(profileRd: RemoteData<Error | undefined, BigProfile>) {
} }
const meta: Meta<MijnRegistratieSection> = { const meta: Meta<MijnRegistratieSection> = {
title: 'Domein/Registratie/Dashboard/Mijn Registratie', title: 'Domein/Registratie/Mijn Registratie',
component: MijnRegistratieSection, component: MijnRegistratieSection,
}; };
export default meta; export default meta;
@@ -1,10 +1,10 @@
import { Component, inject } from '@angular/core'; import { Component, inject } from '@angular/core';
import { successOf } from '@shared/application/remote-data'; import { successOf } from '@shared/application/remote-data';
import { HeadingComponent } from '@shared/ui/heading/heading.component'; import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component'; import { SkeletonComponent } from '@shared/ui/atoms/skeleton/skeleton.component';
import { DataBlockComponent } from '@shared/ui/data-block/data-block.component'; import { DataBlockComponent } from '@shared/ui/molecules/data-block/data-block.component';
import { DataRowComponent } from '@shared/ui/data-row/data-row.component'; import { DataRowComponent } from '@shared/ui/molecules/data-row/data-row.component';
import { ASYNC } from '@shared/ui/async/async.component'; import { ASYNC } from '@shared/ui/molecules/async/async.component';
import { BigProfileStore } from '@registratie/application/big-profile.store'; import { BigProfileStore } from '@registratie/application/big-profile.store';
import { RegistrationSummaryComponent } from '@registratie/ui/registration-summary/registration-summary.component'; import { RegistrationSummaryComponent } from '@registratie/ui/registration-summary/registration-summary.component';
@@ -17,7 +17,7 @@ function storeStub(aantekeningen: RemoteData<Error | undefined, Aantekening[]>)
} }
const meta: Meta<SpecialismenSection> = { const meta: Meta<SpecialismenSection> = {
title: 'Domein/Registratie/Dashboard/Specialismen', title: 'Domein/Registratie/Specialismen',
component: SpecialismenSection, component: SpecialismenSection,
}; };
export default meta; export default meta;
@@ -1,8 +1,8 @@
import { Component, inject } from '@angular/core'; import { Component, inject } from '@angular/core';
import { successOf } from '@shared/application/remote-data'; import { successOf } from '@shared/application/remote-data';
import { HeadingComponent } from '@shared/ui/heading/heading.component'; import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component'; import { SkeletonComponent } from '@shared/ui/atoms/skeleton/skeleton.component';
import { ASYNC } from '@shared/ui/async/async.component'; import { ASYNC } from '@shared/ui/molecules/async/async.component';
import { BigProfileStore } from '@registratie/application/big-profile.store'; import { BigProfileStore } from '@registratie/application/big-profile.store';
import { RegistrationTableComponent } from '@registratie/ui/registration-table/registration-table.component'; import { RegistrationTableComponent } from '@registratie/ui/registration-table/registration-table.component';
@@ -0,0 +1,109 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { applicationConfig } from '@storybook/angular';
import { WatMoetIkRegelenSection } from './wat-moet-ik-regelen.section';
import { BigProfileStore } from '@registratie/application/big-profile.store';
import { BigProfile } from '@registratie/domain/big-profile';
import { HerregistratieDecisions } from '@registratie/domain/registration';
import { RemoteData } from '@shared/application/remote-data';
import { loading, success } from '@shared/testing/remote-data';
const profile: BigProfile = {
registration: {
bigNummer: '19012345601',
naam: 'Dr. A. (Anna) de Vries',
beroep: 'Arts',
registratiedatum: '2012-09-01',
geboortedatum: '1985-03-14',
status: { tag: 'Geregistreerd', herregistratieDatum: '2027-09-01' },
},
person: {
naam: 'Dr. A. (Anna) de Vries',
geboortedatum: '1985-03-14',
adres: { straat: 'Rijksweg 1', postcode: '2514 EA', woonplaats: 'Den Haag' },
},
};
/** Minimal store stand-in — only the members the section's template and class read. */
function storeStub(
profileRd: RemoteData<Error | undefined, BigProfile>,
decisionsRd: RemoteData<Error | undefined, HerregistratieDecisions>,
pendingHerregistratie: boolean,
) {
return {
profile: () => profileRd,
decisions: () => decisionsRd,
pendingHerregistratie: () => pendingHerregistratie,
reloadProfile: () => {},
};
}
const meta: Meta<WatMoetIkRegelenSection> = {
title: 'Domein/Registratie/Wat Moet Ik Regelen',
component: WatMoetIkRegelenSection,
};
export default meta;
type Story = StoryObj<WatMoetIkRegelenSection>;
export const Loading: Story = {
decorators: [
applicationConfig({
providers: [{ provide: BigProfileStore, useValue: storeStub(loading(), loading(), false) }],
}),
],
};
export const MetTaken: Story = {
decorators: [
applicationConfig({
providers: [
{
provide: BigProfileStore,
useValue: storeStub(
success(profile),
success({ eligibleForHerregistratie: true }),
false,
),
},
],
}),
],
parameters: {
// Structural: app-choice-link's host sits between the keuzelijst <ul> and its <li>
// — axe's list/listitem rule needs them adjacent regardless of `display:contents`.
// Same pre-existing gap as task-list.stories.ts and choice-list.stories.ts. WP-11
// (CIBG markup fidelity) reworks this markup; see
// docs/project/backlog/WP-11-markup-fidelity.md.
a11y: { disable: true },
},
};
export const NietsOpenstaand: Story = {
decorators: [
applicationConfig({
providers: [
{
provide: BigProfileStore,
useValue: storeStub(
success(profile),
success({ eligibleForHerregistratie: false }),
false,
),
},
],
}),
],
};
export const InBehandeling: Story = {
decorators: [
applicationConfig({
providers: [
{
provide: BigProfileStore,
useValue: storeStub(
success(profile),
success({ eligibleForHerregistratie: false }),
true,
),
},
],
}),
],
};

Some files were not shown because too many files have changed in this diff Show More