feat(WP-67): merge behandelportal into this repo as a monorepo

Restructures into apps/ssp + apps/behandelportal (two Angular projects)
plus libs/shared + libs/beheer (cross-app libraries), replacing WP-61's
separate sibling repo. That split had already produced real drift: a
hand-vendored copy of the backend's OpenAPI doc, a shared/ui+layout tree
forked and silently diverging (7 files), and beheer + the styles.scss
token bridge duplicated byte-for-byte across both repos.

- git mv the SSP's src/app/* into apps/ssp/; fold shared/, beheer/,
  environments/, the Storybook docs/*.mdx, and styles.scss into
  libs/shared + libs/beheer (all confirmed identical between the two
  repos before merging). auth stays deliberately duplicated per
  ADR-0002 (actor-specific, expected to diverge) - amended there.
- One generated API client (libs/shared), no more vendored swagger.json.
- .dependency-cruiser split into a base factory + one config per app,
  and Storybook into .storybook-ssp/.storybook-behandelportal - both
  forced by the @auth/* alias resolving to different directories per app.
- SiteHeaderComponent/ShellComponent gained HEADER_NAV_ITEMS/
  HEADER_ADMIN_LINKS/DEBUG_PANEL injection tokens so each app supplies
  its own nav/admin-links/dev-panel instead of one being hardcoded.
- CLAUDE.md, ARCHITECTURE.md, dependencies.md, and ADR-0002 updated;
  WP-67 backlog entry documents the full decision trail.

npm run ci green (lint, dep:check x2, 360 tests across ssp/
behandelportal/shared/beheer, both localized builds, backend tests,
snippet + api-client drift); both dev servers, both Storybook
instances, and docker compose verified working.

The old sibling repo (/home/eho/repos/behandelportal) is left
untouched, not deleted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-08-02 21:01:57 +02:00
co-authored by Claude Sonnet 5
parent d3f3b13345
commit e7156c5132
403 changed files with 7103 additions and 60917 deletions
@@ -0,0 +1,267 @@
import { Component, ElementRef, computed, input, output, viewChild } from '@angular/core';
import { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { MaskedValueComponent } from '@shared/ui/masked-value/masked-value.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { StepperComponent } from '@shared/ui/stepper/stepper.component';
import { Besluit, Brief, CaseContext, LibraryPassage } from '@brief/domain/brief';
import { besluitGuidance, inferSelection } from '@brief/domain/besluit';
import { Diagnostic } from '@brief/domain/placeholders';
import { OrgTemplate } from '@brief/domain/org-template';
import { BriefMsg } from '@brief/domain/brief.machine';
import { LetterCanvasComponent } from '@brief/ui/letter-canvas/letter-canvas.component';
import { DiagnosticsPanelComponent } from '@brief/ui/diagnostics-panel/diagnostics-panel.component';
import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejection-comments.component';
import { LetterEditorComponent } from '@brief/ui/letter-editor/letter-editor.component';
import { BesluitPanelComponent } from '@brief/ui/besluit-panel/besluit-panel.component';
/** Organism: the behandelaar's drafting step. Frames "Brief opstellen" as one step in
the case workflow — a case-context header + stepper (Beoordelen → Brief opstellen →
Indienen, neighbours stubbed) — with the besluit-driven guidance, the lean letter
editor, and an on-demand full-letter preview in a modal. Only ever renders for an
editable brief (draft/rejected); the approver's read-only flow stays in
letter-composer. Presentational: emits edit/submit/preview intents. */
@Component({
selector: 'app-behandel-scherm',
imports: [
ButtonComponent,
HeadingComponent,
MaskedValueComponent,
AlertComponent,
StepperComponent,
LetterCanvasComponent,
DiagnosticsPanelComponent,
RejectionCommentsComponent,
LetterEditorComponent,
BesluitPanelComponent,
],
styles: [
`
:host {
display: block;
}
.case-head {
margin-block-end: var(--rhc-space-max-lg);
padding: var(--rhc-space-max-md) var(--rhc-space-max-lg);
border-inline-start: 4px solid var(--rhc-color-primary, var(--rhc-color-border-strong));
background: var(--rhc-color-background-subtle, transparent);
}
.case-meta {
display: flex;
flex-wrap: wrap;
gap: var(--rhc-space-max-md);
color: var(--rhc-color-foreground-subtle);
}
.step-body {
display: grid;
gap: var(--rhc-space-max-xl);
margin-block-start: var(--rhc-space-max-lg);
}
.bar {
display: flex;
flex-wrap: wrap;
gap: var(--rhc-space-max-md);
align-items: center;
margin-block-start: var(--rhc-space-max-xl);
}
dialog {
border: none;
border-radius: var(--rhc-radius-md, 4px);
padding: 0;
max-width: min(900px, 95vw);
width: 100%;
}
dialog::backdrop {
background: rgb(0 0 0 / 45%); /* token-ok: modal scrim, not a palette colour */
}
.modal-body {
padding: var(--rhc-space-max-lg);
max-height: 80vh;
overflow: auto;
}
.modal-bar {
display: flex;
justify-content: space-between;
gap: var(--rhc-space-max-md);
padding: var(--rhc-space-max-md) var(--rhc-space-max-lg);
border-block-start: 1px solid var(--rhc-color-border);
}
`,
],
template: `
<div class="case-head">
<app-heading [level]="2">{{ caseHeading() }}</app-heading>
<div class="case-meta">
<span>{{ caseContext().aanvraagReferentie }}</span>
<span>{{ caseContext().zorgverlenerNaam }}</span>
<span>
{{ bigLabel() }}
<app-masked-value
[value]="caseContext().bigNummer"
[canReveal]="canRevealBigNummer()"
[revealLabel]="revealLabel()"
(reveal)="onReveal()"
/>
</span>
<span>{{ caseContext().beroep }}</span>
</div>
</div>
<app-stepper
[steps]="steps()"
[current]="1"
[processName]="processName()"
[stepTitle]="stepTitle()"
/>
<div class="step-body">
@if (status() === 'rejected') {
<app-rejection-comments mode="show" [comments]="rejectComments()" />
}
<app-besluit-panel
[passages]="availablePassages()"
[besluit]="selection().besluit"
[initialRedenen]="selection().reasons"
(selectionChange)="onSelection($event)"
/>
@if (guidance(); as g) {
@if (g.needsReason) {
<app-alert type="warning">{{ needsReasonHint }}</app-alert>
} @else {
<app-alert type="info">{{ insertedHint(g.insertedCount) }}</app-alert>
}
}
<app-letter-editor [brief]="brief()" [placeholders]="menu()" (edit)="edit.emit($event)" />
<app-diagnostics-panel [diagnostics]="diagnostics()" (locate)="locate.emit($event)" />
<div class="bar">
<app-button variant="subtle" (click)="openPreview()">{{ previewLabel() }}</app-button>
<app-button variant="primary" [disabled]="!canSubmit() || busy()" (click)="submit.emit()">{{
submitLabel()
}}</app-button>
@if (!canSubmit()) {
<span class="app-text-subtle">{{ submitHint() }}</span>
}
</div>
</div>
<dialog #previewDialog>
<div class="modal-body">
<app-letter-canvas
[brief]="brief()"
[orgTemplate]="orgTemplate()"
[logoUrl]="logoUrl()"
[editableRegions]="'none'"
[diagnostics]="diagnostics()"
/>
</div>
<div class="modal-bar">
<app-button variant="secondary" (click)="preview.emit()">{{
openDocumentLabel()
}}</app-button>
<app-button variant="primary" (click)="closePreview()">{{ closeLabel() }}</app-button>
</div>
</dialog>
`,
})
export class BehandelSchermComponent {
brief = input.required<Brief>();
orgTemplate = input.required<OrgTemplate>();
logoUrl = input<string | null>(null);
availablePassages = input<readonly LibraryPassage[]>([]);
diagnostics = input<readonly Diagnostic[]>([]);
caseContext = input.required<CaseContext>();
canSubmit = input(false);
busy = input(false);
/** Server decision (PRD-0002 §5c): may this actor unmask the case BIG-nummer? */
canRevealBigNummer = input(false);
edit = output<BriefMsg>();
submit = output<void>();
preview = output<void>();
locate = output<Diagnostic>();
revealBigNummer = output<void>();
/** Step-up (PRD-0002 §5d) stubbed as a native confirm — the extra verification gesture
before an audited PII reveal. ponytail: real systems prompt MFA / recent re-auth. */
protected onReveal() {
if (confirm(this.stepUpPrompt())) this.revealBigNummer.emit();
}
private previewDialog = viewChild<ElementRef<HTMLDialogElement>>('previewDialog');
protected status = computed(() => this.brief().status.tag);
protected rejectComments = computed(() => {
const s = this.brief().status;
return s.tag === 'rejected' ? s.comments : '';
});
/** The besluit + redenen the letter currently reflects, read back off the kern's
passages — this seeds the panel so it survives reload/undo (no separate storage). */
protected selection = computed(() => {
const kern = this.brief().sections.find((s) => s.sectionKey === 'kern');
return inferSelection(kern?.blocks ?? [], this.availablePassages());
});
/** Visible guidance for the current selection — null until a besluit is chosen (the
panel's own intro copy prompts that first step). */
protected guidance = computed(() => {
const s = this.selection();
return s.besluit ? besluitGuidance(this.availablePassages(), s.besluit, s.reasons) : null;
});
protected needsReasonHint = $localize`:@@brief.guidance.needsReason:Kies een reden, zodat de juiste motivering aan de brief wordt toegevoegd.`;
protected insertedHint = (n: number) =>
$localize`:@@brief.guidance.inserted:${n}:count: standaardtekst(en) toegevoegd op basis van het besluit. Vul aan met vrije tekst waar nodig.`;
// Same insert menu as the composer: only valid, fillable, non-deprecated fields.
protected menu = computed<PlaceholderOption[]>(() =>
this.brief()
.placeholders.filter((p) => p.fillable !== false && !p.deprecated)
.map((p) => ({ key: p.key, label: p.label, autoResolvable: p.autoResolvable })),
);
/** Besluit/redenen changed → recompose the kern as one edit (= one undo step). */
protected onSelection(sel: { besluit: Besluit | null; reasons: string[] }) {
this.edit.emit({ tag: 'BesluitSelected', besluit: sel.besluit, reasons: sel.reasons });
}
protected openPreview() {
this.previewDialog()?.nativeElement.showModal();
}
protected closePreview() {
this.previewDialog()?.nativeElement.close();
}
protected submitLabel = computed(() =>
this.status() === 'rejected'
? $localize`:@@brief.resubmit:Opnieuw indienen`
: $localize`:@@brief.submit:Indienen ter beoordeling`,
);
protected steps = input<string[]>([
$localize`:@@brief.step.beoordelen:Beoordelen`,
$localize`:@@brief.step.opstellen:Brief opstellen`,
$localize`:@@brief.step.indienen:Indienen`,
]);
protected processName = input($localize`:@@brief.process:Herregistratie behandelen`);
protected stepTitle = input($localize`:@@brief.step.opstellen:Brief opstellen`);
protected caseHeading = input($localize`:@@brief.case.heading:Aanvraag herregistratie`);
protected bigLabel = input($localize`:@@brief.case.big:BIG-nummer`);
protected revealLabel = input($localize`:@@brief.case.reveal:Toon BIG-nummer`);
protected stepUpPrompt = input(
$localize`:@@brief.case.revealConfirm:Extra verificatie vereist. Het tonen van het BIG-nummer wordt vastgelegd. Doorgaan?`,
);
protected previewLabel = input($localize`:@@brief.preview.open:Voorbeeld`);
protected openDocumentLabel = input(
$localize`:@@brief.preview.openDocument:Openen als document (PDF)`,
);
protected closeLabel = input($localize`:@@common.close:Sluiten`);
protected submitHint = input(
$localize`:@@brief.submitHint:Vul eerst alle verplichte secties en los fouten op.`,
);
}
@@ -0,0 +1,174 @@
import type { Meta, StoryObj } from '@storybook/angular';
import {
Brief,
BriefStatus,
CaseContext,
LibraryPassage,
allDiagnostics,
} from '@brief/domain/brief';
import { OrgTemplate } from '@brief/domain/org-template';
import { BehandelSchermComponent } from './behandel-scherm.component';
const text = (t: string): LibraryPassage['content'] => ({
paragraphs: [{ nodes: [{ type: 'text', text: t }] }],
});
const orgTemplate: OrgTemplate = {
subOrgId: 'cibg-registers',
orgName: 'CIBG — Registers',
returnAddress: 'BIG-register\nPostbus 00000\n2500 AA Den Haag',
footerContact: 'www.bigregister.nl',
footerLegal: 'Ons kenmerk vermelden bij correspondentie.',
signatureName: 'A. de Vries',
signatureRole: 'Hoofd Registratie',
signatureClosing: 'Met vriendelijke groet,',
margins: { topMm: 25, rightMm: 25, bottomMm: 25, leftMm: 25 },
version: 1,
};
const caseContext: CaseContext = {
zorgverlenerNaam: 'Dr. A. (Anna) de Vries',
bigNummer: '19012345601',
beroep: 'arts',
aanvraagReferentie: 'HER-2026-000842',
};
const passages: LibraryPassage[] = [
{
passageId: 'p-kern-positief',
scope: 'global',
sectionKey: 'kern',
label: 'Toewijzing',
version: 1,
besluit: 'positief',
content: text('Uw aanvraag is toegewezen.'),
},
{
passageId: 'p-kern-negatief',
scope: 'global',
sectionKey: 'kern',
label: 'Afwijzing',
version: 1,
besluit: 'negatief',
content: text('Uw aanvraag is afgewezen.'),
},
{
passageId: 'p-kern-scholing',
scope: 'global',
sectionKey: 'kern',
label: 'Onvoldoende scholing',
version: 1,
besluit: 'negatief',
reason: 'onvoldoende_scholing',
content: text('Onvoldoende scholing.'),
},
];
function brief(status: BriefStatus, kernBlocks: Brief['sections'][number]['blocks'] = []): Brief {
return {
briefId: 'b1',
beroep: 'arts',
templateId: 't1',
drafterId: 'demo-drafter',
status,
placeholders: [
{ key: 'naam_zorgverlener', label: 'Naam zorgverlener', autoResolvable: true },
{ key: 'reden_besluit', label: 'Reden besluit', autoResolvable: false },
],
sections: [
{
sectionKey: 'aanhef',
title: 'Aanhef',
required: true,
locked: true,
blocks: [{ type: 'freeText', blockId: 'aanhef-1', content: text('Geachte heer/mevrouw,') }],
},
{
sectionKey: 'kern',
title: 'Kern van het besluit',
required: true,
locked: false,
blocks: kernBlocks,
},
{
sectionKey: 'slot',
title: 'Slot',
required: false,
locked: true,
blocks: [{ type: 'freeText', blockId: 'slot-1', content: text('Met vriendelijke groet,') }],
},
],
};
}
const meta: Meta<BehandelSchermComponent> = {
title: 'Domein/Brief/Behandel Scherm',
component: BehandelSchermComponent,
args: { orgTemplate, caseContext, availablePassages: passages, busy: false },
};
export default meta;
type Story = StoryObj<BehandelSchermComponent>;
/** Fresh case: no besluit chosen yet, so the kern is empty and only the editable
body shows (aanhef/slot appear in the preview). */
export const EmptyKern: Story = {
args: { brief: brief({ tag: 'draft' }), diagnostics: [], canSubmit: false },
};
// A besluit-sourced kern block (carries provenance), so the panel re-seeds itself from it.
const negatiefScholingKern: Brief['sections'][number]['blocks'] = [
{
type: 'passage',
blockId: 'local-1',
sourcePassageId: 'p-kern-negatief',
sourceVersion: 1,
edited: false,
content: text('Uw aanvraag is afgewezen.'),
},
{
type: 'passage',
blockId: 'local-2',
sourcePassageId: 'p-kern-scholing',
sourceVersion: 1,
edited: false,
content: text('Onvoldoende scholing.'),
},
];
/** Draft with a negatief besluit: the kern is filled from the selection and the besluit
panel reflects it (negatief + "onvoldoende scholing" ticked), read back off the kern. */
export const WithContent: Story = {
render: (args) => {
const b = brief({ tag: 'draft' }, negatiefScholingKern);
return { props: { ...args, brief: b, diagnostics: allDiagnostics(b), canSubmit: true } };
},
};
/** Field-level PII (PRD-0002 §5c): the case BIG-nummer arrives MASKED, as the server
ships it. The behandelaar holds the reveal capability, so the "Toon BIG-nummer"
action shows — it runs a step-up confirm and an audited server call before unmasking. */
export const MaskedBigNummer: Story = {
args: {
brief: brief({ tag: 'draft' }),
diagnostics: [],
canSubmit: false,
caseContext: { ...caseContext, bigNummer: '********601' },
canRevealBigNummer: true,
},
};
/** Rejected: the drafter reopens; the rejection comments show above the editor. */
export const Rejected: Story = {
render: (args) => {
const b = brief(
{
tag: 'rejected',
rejectedBy: 'demo-approver',
rejectedAt: '2026-07-01',
comments: 'Graag de reden concreter.',
},
negatiefScholingKern,
);
return { props: { ...args, brief: b, diagnostics: allDiagnostics(b), canSubmit: true } };
},
};
@@ -0,0 +1,109 @@
import { Component, computed, input, linkedSignal, output } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { CheckboxComponent } from '@shared/ui/checkbox/checkbox.component';
import { RadioGroupComponent, RadioOption } from '@shared/ui/radio-group/radio-group.component';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { Besluit, LibraryPassage } from '@brief/domain/brief';
import { redenenFor } from '@brief/domain/besluit';
/** Organism: the guided-drafting selector. The behandelaar picks the besluit
(positief/negatief) and — for a negatief besluit — the reden(en); the kern's
standaardteksten follow the selection LIVE (`selectionChange` → the store recomposes
the kern). This is the "no detective work" step: which passages belong is decided by
the besluit, not by the drafter hunting the library.
ponytail: view-state signals, not a form-machine — no validation/submission of its own;
it just reports the selection. `besluit`/`redenen` inputs re-seed it (via linkedSignal)
from the persisted letter on reload/undo, so it always reflects the letter's real state. */
@Component({
selector: 'app-besluit-panel',
imports: [FormsModule, CheckboxComponent, RadioGroupComponent, HeadingComponent],
styles: [
`
:host {
display: block;
padding: var(--rhc-space-max-lg);
border: 1px solid var(--rhc-color-border);
border-radius: var(--rhc-radius-md, 4px);
background: var(--rhc-color-background-subtle, transparent);
}
.redenen {
display: grid;
gap: var(--rhc-space-max-sm);
margin-block-start: var(--rhc-space-max-md);
}
`,
],
template: `
<app-heading [level]="3">{{ heading() }}</app-heading>
<p class="app-text-subtle">{{ intro() }}</p>
<app-radio-group
name="besluit"
[options]="besluitOptions()"
[ngModel]="selected()"
(ngModelChange)="onBesluit($event)"
/>
@if (redenen().length > 0) {
<div class="redenen" role="group" [attr.aria-label]="redenenLabel()">
@for (r of redenen(); track r.code) {
<app-checkbox
[checkboxId]="'reden-' + r.code"
[label]="r.label"
[ngModel]="checked().has(r.code)"
(ngModelChange)="toggle(r.code, $event)"
/>
}
</div>
}
`,
})
export class BesluitPanelComponent {
/** The passage library — the redenen checkboxes are derived from its negatief tags. */
passages = input<readonly LibraryPassage[]>([]);
/** The letter's current selection, inferred from its kern passages — re-seeds the panel. */
besluit = input<Besluit | null>(null);
initialRedenen = input<readonly string[]>([]);
selectionChange = output<{ besluit: Besluit | null; reasons: string[] }>();
protected selected = linkedSignal<Besluit | ''>(() => this.besluit() ?? '');
protected checked = linkedSignal<ReadonlySet<string>>(() => new Set(this.initialRedenen()));
/** The reden checkboxes for the chosen besluit — derived from the reason-tagged passages. */
protected redenen = computed(() =>
this.selected() === '' ? [] : redenenFor(this.passages(), this.selected() as Besluit),
);
protected onBesluit(value: string) {
this.selected.set(value === 'positief' || value === 'negatief' ? value : '');
this.checked.set(new Set()); // redenen only apply to the chosen besluit
this.emit();
}
protected toggle(code: string, on: boolean) {
const next = new Set(this.checked());
if (on) next.add(code);
else next.delete(code);
this.checked.set(next);
this.emit();
}
private emit() {
this.selectionChange.emit({
besluit: this.selected() === '' ? null : (this.selected() as Besluit),
reasons: [...this.checked()],
});
}
protected besluitOptions = input<RadioOption[]>([
{ value: 'positief', label: $localize`:@@brief.besluit.positief:Positief besluit (toewijzen)` },
{ value: 'negatief', label: $localize`:@@brief.besluit.negatief:Negatief besluit (afwijzen)` },
]);
protected heading = input($localize`:@@brief.besluit.heading:Besluit`);
protected intro = input(
$localize`:@@brief.besluit.intro:Kies het besluit; de juiste standaardteksten verschijnen meteen in de brief.`,
);
protected redenenLabel = input($localize`:@@brief.besluit.redenen:Reden(en) voor afwijzing`);
}
@@ -0,0 +1,65 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { LibraryPassage } from '@brief/domain/brief';
import { BesluitPanelComponent } from './besluit-panel.component';
const text = (t: string): LibraryPassage['content'] => ({
paragraphs: [{ nodes: [{ type: 'text', text: t }] }],
});
const passages: LibraryPassage[] = [
{
passageId: 'p-kern-positief',
scope: 'global',
sectionKey: 'kern',
label: 'Toewijzing',
version: 1,
besluit: 'positief',
content: text('Toegewezen.'),
},
{
passageId: 'p-kern-negatief',
scope: 'global',
sectionKey: 'kern',
label: 'Afwijzing',
version: 1,
besluit: 'negatief',
content: text('Afgewezen.'),
},
{
passageId: 'p-kern-scholing',
scope: 'global',
sectionKey: 'kern',
label: 'Onvoldoende scholing',
version: 1,
besluit: 'negatief',
reason: 'onvoldoende_scholing',
content: text('Onvoldoende scholing.'),
},
{
passageId: 'p-kern-gegevens',
scope: 'global',
sectionKey: 'kern',
label: 'Onjuiste gegevens',
version: 1,
besluit: 'negatief',
reason: 'onjuiste_gegevens',
content: text('Onjuiste gegevens.'),
},
];
const meta: Meta<BesluitPanelComponent> = {
title: 'Domein/Brief/Besluit Panel',
component: BesluitPanelComponent,
args: { passages },
};
export default meta;
type Story = StoryObj<BesluitPanelComponent>;
/** Pick a besluit; a negatief besluit reveals the reason checkboxes. Each change emits
`selectionChange` and the kern's standaardteksten follow live — no generate button. */
export const Default: Story = {};
/** Re-seeded from a persisted letter: a negatief besluit with a reden already ticked. */
export const NegatiefMetReden: Story = {
args: { besluit: 'negatief', initialRedenen: ['onvoldoende_scholing'] },
};
+193
View File
@@ -0,0 +1,193 @@
import { Component, computed, inject } from '@angular/core';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { ASYNC } from '@shared/ui/async/async.component';
import { BriefStore } from '@brief/application/brief.store';
import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-composer.component';
import { BehandelSchermComponent } from '@brief/ui/behandel-scherm/behandel-scherm.component';
/** Page: thin container. Injects the root store, kicks off the load, and passes its
derived read-model to the composer. Business/UI logic lives below in pure pieces;
this just wires signals to the organism and events back to store commands. */
@Component({
selector: 'app-brief-page',
imports: [
PageShellComponent,
AlertComponent,
ButtonComponent,
...ASYNC,
LetterComposerComponent,
BehandelSchermComponent,
],
host: { '(document:keydown)': 'onKey($event)' },
styles: [
`
.brief-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--rhc-space-max-md);
margin-block-end: var(--rhc-space-max-lg);
}
.toolbar-start {
display: flex;
align-items: center;
gap: var(--rhc-space-max-md);
}
.save {
color: var(--rhc-color-foreground-subtle);
font-size: 0.9em;
}
`,
],
template: `
<app-page-shell [heading]="heading" [intro]="intro" backLink="/dashboard">
@if (lastError(); as err) {
<app-alert type="error">{{ err }}</app-alert>
}
<app-async [data]="store.remoteData()">
<ng-template appAsyncError>
<app-alert type="error">{{ failedText }}</app-alert>
<app-button variant="secondary" (click)="reload()">{{ retryText }}</app-button>
</ng-template>
<ng-template appAsyncLoaded>
@if (loaded(); as s) {
@if (store.orgTemplate(); as orgTemplate) {
<div class="brief-toolbar">
<div class="toolbar-start">
@if (store.canEdit()) {
<app-button
variant="subtle"
[disabled]="!store.canUndo()"
[attr.aria-label]="undoLabel"
(click)="store.undo()"
>{{ undoLabel }}</app-button
>
<app-button
variant="subtle"
[disabled]="!store.canRedo()"
[attr.aria-label]="redoLabel"
(click)="store.redo()"
>{{ redoLabel }}</app-button
>
}
<span class="save" role="status" aria-live="polite">{{ saveText() }}</span>
@if (store.saveState().tag === 'Error') {
<app-button variant="secondary" (click)="store.retrySave()">{{
retrySaveLabel
}}</app-button>
}
</div>
<app-button variant="subtle" [disabled]="store.busy()" (click)="resetDemo()">{{
resetLabel
}}</app-button>
</div>
@if (store.canEdit() && store.caseContext(); as caseContext) {
<!-- Drafter (behandelaar): guided drafting step in the case workflow. -->
<app-behandel-scherm
[brief]="s.brief"
[orgTemplate]="orgTemplate"
[logoUrl]="store.logoUrl()"
[availablePassages]="s.availablePassages"
[diagnostics]="store.diagnostics()"
[caseContext]="caseContext"
[canSubmit]="store.canSubmit()"
[busy]="store.busy()"
[canRevealBigNummer]="store.canRevealBigNummer()"
(edit)="store.edit($event)"
(submit)="store.submit()"
(preview)="store.previewLetter()"
(revealBigNummer)="store.revealBigNummer()"
/>
} @else {
<!-- Approver / read-only: review + approve/reject/send. -->
<app-letter-composer
[brief]="s.brief"
[orgTemplate]="orgTemplate"
[logoUrl]="store.logoUrl()"
[diagnostics]="store.diagnostics()"
[blockDiffs]="store.blockDiffs()"
[removedCount]="store.removedSinceReject()"
[canApprove]="store.canApprove()"
[canReject]="store.canReject()"
[canSend]="store.canSend()"
[busy]="store.busy()"
(approve)="store.approve()"
(reject)="store.reject($event)"
(send)="store.send()"
(preview)="store.previewLetter()"
/>
}
}
}
</ng-template>
</app-async>
</app-page-shell>
`,
})
export class BriefPage {
protected store = inject(BriefStore);
protected model = this.store.model;
protected lastError = this.store.lastError;
protected heading = $localize`:@@brief.page.heading:Brief opstellen`;
protected intro = $localize`:@@brief.page.intro:Stel de brief aan de zorgverlener samen uit standaardteksten en vrije tekst.`;
protected failedText = $localize`:@@brief.page.failed:De brief kon niet worden geladen.`;
protected retryText = $localize`:@@brief.page.retry:Opnieuw proberen`;
protected resetLabel = $localize`:@@brief.page.reset:Opnieuw beginnen (demo)`;
protected undoLabel = $localize`:@@brief.page.undo:Ongedaan maken`;
protected redoLabel = $localize`:@@brief.page.redo:Opnieuw uitvoeren`;
protected retrySaveLabel = $localize`:@@brief.page.retrySave:Opnieuw proberen`;
private savingText = $localize`:@@brief.page.saving:Concept opslaan…`;
private savedText = $localize`:@@brief.page.saved:Concept opgeslagen`;
private saveErrorText = $localize`:@@brief.page.saveError:Niet opgeslagen — opnieuw proberen`;
/** Debounced-save state, surfaced in a polite live region. */
protected saveText = computed(() => {
switch (this.store.saveState().tag) {
case 'Saving':
return this.savingText;
case 'Saved':
return this.savedText;
case 'Error':
return this.saveErrorText;
default:
return '';
}
});
constructor() {
void this.store.load();
}
protected resetDemo() {
void this.store.resetDemo();
}
/** Typed narrowing for the `<app-async>` loaded slot — see WP-06: a structural
directive's context can't inherit a generic from a sibling host input, so the
Success value is unwrapped here instead of through `let-`. */
protected readonly loaded = computed(() => {
const s = this.model();
return s.tag === 'loaded' ? s : undefined;
});
protected reload() {
void this.store.load();
}
/** Ctrl/Cmd+Z = undo, Ctrl/Cmd+Shift+Z = redo (WP-27). Ignored while focus is in the
rich-text editor or a form control, so the browser's own text undo keeps working
there — our shell-level undo is for structural edits (add/remove/reorder blocks). */
protected onKey(e: KeyboardEvent) {
if (!(e.ctrlKey || e.metaKey) || e.key.toLowerCase() !== 'z' || !this.store.canEdit()) return;
const t = e.target as HTMLElement | null;
if (t && (t.isContentEditable || t.tagName === 'INPUT' || t.tagName === 'TEXTAREA')) return;
e.preventDefault();
if (e.shiftKey) this.store.redo();
else this.store.undo();
}
}
@@ -0,0 +1,73 @@
import { Component, computed, input, output } from '@angular/core';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { Diagnostic } from '@brief/domain/placeholders';
/** Molecule: lists all letter diagnostics grouped by severity. Errors block
save/send; warnings (deprecated, unresolved-at-send) are surfaced but allowed.
Fed by a `computed()` over the letter content — never stored. */
@Component({
selector: 'app-diagnostics-panel',
imports: [AlertComponent],
styles: [
`
:host {
display: block;
}
ul {
margin: 0;
padding-inline-start: 1.1rem;
display: grid;
gap: 0.15rem;
}
button {
background: none;
border: 0;
padding: 0;
color: var(--rhc-color-foreground-link);
cursor: pointer;
text-align: start;
text-decoration: underline;
}
`,
],
template: `
@if (errors().length) {
<app-alert type="error">
<strong>{{ errorsTitle() }}</strong>
<ul>
@for (d of errors(); track $index) {
<li>
<button type="button" (click)="locate.emit(d)">{{ d.message }}</button>
</li>
}
</ul>
</app-alert>
}
@if (warnings().length) {
<app-alert type="warning">
<strong>{{ warningsTitle() }}</strong>
<ul>
@for (d of warnings(); track $index) {
<li>
<button type="button" (click)="locate.emit(d)">{{ d.message }}</button>
</li>
}
</ul>
</app-alert>
}
@if (!errors().length && !warnings().length) {
<app-alert type="ok">{{ cleanText() }}</app-alert>
}
`,
})
export class DiagnosticsPanelComponent {
diagnostics = input<readonly Diagnostic[]>([]);
locate = output<Diagnostic>();
errorsTitle = input($localize`:@@brief.diag.errors:Op te lossen voor indienen/versturen:`);
warningsTitle = input($localize`:@@brief.diag.warnings:Aandachtspunten:`);
cleanText = input($localize`:@@brief.diag.clean:Geen problemen gevonden in de velden.`);
protected errors = computed(() => this.diagnostics().filter((d) => d.severity === 'error'));
protected warnings = computed(() => this.diagnostics().filter((d) => d.severity === 'warning'));
}
@@ -0,0 +1,33 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { Diagnostic } from '@brief/domain/placeholders';
import { DiagnosticsPanelComponent } from './diagnostics-panel.component';
const location = { blockId: 'local-2', paragraphIndex: 0, nodeIndex: 1 };
const errorAndWarning: Diagnostic[] = [
{
severity: 'error',
code: 'unknown-placeholder',
placeholderKey: 'onbekend_veld',
location,
message: 'Onbekend veld "onbekend_veld" — controleer de spelling.',
},
{
severity: 'warning',
code: 'unresolved-at-send',
placeholderKey: 'reden_besluit',
location,
message: '"Reden besluit" is nog niet ingevuld.',
},
];
const meta: Meta<DiagnosticsPanelComponent> = {
title: 'Domein/Brief/Diagnostics Panel',
component: DiagnosticsPanelComponent,
args: { locate: () => {} },
};
export default meta;
type Story = StoryObj<DiagnosticsPanelComponent>;
export const Findings: Story = { args: { diagnostics: errorAndWarning } };
export const Clean: Story = { args: { diagnostics: [] } };
@@ -0,0 +1,79 @@
import { Component, computed, input, output } from '@angular/core';
import { RichTextBlock } from '@shared/kernel/rich-text';
import {
RichTextEditorComponent,
PlaceholderOption,
} from '@shared/ui/rich-text-editor/rich-text-editor.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { LetterBlock } from '@brief/domain/brief';
/** Molecule: one block in a section — its editor plus provenance + block controls.
Presentational: emits content/remove/move events; the section maps them to messages. */
@Component({
selector: 'app-letter-block',
imports: [RichTextEditorComponent, ButtonComponent],
styles: [
`
:host {
display: block;
}
.block {
border-inline-start: var(--rhc-border-width-lg) solid var(--rhc-color-border-subtle);
padding-inline-start: var(--rhc-space-max-md);
}
.meta {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--rhc-space-max-md);
margin-block-end: var(--rhc-space-max-sm);
}
.controls {
display: flex;
gap: var(--rhc-space-max-sm);
}
`,
],
template: `
<div class="block">
<div class="meta">
<span class="app-text-subtle">{{ provenance() }}</span>
@if (editable()) {
<span class="controls">
<app-button variant="subtle" (click)="moved.emit(-1)" i18n="@@brief.block.moveUp"
>Omhoog</app-button
>
<app-button variant="subtle" (click)="moved.emit(1)" i18n="@@brief.block.moveDown"
>Omlaag</app-button
>
<app-button variant="subtle" (click)="removed.emit()" i18n="@@brief.block.remove"
>Verwijderen</app-button
>
</span>
}
</div>
<app-rich-text-editor
[content]="block().content"
[placeholders]="placeholders()"
[editable]="editable()"
(contentChanged)="contentChanged.emit($event)"
/>
</div>
`,
})
export class LetterBlockComponent {
block = input.required<LetterBlock>();
placeholders = input<readonly PlaceholderOption[]>([]);
editable = input(false);
contentChanged = output<RichTextBlock>();
removed = output<void>();
moved = output<-1 | 1>();
protected provenance = computed(() => {
const b = this.block();
if (b.type === 'freeText') return $localize`:@@brief.provenance.free:Vrije tekst`;
return b.edited
? $localize`:@@brief.provenance.edited:Aangepaste standaardtekst`
: $localize`:@@brief.provenance.standard:Standaardtekst`;
});
}
@@ -0,0 +1,47 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { LetterBlock } from '@brief/domain/brief';
import { LetterBlockComponent } from './letter-block.component';
const passageBlock: LetterBlock = {
type: 'passage',
blockId: 'local-1',
sourcePassageId: 'p1',
sourceVersion: 1,
edited: false,
content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Geachte heer/mevrouw,' }] }] },
};
const freeTextBlock: LetterBlock = {
type: 'freeText',
blockId: 'local-2',
content: {
paragraphs: [
{
nodes: [
{ type: 'text', text: 'Wij hebben besloten om reden ' },
{ type: 'placeholder', key: 'reden_besluit' },
{ type: 'text', text: '.' },
],
},
],
},
};
const placeholders = [{ key: 'reden_besluit', label: 'Reden besluit' }];
const meta: Meta<LetterBlockComponent> = {
title: 'Domein/Brief/Letter Block',
component: LetterBlockComponent,
args: {
block: passageBlock,
placeholders,
contentChanged: () => {},
removed: () => {},
moved: () => {},
},
};
export default meta;
type Story = StoryObj<LetterBlockComponent>;
export const ReadOnlyPassage: Story = { args: { editable: false } };
export const EditableFreeText: Story = { args: { block: freeTextBlock, editable: true } };
@@ -0,0 +1,463 @@
import {
Component,
DestroyRef,
ElementRef,
computed,
effect,
inject,
input,
linkedSignal,
output,
signal,
viewChild,
} from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { PlaceholderChipComponent } from '@shared/ui/placeholder-chip/placeholder-chip.component';
import { formatDatumNl } from '@shared/kernel/datum';
import { Paragraph } from '@shared/kernel/rich-text';
import { Brief, LetterBlock } from '@brief/domain/brief';
import { OrgTemplate } from '@brief/domain/org-template';
import { OrgTemplateTextField } from '@brief/domain/org-template.machine';
import { Diagnostic } from '@brief/domain/placeholders';
import { BlockDiffKind } from '@brief/domain/brief-diff';
/** A run of consecutive lines to render together: a list (bullet/number) or a single plain line. */
type PreviewSegment = {
readonly list: 'bullet' | 'number' | null;
readonly items: readonly Paragraph[];
};
function groupParagraphs(paras: readonly Paragraph[]): PreviewSegment[] {
const out: { list: 'bullet' | 'number' | null; items: Paragraph[] }[] = [];
for (const para of paras) {
const kind = para.list ?? null;
const last = out[out.length - 1];
if (kind && last && last.list === kind) last.items.push(para);
else out.push({ list: kind, items: [para] });
}
return out;
}
// Illustrative values for the "Voorbeeld" toggle — what send resolves server-side.
const SAMPLE_VALUES: Record<string, string> = {
naam_zorgverlener: 'J. Jansen',
big_nummer: '12345678901',
};
/** A4 height in CSS px (1in = 96px = 25.4mm) — for the approximate page-break marks. */
const A4_HEIGHT_PX = (297 * 96) / 25.4;
/** Organism: the letter as one surface — the org template's letterhead, signature and
footer around the case-type template's sections. `editableRegions` picks who edits
what: `'content'` hosts the editable letter-sections in place (drafter), `'none'`
renders everything read-only (approver/locked, absorbs the old letter-preview),
`'template'` reserves the org-identity regions for the admin editor (WP-26).
Letter typography/geometry come from the shared `public/letter.css` contract —
the same file the backend preview renderer inlines (WP-25). */
@Component({
selector: 'app-letter-canvas',
imports: [NgTemplateOutlet, ButtonComponent, PlaceholderChipComponent],
styles: [
`
:host {
display: block;
}
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--rhc-space-max-sm);
margin-block-end: var(--rhc-space-max-sm);
}
.zoom {
display: flex;
align-items: center;
gap: var(--rhc-space-max-sm);
}
.zoom-pct {
min-width: 3.5ch;
text-align: center;
color: var(--rhc-color-foreground-subtle);
font-variant-numeric: tabular-nums;
}
/* Rejection-diff badge (WP-27): a small pill above a changed/added block. */
.diff-block.diff-changed {
border-inline-start: 3px solid var(--rhc-color-oranje-500);
padding-inline-start: var(--rhc-space-max-sm);
}
.diff-badge {
display: inline-block;
margin-block-end: 1mm;
padding: 0 1.5mm;
border-radius: var(--rhc-border-radius-sm);
font-size: 7.5pt;
/* changed = dark text on oranje-500 (4.79:1); white on oranje-500 fails (3.23:1). */
color: var(--rhc-color-foreground-default);
background: var(--rhc-color-oranje-500);
}
.diff-badge.added {
/* added = white on groen-700 (6.4:1); dark text on any green fails 4.5:1 (WP-29 axe). */
color: var(--rhc-color-wit);
background: var(--rhc-color-groen-700);
}
/* Portal-side chrome around the letter surface (not part of the contract file). */
.surface {
background: var(--rhc-color-grijs-100);
border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);
border-radius: var(--rhc-border-radius-md);
padding: var(--rhc-space-max-lg);
overflow-x: auto;
}
.surface .letter {
box-shadow: 0 1px 4px rgb(0 0 0 / 0.15); /* token-ok: paper drop-shadow, not a palette colour */
}
/* Admin edit-in-place (editableRegions='template'): the org-identity fields
become controls styled to sit in the letter, with a visible editable affordance. */
.tmpl-input,
.tmpl-textarea {
font: inherit;
color: inherit;
width: 100%;
box-sizing: border-box;
background: var(--rhc-color-geel-100);
border: var(--rhc-border-width-sm) dashed var(--rhc-color-border-strong);
border-radius: var(--rhc-border-radius-sm);
padding: 0.5mm 1mm;
}
.tmpl-textarea {
resize: vertical;
}
.org-logo {
max-height: 20mm;
max-width: 60mm;
margin-block-end: 3mm;
}
`,
],
template: `
<ng-template #line let-nodes>
@for (node of nodes; track $index) {
@switch (node.type) {
@case ('text') {
<span>{{ node.text }}</span>
}
@case ('lineBreak') {
<br />
}
@case ('placeholder') {
@if (showSample() && autoFor(node.key)) {
<span>{{ sampleFor(node.key) }}</span>
} @else {
<app-placeholder-chip
[label]="labelFor(node.key)"
[autoResolvable]="autoFor(node.key)"
[state]="stateFor(node.key)"
/>
}
}
}
}
</ng-template>
@if (editableRegions() !== 'template') {
<div class="toolbar">
<div class="zoom" role="group" [attr.aria-label]="zoomGroupLabel()">
<app-button
variant="subtle"
[disabled]="zoomLevel() <= 0.5"
[attr.aria-label]="zoomOutLabel()"
(click)="zoomBy(-0.1)"
>−</app-button
>
<span class="zoom-pct">{{ zoomPct() }}</span>
<app-button
variant="subtle"
[disabled]="zoomLevel() >= 1.5"
[attr.aria-label]="zoomInLabel()"
(click)="zoomBy(0.1)"
>+</app-button
>
<app-button variant="subtle" (click)="zoomLevel.set(1)">{{
zoomResetLabel()
}}</app-button>
</div>
@if (editableRegions() === 'none') {
<app-button
variant="subtle"
(click)="showSample.set(!showSample())"
[attr.aria-pressed]="showSample()"
>
{{ showSample() ? hideSampleLabel() : showSampleLabel() }}
</app-button>
}
</div>
}
<div class="surface">
<div class="letter" #page [style]="marginStyle()" [style.zoom]="zoomLevel()">
<!-- div, not <header>/<footer>: the CIBG huisstijl styles those bare elements
(robijn footer background) — the letter surface must stay letter.css-only. -->
<div class="letter__letterhead">
@if (logoUrl()) {
<img class="org-logo" [src]="logoUrl()" [alt]="logoAlt()" />
}
@if (editing()) {
<input
class="tmpl-input org-wordmark"
[value]="orgTemplate().orgName"
[attr.aria-label]="orgNameLabel()"
(input)="emitEdit('orgName', $event)"
/>
<textarea
class="tmpl-textarea return-address"
rows="2"
[value]="orgTemplate().returnAddress"
[attr.aria-label]="returnAddressLabel()"
(input)="emitEdit('returnAddress', $event)"
></textarea>
} @else {
<p class="org-wordmark">{{ orgTemplate().orgName }}</p>
<address class="return-address">{{ orgTemplate().returnAddress }}</address>
}
<address class="address-window">{{ recipientText() }}</address>
<dl class="reference">
<div>
<dt>{{ referenceLabel() }}</dt>
<dd>{{ brief().briefId }}</dd>
</div>
<div>
<dt>{{ dateLabel() }}</dt>
<dd>{{ letterDate }}</dd>
</div>
</dl>
</div>
<div class="letter__body">
@for (section of brief().sections; track section.sectionKey) {
<section>
<h3>{{ section.title }}</h3>
@for (block of section.blocks; track block.blockId) {
@let diffKind = showDiff() ? blockDiffs().get(block.blockId) : undefined;
<div class="diff-block" [class.diff-changed]="!!diffKind">
@if (diffKind) {
<span class="diff-badge" [class.added]="diffKind === 'added'">{{
diffLabel(diffKind)
}}</span>
}
@for (seg of segmentsOf(block); track $index) {
@if (seg.list === 'bullet') {
<ul>
@for (para of seg.items; track $index) {
<li>
<ng-container
[ngTemplateOutlet]="line"
[ngTemplateOutletContext]="{ $implicit: para.nodes }"
/>
</li>
}
</ul>
} @else if (seg.list === 'number') {
<ol>
@for (para of seg.items; track $index) {
<li>
<ng-container
[ngTemplateOutlet]="line"
[ngTemplateOutletContext]="{ $implicit: para.nodes }"
/>
</li>
}
</ol>
} @else {
<p>
<ng-container
[ngTemplateOutlet]="line"
[ngTemplateOutletContext]="{ $implicit: seg.items[0].nodes }"
/>
</p>
}
}
</div>
}
</section>
}
</div>
<div class="letter__signature">
@if (editing()) {
<input
class="tmpl-input"
[value]="orgTemplate().signatureClosing"
[attr.aria-label]="signatureClosingLabel()"
(input)="emitEdit('signatureClosing', $event)"
/>
<input
class="tmpl-input signature-name"
[value]="orgTemplate().signatureName"
[attr.aria-label]="signatureNameLabel()"
(input)="emitEdit('signatureName', $event)"
/>
<input
class="tmpl-input"
[value]="orgTemplate().signatureRole"
[attr.aria-label]="signatureRoleLabel()"
(input)="emitEdit('signatureRole', $event)"
/>
} @else {
<p>{{ orgTemplate().signatureClosing }}</p>
<p class="signature-name">{{ orgTemplate().signatureName }}</p>
<p>{{ orgTemplate().signatureRole }}</p>
}
</div>
<div class="letter__footer">
@if (editing()) {
<textarea
class="tmpl-textarea footer-contact"
rows="2"
[value]="orgTemplate().footerContact"
[attr.aria-label]="footerContactLabel()"
(input)="emitEdit('footerContact', $event)"
></textarea>
<input
class="tmpl-input footer-legal"
[value]="orgTemplate().footerLegal"
[attr.aria-label]="footerLegalLabel()"
(input)="emitEdit('footerLegal', $event)"
/>
} @else {
<div class="footer-contact">{{ orgTemplate().footerContact }}</div>
<div class="footer-legal">{{ orgTemplate().footerLegal }}</div>
}
</div>
@for (top of pageBreaks(); track $index) {
<div class="letter__page-break" [style.top.px]="top" aria-hidden="true">
<span>{{ pageBreakCaption() }}</span>
</div>
}
</div>
</div>
`,
})
export class LetterCanvasComponent {
brief = input.required<Brief>();
orgTemplate = input.required<OrgTemplate>();
/** Who edits what on the surface: read-only ('none', the drafter preview + approver
view) or admin editor ('template', WP-26). Authoring moved to letter-editor. */
editableRegions = input<'template' | 'none'>('none');
diagnostics = input<readonly Diagnostic[]>([]);
/** Initial zoom; the in-canvas controls take over from here (WP-27). */
zoom = input(1);
/** Blocks changed/added/removed since the letter was rejected (WP-27); badged when
`showDiff` is on. Removed blocks aren't in the map's rendered set — they no longer
exist in the letter — the composer surfaces them as a count. */
blockDiffs = input<ReadonlyMap<string, BlockDiffKind>>(new Map());
showDiff = input(false);
/** The org logo's content URL (letterhead), or null when none is set. */
logoUrl = input<string | null>(null);
/** An in-place edit to an org-identity field (only in `editableRegions='template'`). */
templateEdit = output<{ field: OrgTemplateTextField; value: string }>();
showSampleLabel = input($localize`:@@brief.preview.showSample:Voorbeeld met testwaarden`);
hideSampleLabel = input($localize`:@@brief.preview.hideSample:Testwaarden verbergen`);
pageBreakCaption = input(
$localize`:@@brief.canvas.pageBreak:±pagina-einde — afdrukvoorbeeld is leidend`,
);
recipientText = input(
$localize`:@@brief.canvas.recipient:Adres van de geadresseerde\n(wordt ingevuld bij verzending)`,
);
referenceLabel = input($localize`:@@brief.canvas.reference:Ons kenmerk`);
dateLabel = input($localize`:@@brief.canvas.date:Datum`);
logoAlt = input($localize`:@@brief.canvas.logoAlt:Logo van de organisatie`);
orgNameLabel = input($localize`:@@brief.canvas.orgName:Organisatienaam`);
returnAddressLabel = input($localize`:@@brief.canvas.returnAddress:Retouradres`);
signatureClosingLabel = input($localize`:@@brief.canvas.signatureClosing:Afsluiting`);
signatureNameLabel = input($localize`:@@brief.canvas.signatureName:Naam ondertekenaar`);
signatureRoleLabel = input($localize`:@@brief.canvas.signatureRole:Functie ondertekenaar`);
footerContactLabel = input($localize`:@@brief.canvas.footerContact:Contactgegevens (voettekst)`);
footerLegalLabel = input($localize`:@@brief.canvas.footerLegal:Juridische voettekst`);
zoomGroupLabel = input($localize`:@@brief.canvas.zoom:Zoomniveau`);
zoomInLabel = input($localize`:@@brief.canvas.zoomIn:Inzoomen`);
zoomOutLabel = input($localize`:@@brief.canvas.zoomOut:Uitzoomen`);
zoomResetLabel = input($localize`:@@brief.canvas.zoomReset:100%`);
addedLabel = input($localize`:@@brief.diff.added:nieuw`);
changedLabel = input($localize`:@@brief.diff.changed:gewijzigd sinds afwijzing`);
protected showSample = signal(false);
protected letterDate = formatDatumNl(new Date());
/** Zoom seeded from the input; the +/−/reset controls drive it from there. */
protected zoomLevel = linkedSignal(() => this.zoom());
protected zoomPct = computed(() => `${Math.round(this.zoomLevel() * 100)}%`);
protected zoomBy(delta: number) {
// clamp 0.5–1.5; round to avoid float drift accumulating on repeated clicks.
this.zoomLevel.update((z) => Math.round(Math.min(1.5, Math.max(0.5, z + delta)) * 10) / 10);
}
protected diffLabel = (kind: BlockDiffKind) =>
kind === 'added' ? this.addedLabel() : this.changedLabel();
/** Admin edit-in-place: the org-identity regions render as controls. */
protected editing = computed(() => this.editableRegions() === 'template');
protected emitEdit(field: OrgTemplateTextField, event: Event) {
this.templateEdit.emit({
field,
value: (event.target as HTMLInputElement | HTMLTextAreaElement).value,
});
}
protected marginStyle = computed(() => {
const m = this.orgTemplate().margins;
return {
'--letter-margin-top': `${m.topMm}mm`,
'--letter-margin-right': `${m.rightMm}mm`,
'--letter-margin-bottom': `${m.bottomMm}mm`,
'--letter-margin-left': `${m.leftMm}mm`,
};
});
// --- read-only rendering helpers (migrated from the superseded letter-preview) ---
private defs = computed(() => new Map(this.brief().placeholders.map((p) => [p.key, p])));
private worst = computed(() => {
const m = new Map<string, 'error' | 'warning'>();
for (const d of this.diagnostics()) {
if (!d.placeholderKey) continue;
if (d.severity === 'error') m.set(d.placeholderKey, 'error');
else if (!m.has(d.placeholderKey)) m.set(d.placeholderKey, 'warning');
}
return m;
});
protected segmentsOf = (block: LetterBlock) => groupParagraphs(block.content.paragraphs);
protected labelFor = (key: string) => this.defs().get(key)?.label ?? key;
protected autoFor = (key: string) => this.defs().get(key)?.autoResolvable ?? false;
protected stateFor = (key: string): 'ok' | 'warning' | 'error' => this.worst().get(key) ?? 'ok';
protected sampleFor = (key: string) =>
SAMPLE_VALUES[key] ?? (key === 'datum' ? this.letterDate : this.labelFor(key));
// --- approximate page-break marks (PRD §2b: honest "±", print preview is leading) ---
private page = viewChild<ElementRef<HTMLElement>>('page');
protected pageBreaks = signal<readonly number[]>([]);
constructor() {
// ponytail: whole-surface height / A4-interval — ignores that a break never truly
// falls mid-line; the caption says "±" and WP-25's server preview is authoritative.
const observer = new ResizeObserver(([entry]) => {
// ~1cm tolerance so a letter ending on a page boundary gets no edge-hugging mark.
const pages = Math.ceil((entry.target.scrollHeight - 40) / A4_HEIGHT_PX);
this.pageBreaks.set(
Array.from({ length: Math.max(0, pages - 1) }, (_, i) => (i + 1) * A4_HEIGHT_PX),
);
});
effect((onCleanup) => {
const el = this.page()?.nativeElement;
if (!el) return;
observer.observe(el);
onCleanup(() => observer.unobserve(el));
});
inject(DestroyRef).onDestroy(() => observer.disconnect());
}
}
@@ -0,0 +1,163 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { Brief, allDiagnostics } from '@brief/domain/brief';
import { OrgTemplate } from '@brief/domain/org-template';
import { LetterCanvasComponent } from './letter-canvas.component';
const orgTemplate: OrgTemplate = {
subOrgId: 'cibg-registers',
orgName: 'CIBG — Registers',
returnAddress: 'BIG-register\nPostbus 00000\n2500 AA Den Haag',
footerContact: 'www.bigregister.nl\ninfo@voorbeeld.example\n070 000 00 00',
footerLegal: 'Ons kenmerk vermelden bij correspondentie.',
signatureName: 'A. de Vries',
signatureRole: 'Hoofd Registratie',
signatureClosing: 'Met vriendelijke groet,',
margins: { topMm: 25, rightMm: 25, bottomMm: 25, leftMm: 25 },
version: 1,
};
const brief: Brief = {
briefId: 'b1',
beroep: 'arts',
templateId: 't1',
drafterId: 'demo-drafter',
status: { tag: 'draft' },
placeholders: [
{ key: 'naam_zorgverlener', label: 'Naam zorgverlener', autoResolvable: true },
{ key: 'reden_besluit', label: 'Reden besluit', autoResolvable: false },
],
sections: [
{
sectionKey: 'aanhef',
title: 'Aanhef',
required: true,
locked: true,
blocks: [
{
type: 'passage',
blockId: 'local-1',
sourcePassageId: 'p1',
sourceVersion: 1,
edited: false,
content: {
paragraphs: [
{
nodes: [
{ type: 'text', text: 'Geachte heer/mevrouw ' },
{ type: 'placeholder', key: 'naam_zorgverlener' },
{ type: 'text', text: ',' },
],
},
],
},
},
],
},
{
sectionKey: 'kern',
title: 'Kern van het besluit',
required: true,
locked: false,
blocks: [
{
type: 'freeText',
blockId: 'local-2',
content: {
paragraphs: [
{
nodes: [
{ type: 'text', text: 'Wij hebben besloten om reden ' },
{ type: 'placeholder', key: 'reden_besluit' },
{ type: 'text', text: '.' },
],
},
],
},
},
],
},
],
};
/** Enough repeated body text to push the surface past one A4 page. */
const longBrief: Brief = {
...brief,
sections: brief.sections.map((s) =>
s.sectionKey === 'kern'
? {
...s,
blocks: [
{
type: 'freeText',
blockId: 'local-long',
content: {
paragraphs: Array.from({ length: 40 }, (_, i) => ({
nodes: [
{
type: 'text' as const,
text: `Alinea ${i + 1}: de beoordeling van uw aanvraag is uitgevoerd volgens de geldende regels voor herregistratie in het BIG-register.`,
},
],
})),
},
},
],
}
: s,
),
};
const meta: Meta<LetterCanvasComponent> = {
title: 'Domein/Brief/Letter Canvas',
component: LetterCanvasComponent,
args: {
brief,
orgTemplate,
diagnostics: allDiagnostics(brief),
},
};
export default meta;
type Story = StoryObj<LetterCanvasComponent>;
/** Read-only rendered letter: the drafter's preview modal and the approver view, with the
sample-values toggle and diagnostic placeholder chips (absorbs the old Letter Preview). */
export const ReadOnly: Story = { args: { editableRegions: 'none' } };
export const ReadOnlyZonderBevindingen: Story = {
args: { editableRegions: 'none', diagnostics: [] },
};
/** Admin editor focus (consumer arrives in WP-26): body read-only, no "not yours" tint. */
export const TemplateMode: Story = { args: { editableRegions: 'template' } };
export const Zoomed: Story = { args: { editableRegions: 'none', zoom: 0.6 } };
/** Approver's "Toon wijzigingen": blocks changed/added since rejection are badged (WP-27). */
export const WithDiff: Story = {
args: {
editableRegions: 'none',
diagnostics: [],
showDiff: true,
blockDiffs: new Map([
['local-1', 'added'],
['local-2', 'changed'],
]),
},
};
/** Long letter: the approximate ±page-break marks appear per A4 interval. */
export const PageBreak: Story = {
args: { editableRegions: 'none', brief: longBrief, diagnostics: [] },
};
// Inline SVG so the story needs no backend/upload round-trip (WP-26 logo upload).
const sampleLogo =
'data:image/svg+xml;utf8,' +
encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="120" height="40"><rect width="120" height="40" fill="#003366"/><text x="60" y="25" font-size="14" fill="white" text-anchor="middle">CIBG</text></svg>',
);
/** Published org logo (WP-26 AC2): the letterhead shows it above the org name. */
export const MetLogo: Story = {
args: { editableRegions: 'none', diagnostics: [], logoUrl: sampleLogo },
};
@@ -0,0 +1,210 @@
import { Component, computed, input, output, signal } from '@angular/core';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { StatusBadgeComponent } from '@shared/ui/status-badge/status-badge.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { Brief } from '@brief/domain/brief';
import { OrgTemplate } from '@brief/domain/org-template';
import { Diagnostic } from '@brief/domain/placeholders';
import { BlockDiffKind } from '@brief/domain/brief-diff';
import { LetterCanvasComponent } from '@brief/ui/letter-canvas/letter-canvas.component';
import { DiagnosticsPanelComponent } from '@brief/ui/diagnostics-panel/diagnostics-panel.component';
import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejection-comments.component';
/** Organism: the whole letter flow — status badge, the letter canvas (editable in
place for the drafter, read-only for approver/locked), the diagnostics panel, and
the action bar appropriate to status × permission. Presentational: emits edit +
transition intents; the canEdit/canApprove/canReject/canSend inputs are
server-computed decision flags (PRD-0002 phase P1) — this component never derives them. */
@Component({
selector: 'app-letter-composer',
imports: [
HeadingComponent,
StatusBadgeComponent,
ButtonComponent,
AlertComponent,
LetterCanvasComponent,
DiagnosticsPanelComponent,
RejectionCommentsComponent,
],
styles: [
`
:host {
display: block;
}
.head {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--rhc-space-max-md);
flex-wrap: wrap;
margin-block-end: var(--rhc-space-max-lg);
}
.head-end {
display: flex;
align-items: center;
gap: var(--rhc-space-max-md);
}
.panel {
margin-block: var(--rhc-space-max-xl);
}
.bar {
display: flex;
flex-wrap: wrap;
gap: var(--rhc-space-max-md);
align-items: center;
margin-block-start: var(--rhc-space-max-xl);
}
`,
],
template: `
<div class="head">
<app-heading [level]="2">{{ title() }}</app-heading>
<div class="head-end">
@if (hasRejectionDiff()) {
<app-button
variant="subtle"
[attr.aria-pressed]="showDiff()"
(click)="showDiff.set(!showDiff())"
>{{ showDiff() ? hideDiffLabel() : showDiffLabel() }}</app-button
>
}
<app-button variant="subtle" [disabled]="busy()" (click)="preview.emit()">{{
previewLabel()
}}</app-button>
<app-status-badge [label]="statusLabel()" [color]="statusColor()" />
</div>
</div>
@if (status() === 'rejected') {
<app-rejection-comments mode="show" [comments]="rejectComments()" />
}
@if (pureViewer()) {
<app-alert type="info">{{ readonlyNotice() }}</app-alert>
}
@if (showDiff() && removedCount() > 0) {
<app-alert type="info">{{ removedText() }}</app-alert>
}
<app-letter-canvas
[brief]="brief()"
[orgTemplate]="orgTemplate()"
[logoUrl]="logoUrl()"
[editableRegions]="'none'"
[diagnostics]="diagnostics()"
[blockDiffs]="blockDiffs()"
[showDiff]="showDiff()"
/>
<div class="panel">
<app-diagnostics-panel [diagnostics]="diagnostics()" (locate)="locate.emit($event)" />
</div>
<div class="bar">
@switch (status()) {
@case ('submitted') {
@if (canApprove() || canReject()) {
<app-button variant="primary" [disabled]="busy()" (click)="approve.emit()">{{
approveLabel()
}}</app-button>
<app-rejection-comments mode="entry" [busy]="busy()" (reject)="reject.emit($event)" />
} @else {
<app-alert type="info">{{ awaitingText() }}</app-alert>
}
}
@case ('approved') {
@if (canSend()) {
<app-button variant="primary" [disabled]="busy()" (click)="send.emit()">{{
sendLabel()
}}</app-button>
}
}
@case ('sent') {
<app-alert type="ok">{{ sentText() }}</app-alert>
}
}
</div>
`,
})
export class LetterComposerComponent {
brief = input.required<Brief>();
orgTemplate = input.required<OrgTemplate>();
logoUrl = input<string | null>(null);
diagnostics = input<readonly Diagnostic[]>([]);
canApprove = input(false);
canReject = input(false);
canSend = input(false);
busy = input(false);
/** Rejection diff (WP-27): the changed/added/removed blocks and their count. The
"Toon wijzigingen" toggle only appears when there's something to show. */
blockDiffs = input<ReadonlyMap<string, BlockDiffKind>>(new Map());
removedCount = input(0);
protected hasRejectionDiff = computed(() => this.blockDiffs().size > 0);
protected showDiff = signal(false);
approve = output<void>();
reject = output<string>();
send = output<void>();
preview = output<void>();
locate = output<Diagnostic>();
title = input($localize`:@@brief.title:Brief aan de zorgverlener`);
previewLabel = input($localize`:@@brief.preview.open:Voorbeeld`);
showDiffLabel = input($localize`:@@brief.diff.show:Toon wijzigingen`);
hideDiffLabel = input($localize`:@@brief.diff.hide:Verberg wijzigingen`);
removedText = computed(
() =>
$localize`:@@brief.diff.removed:${this.removedCount()}:count: blok(ken) verwijderd sinds afwijzing.`,
);
approveLabel = input($localize`:@@brief.approve:Goedkeuren`);
sendLabel = input($localize`:@@brief.send:Versturen`);
awaitingText = input(
$localize`:@@brief.awaiting:De brief wacht op beoordeling door een collega.`,
);
sentText = input($localize`:@@brief.sent:De brief is verzonden.`);
protected status = computed(() => this.brief().status.tag);
/** A pure viewer has no action on this letter (not the behandelaar, not an approver with
approve/reject/send) — e.g. an admin. Show a notice so the read-only letter isn't
mistaken for a broken editor. */
protected pureViewer = computed(() => !this.canApprove() && !this.canReject() && !this.canSend());
readonlyNotice = input(
$localize`:@@brief.readonlyNotice:Alleen-lezen weergave. De behandelaar stelt de brief op.`,
);
protected rejectComments = computed(() => {
const s = this.brief().status;
return s.tag === 'rejected' ? s.comments : '';
});
protected statusLabel = computed(() => {
switch (this.status()) {
case 'draft':
return $localize`:@@brief.status.draft:Concept`;
case 'submitted':
return $localize`:@@brief.status.submitted:Ter beoordeling`;
case 'approved':
return $localize`:@@brief.status.approved:Goedgekeurd`;
case 'rejected':
return $localize`:@@brief.status.rejected:Afgewezen`;
case 'sent':
return $localize`:@@brief.status.sent:Verzonden`;
}
});
protected statusColor = computed(() => {
switch (this.status()) {
case 'draft':
return 'var(--rhc-color-border-strong)';
case 'submitted':
return 'var(--rhc-color-oranje-500)';
case 'approved':
case 'sent':
return 'var(--rhc-color-groen-500)';
case 'rejected':
return 'var(--rhc-color-rood-500)';
}
});
}
@@ -0,0 +1,222 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { LetterComposerComponent } from './letter-composer.component';
import { Brief, BriefDecisions, BriefStatus, LibraryPassage } from '@brief/domain/brief';
import { allDiagnostics } from '@brief/domain/brief';
import { OrgTemplate } from '@brief/domain/org-template';
import { BlockDiffKind } from '@brief/domain/brief-diff';
const orgTemplate: OrgTemplate = {
subOrgId: 'cibg-registers',
orgName: 'CIBG — Registers',
returnAddress: 'BIG-register\nPostbus 00000\n2500 AA Den Haag',
footerContact: 'www.bigregister.nl\ninfo@voorbeeld.example',
footerLegal: 'Ons kenmerk vermelden bij correspondentie.',
signatureName: 'A. de Vries',
signatureRole: 'Hoofd Registratie',
signatureClosing: 'Met vriendelijke groet,',
margins: { topMm: 25, rightMm: 25, bottomMm: 25, leftMm: 25 },
version: 1,
};
const passages: LibraryPassage[] = [
{
passageId: 'p1',
scope: 'global',
sectionKey: 'aanhef',
label: 'Standaard aanhef',
version: 1,
content: {
paragraphs: [
{
nodes: [
{ type: 'text', text: 'Geachte heer/mevrouw ' },
{ type: 'placeholder', key: 'naam_zorgverlener' },
{ type: 'text', text: ',' },
],
},
],
},
},
{
passageId: 'p2',
scope: 'beroep',
beroep: 'arts',
sectionKey: 'kern',
label: 'Toelichting arts',
version: 1,
content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Als arts ...' }] }] },
},
];
function brief(status: BriefStatus): Brief {
return {
briefId: 'b1',
beroep: 'arts',
templateId: 't1',
drafterId: 'demo-drafter',
status,
placeholders: [
{ key: 'naam_zorgverlener', label: 'Naam zorgverlener', autoResolvable: true },
{ key: 'reden_besluit', label: 'Reden besluit', autoResolvable: false },
],
sections: [
{
sectionKey: 'aanhef',
title: 'Aanhef',
required: true,
locked: true,
blocks: [
{
type: 'passage',
blockId: 'local-1',
sourcePassageId: 'p1',
sourceVersion: 1,
edited: false,
content: passages[0].content,
},
],
},
{
sectionKey: 'kern',
title: 'Kern van het besluit',
required: true,
locked: false,
blocks: [
{
type: 'freeText',
blockId: 'local-2',
content: {
paragraphs: [
{
nodes: [
{ type: 'text', text: 'Wij hebben besloten om reden ' },
{ type: 'placeholder', key: 'reden_besluit' },
{ type: 'text', text: '.' },
],
},
],
},
},
],
},
{
sectionKey: 'slot',
title: 'Slot',
required: false,
locked: true,
blocks: [
{
type: 'freeText',
blockId: 'local-3',
content: {
paragraphs: [{ nodes: [{ type: 'text', text: 'Met vriendelijke groet,' }] }],
},
},
],
},
],
};
}
const render = (
b: Brief,
decisions: BriefDecisions,
extra: {
blockDiffs?: ReadonlyMap<string, BlockDiffKind>;
removedCount?: number;
logoUrl?: string | null;
} = {},
) => ({
props: {
brief: b,
orgTemplate,
diagnostics: allDiagnostics(b),
...decisions,
busy: false,
blockDiffs: extra.blockDiffs ?? new Map<string, BlockDiffKind>(),
removedCount: extra.removedCount ?? 0,
logoUrl: extra.logoUrl ?? null,
},
template: `<app-letter-composer [brief]="brief" [orgTemplate]="orgTemplate" [logoUrl]="logoUrl"
[diagnostics]="diagnostics" [canApprove]="canApprove" [canReject]="canReject"
[canSend]="canSend" [busy]="busy" [blockDiffs]="blockDiffs"
[removedCount]="removedCount"></app-letter-composer>`,
});
const meta: Meta<LetterComposerComponent> = {
title: 'Domein/Brief/Letter Composer',
component: LetterComposerComponent,
};
export default meta;
type Story = StoryObj<LetterComposerComponent>;
export const SubmittedApprover: Story = {
render: () =>
render(brief({ tag: 'submitted', submittedBy: 'demo-drafter', submittedAt: '2026-07-01' }), {
canEdit: false,
canApprove: true,
canReject: true,
canSend: false,
canRevealBigNummer: false,
}),
};
export const ApprovedSender: Story = {
render: () =>
render(brief({ tag: 'approved', approvedBy: 'demo-approver', approvedAt: '2026-07-01' }), {
canEdit: false,
canApprove: false,
canReject: false,
canSend: true,
canRevealBigNummer: false,
}),
};
export const Sent: Story = {
render: () =>
render(brief({ tag: 'sent', sentAt: '2026-07-01' }), {
canEdit: false,
canApprove: false,
canReject: false,
canSend: false,
canRevealBigNummer: false,
}),
};
/** Approver's "Toon wijzigingen" (WP-27): a resubmitted letter with blocks changed,
added and removed since the last rejection. */
export const RejectionDiff: Story = {
render: () =>
render(
brief({
tag: 'submitted',
submittedBy: 'demo-drafter',
submittedAt: '2026-07-02',
}),
{
canEdit: false,
canApprove: true,
canReject: true,
canSend: false,
canRevealBigNummer: false,
},
{
blockDiffs: new Map<string, BlockDiffKind>([
['local-2', 'changed'],
['local-3', 'added'],
]),
removedCount: 1,
},
),
};
/** A pure viewer (e.g. admin) has no approve/reject/send capability on this letter —
the read-only notice, not a broken-looking editor. */
export const AlleenLezen: Story = {
render: () =>
render(brief({ tag: 'submitted', submittedBy: 'demo-drafter', submittedAt: '2026-07-01' }), {
canEdit: false,
canApprove: false,
canReject: false,
canSend: false,
canRevealBigNummer: false,
}),
};
@@ -0,0 +1,40 @@
import { Component, computed, input, output } from '@angular/core';
import { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component';
import { Brief } from '@brief/domain/brief';
import { BriefMsg } from '@brief/domain/brief.machine';
import { LetterSectionComponent } from '@brief/ui/letter-section/letter-section.component';
/** Organism: the lean authoring surface — just the editable letter sections and their
add/edit controls, no letterhead/signature/footer/zoom. The full rendered letter
(including the locked aanhef/slot) lives in the preview modal (see behandel-scherm),
so the drafter stays focused on composing the body they actually own. */
@Component({
selector: 'app-letter-editor',
imports: [LetterSectionComponent],
styles: [
`
:host {
display: grid;
gap: var(--rhc-space-max-xl);
}
`,
],
template: `
@for (section of editableSections(); track section.sectionKey) {
<app-letter-section
[section]="section"
[placeholders]="placeholders()"
[editable]="true"
(edit)="edit.emit($event)"
/>
}
`,
})
export class LetterEditorComponent {
brief = input.required<Brief>();
placeholders = input<readonly PlaceholderOption[]>([]);
edit = output<BriefMsg>();
/** Only unlocked sections are authored here; locked aanhef/slot appear in the preview. */
protected editableSections = computed(() => this.brief().sections.filter((s) => !s.locked));
}
@@ -0,0 +1,62 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { Brief } from '@brief/domain/brief';
import { LetterEditorComponent } from './letter-editor.component';
const brief: Brief = {
briefId: 'b1',
beroep: 'arts',
templateId: 't1',
drafterId: 'demo-drafter',
status: { tag: 'draft' },
placeholders: [{ key: 'reden_besluit', label: 'Reden besluit', autoResolvable: false }],
sections: [
{
sectionKey: 'aanhef',
title: 'Aanhef',
required: true,
locked: true,
blocks: [
{
type: 'freeText',
blockId: 'aanhef-1',
content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Geachte heer/mevrouw,' }] }] },
},
],
},
{
sectionKey: 'kern',
title: 'Kern van het besluit',
required: true,
locked: false,
blocks: [
{
type: 'freeText',
blockId: 'kern-1',
content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Wij hebben besloten...' }] }] },
},
],
},
],
};
const meta: Meta<LetterEditorComponent> = {
title: 'Domein/Brief/Letter Editor',
component: LetterEditorComponent,
args: { brief, placeholders: [] },
};
export default meta;
type Story = StoryObj<LetterEditorComponent>;
/** The lean authoring surface: only the editable sections (the kern); the locked
aanhef/slot are hidden here and appear only in the preview. */
export const Default: Story = {};
/** Empty kern: shows the empty-state hint plus the free-text action, no starter content. */
export const EmptyKern: Story = {
args: {
brief: {
...brief,
sections: brief.sections.map((s) => (s.sectionKey === 'kern' ? { ...s, blocks: [] } : s)),
},
},
};
@@ -0,0 +1,93 @@
import { Component, input, output } from '@angular/core';
import { RichTextBlock } from '@shared/kernel/rich-text';
import { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { LetterSection } from '@brief/domain/brief';
import { BriefMsg } from '@brief/domain/brief.machine';
import { LetterBlockComponent } from '@brief/ui/letter-block/letter-block.component';
/** Organism: one template section — its ordered blocks plus (when editable) the
add-free-text action. Standaardteksten enter the kern via the besluit panel, not a
per-section picker, so this only offers free text. Maps child events to `BriefMsg`s;
sections themselves can never be added/removed/reordered (no message exists for it). */
@Component({
selector: 'app-letter-section',
imports: [ButtonComponent, HeadingComponent, LetterBlockComponent],
styles: [
`
:host {
display: block;
}
.blocks {
display: grid;
gap: var(--rhc-space-max-lg);
margin-block: var(--rhc-space-max-md);
}
.actions {
display: flex;
flex-wrap: wrap;
gap: var(--rhc-space-max-sm);
}
.required {
color: var(--rhc-color-foreground-subtle);
}
.empty {
color: var(--rhc-color-foreground-subtle);
font-style: italic;
}
`,
],
template: `
<app-heading [level]="3">
{{ section().title }}
@if (section().required) {
<span class="required">· {{ requiredLabel() }}</span>
}
</app-heading>
<div class="blocks">
@for (block of section().blocks; track block.blockId) {
<app-letter-block
[block]="block"
[placeholders]="placeholders()"
[editable]="editable()"
(contentChanged)="onContent(block.blockId, $event)"
(removed)="edit.emit({ tag: 'BlockRemoved', blockId: block.blockId })"
(moved)="onMove(block.blockId, $event)"
/>
} @empty {
<p class="empty">{{ emptyLabel() }}</p>
}
</div>
@if (editable()) {
<div class="actions">
<app-button
variant="subtle"
(click)="edit.emit({ tag: 'FreeTextBlockAdded', sectionKey: section().sectionKey })"
>{{ addFreeLabel() }}</app-button
>
</div>
}
`,
})
export class LetterSectionComponent {
section = input.required<LetterSection>();
placeholders = input<readonly PlaceholderOption[]>([]);
editable = input(false);
edit = output<BriefMsg>();
requiredLabel = input($localize`:@@brief.section.required:verplicht`);
emptyLabel = input($localize`:@@brief.section.empty:Nog geen tekst in deze sectie.`);
addFreeLabel = input($localize`:@@brief.section.addFree:Vrije tekst toevoegen`);
protected onContent(blockId: string, content: RichTextBlock) {
this.edit.emit({ tag: 'BlockContentEdited', blockId, content });
}
protected onMove(blockId: string, direction: -1 | 1) {
const i = this.section().blocks.findIndex((b) => b.blockId === blockId);
this.edit.emit({ tag: 'BlockMovedWithinSection', blockId, toIndex: i + direction });
}
}
@@ -0,0 +1,44 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { LetterSection } from '@brief/domain/brief';
import { LetterSectionComponent } from './letter-section.component';
const section: LetterSection = {
sectionKey: 'kern',
title: 'Kern van het besluit',
required: true,
locked: false,
blocks: [
{
type: 'freeText',
blockId: 'local-2',
content: {
paragraphs: [
{
nodes: [
{ type: 'text', text: 'Wij hebben besloten om reden ' },
{ type: 'placeholder', key: 'reden_besluit' },
{ type: 'text', text: '.' },
],
},
],
},
},
],
};
const emptySection: LetterSection = { ...section, blocks: [] };
const placeholders = [{ key: 'reden_besluit', label: 'Reden besluit' }];
const meta: Meta<LetterSectionComponent> = {
title: 'Domein/Brief/Letter Section',
component: LetterSectionComponent,
args: { section, placeholders, edit: () => {} },
};
export default meta;
type Story = StoryObj<LetterSectionComponent>;
export const ReadOnly: Story = { args: { editable: false } };
export const Editable: Story = { args: { editable: true } };
/** Empty section: shows the empty-state hint plus the free-text action. */
export const EditableEmpty: Story = { args: { section: emptySection, editable: true } };
@@ -0,0 +1,353 @@
import { Component, computed, input, output } from '@angular/core';
import { DatePipe } from '@angular/common';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { FileInputComponent } from '@shared/ui/upload/file-input/file-input.component';
import { SingleUploadComponent } from '@shared/ui/upload/single-upload/single-upload.component';
import { UploadState } from '@shared/upload/upload.machine';
import { Brief } from '@brief/domain/brief';
import {
MARGIN_MAX_MM,
MARGIN_MIN_MM,
Margins,
OrgTemplate,
OrgTemplateVersion,
SubOrgSummary,
} from '@brief/domain/org-template';
import { OrgTemplateTextField } from '@brief/domain/org-template.machine';
import { LetterCanvasComponent } from '@brief/ui/letter-canvas/letter-canvas.component';
const LOGO_CATEGORY = 'org-logo';
const EDGES: readonly (keyof Margins)[] = ['topMm', 'rightMm', 'bottomMm', 'leftMm'];
/** A minimal read-only sample letter, so the admin sees the org identity in context
while editing (content itself is not the admin's to change). */
export const SAMPLE_LETTER_BRIEF: Brief = {
briefId: 'VOORBEELD-0001',
beroep: 'arts',
templateId: 'sample',
drafterId: 'sample',
status: { tag: 'draft' },
placeholders: [
{ key: 'naam_zorgverlener', label: 'Naam zorgverlener', autoResolvable: true },
{ key: 'datum', label: 'Datum', autoResolvable: true },
],
sections: [
{
sectionKey: 'body',
title: 'Voorbeeldinhoud',
required: true,
locked: true,
blocks: [
{
type: 'freeText',
blockId: 'sample-1',
content: {
paragraphs: [
{
nodes: [
{ type: 'text', text: 'Geachte ' },
{ type: 'placeholder', key: 'naam_zorgverlener' },
{ type: 'text', text: ',' },
],
},
{
nodes: [
{
type: 'text',
text: 'Dit is voorbeeldinhoud. Alleen de huisstijl-onderdelen (logo, afzender, ondertekening en voettekst) zijn hier bewerkbaar.',
},
],
},
],
},
},
],
},
],
};
/**
* Organism (WP-26): the admin org-template editor. The mirror of the drafter's
* composer — the letter canvas runs in `editableRegions='template'` so the
* letterhead/signature/footer are edited in place, while the content is a read-only
* sample. Margins, logo upload, version history and the publish bar sit around it.
* Presentational: every mutation is an output the store turns into a command.
*/
@Component({
selector: 'app-org-template-editor',
imports: [
DatePipe,
HeadingComponent,
ButtonComponent,
AlertComponent,
FileInputComponent,
SingleUploadComponent,
LetterCanvasComponent,
],
styles: [
`
:host {
display: block;
}
.toolbar {
display: flex;
justify-content: space-between;
align-items: end;
gap: var(--rhc-space-max-md);
flex-wrap: wrap;
margin-block-end: var(--rhc-space-max-lg);
}
.field {
display: flex;
flex-direction: column;
gap: var(--rhc-space-max-2xs);
}
.save {
color: var(--rhc-color-foreground-subtle);
font-size: 0.9em;
}
.section {
margin-block-start: var(--rhc-space-max-xl);
}
.margins {
display: flex;
gap: var(--rhc-space-max-md);
flex-wrap: wrap;
border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);
border-radius: var(--rhc-border-radius-md);
padding: var(--rhc-space-max-md);
}
.margins input {
width: 6rem;
}
.history-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--rhc-space-max-sm);
}
.history-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--rhc-space-max-md);
border-block-end: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);
padding-block-end: var(--rhc-space-max-sm);
}
.bar {
display: flex;
flex-wrap: wrap;
gap: var(--rhc-space-max-md);
align-items: center;
margin-block-start: var(--rhc-space-max-xl);
}
.published {
color: var(--rhc-color-foreground-subtle);
}
`,
],
template: `
<div class="toolbar">
<label class="field">
<span>{{ subOrgLabel() }}</span>
<select class="form-select" (change)="onSelectSubOrg($event)">
@for (o of subOrgs(); track o.subOrgId) {
<option [value]="o.subOrgId" [selected]="o.subOrgId === selectedSubOrgId()">
{{ o.orgName }}
</option>
}
</select>
</label>
<span class="save" role="status" aria-live="polite">{{ saveText() }}</span>
</div>
<app-letter-canvas
[brief]="sampleBrief()"
[orgTemplate]="draft()"
[logoUrl]="logoUrl()"
editableRegions="template"
(templateEdit)="templateEdit.emit($event)"
/>
<fieldset class="section margins">
<legend>{{ marginsLegend() }}</legend>
@for (edge of edges; track edge) {
<label class="field">
<span>{{ edgeLabel(edge) }}</span>
<input
class="form-control"
type="number"
[min]="MIN"
[max]="MAX"
[value]="draft().margins[edge]"
(input)="onMargin(edge, $event)"
/>
</label>
}
</fieldset>
<section class="section">
<app-heading [level]="3">{{ logoHeading() }}</app-heading>
@if (logoCategory()) {
<app-file-input
inputId="org-logo-input"
[accept]="logoCategory()!.acceptedTypes"
[maxSizeMb]="logoCategory()!.maxSizeMb"
[label]="logoHeading()"
(filesSelected)="logoSelected.emit($event)"
/>
}
@if (logoRejection()) {
<app-alert type="error">{{ logoRejection() }}</app-alert>
}
@if (logoUploads().length) {
<ul class="file-list">
@for (u of logoUploads(); track u.localId) {
<li
app-single-upload
[upload]="u"
[previewUrlFor]="previewUrlFor()"
(remove)="logoRemoved.emit(u.localId)"
(retry)="logoRetry.emit(u.localId)"
></li>
}
</ul>
}
</section>
<section class="section">
<app-heading [level]="3">{{ historyHeading() }}</app-heading>
@if (history().length === 0) {
<p class="published">{{ noHistory() }}</p>
} @else {
<ul class="history-list">
@for (v of history(); track v.version) {
<li class="history-row">
<span
>{{ versionLabel() }} {{ v.version }} · {{ v.publishedAt | date: 'longDate' }}</span
>
<app-button variant="subtle" [disabled]="busy()" (click)="rollback.emit(v.version)">
{{ rollbackLabel() }}
</app-button>
</li>
}
</ul>
}
</section>
<div class="bar">
<span class="published">{{ publishedLabel() }} {{ publishedVersion() }}</span>
@if (pendingPublish()) {
<app-alert type="warning">{{ impactText() }}</app-alert>
<app-button variant="primary" [disabled]="busy()" (click)="confirmPublish.emit()">
{{ confirmLabel() }}
</app-button>
<app-button variant="subtle" [disabled]="busy()" (click)="cancelPublish.emit()">
{{ cancelLabel() }}
</app-button>
} @else {
<app-button
variant="primary"
[disabled]="!draftValid() || busy()"
(click)="requestPublish.emit()"
>
{{ publishLabel() }}
</app-button>
@if (!draftValid()) {
<span class="published">{{ invalidHint() }}</span>
}
}
<app-button variant="secondary" [disabled]="busy()" (click)="proefbrief.emit()">
{{ proefbriefLabel() }}
</app-button>
</div>
`,
})
export class OrgTemplateEditorComponent {
draft = input.required<OrgTemplate>();
logoUrl = input<string | null>(null);
uploadState = input.required<UploadState>();
subOrgs = input<readonly SubOrgSummary[]>([]);
selectedSubOrgId = input<string | null>(null);
history = input<readonly OrgTemplateVersion[]>([]);
publishedVersion = input(0);
unsentBriefs = input(0);
draftValid = input(false);
busy = input(false);
pendingPublish = input(false);
saveText = input('');
sampleBrief = input<Brief>(SAMPLE_LETTER_BRIEF);
previewUrlFor = input<(documentId: string) => string | undefined>();
selectSubOrg = output<string>();
templateEdit = output<{ field: OrgTemplateTextField; value: string }>();
marginEdit = output<{ edge: keyof Margins; value: number }>();
logoSelected = output<File[]>();
logoRemoved = output<string>();
logoRetry = output<string>();
requestPublish = output<void>();
confirmPublish = output<void>();
cancelPublish = output<void>();
rollback = output<number>();
proefbrief = output<void>();
protected readonly edges = EDGES;
protected readonly MIN = MARGIN_MIN_MM;
protected readonly MAX = MARGIN_MAX_MM;
protected logoCategory = computed(() =>
this.uploadState().categories.find((c) => c.categoryId === LOGO_CATEGORY),
);
protected logoUploads = computed(() =>
this.uploadState().uploads.filter((u) => u.categoryId === LOGO_CATEGORY),
);
protected logoRejection = computed(() => this.uploadState().rejections[LOGO_CATEGORY]);
protected onSelectSubOrg(event: Event) {
this.selectSubOrg.emit((event.target as HTMLSelectElement).value);
}
protected onMargin(edge: keyof Margins, event: Event) {
const value = (event.target as HTMLInputElement).valueAsNumber;
if (Number.isFinite(value)) this.marginEdit.emit({ edge, value });
}
protected edgeLabel(edge: keyof Margins): string {
switch (edge) {
case 'topMm':
return $localize`:@@orgTemplate.margin.top:Boven (mm)`;
case 'rightMm':
return $localize`:@@orgTemplate.margin.right:Rechts (mm)`;
case 'bottomMm':
return $localize`:@@orgTemplate.margin.bottom:Onder (mm)`;
case 'leftMm':
return $localize`:@@orgTemplate.margin.left:Links (mm)`;
}
}
protected impactText = computed(
() =>
$localize`:@@orgTemplate.publish.impact:Dit raakt ${this.unsentBriefs()}:count: nog niet verzonden brieven. Publiceren?`,
);
protected subOrgLabel = input($localize`:@@orgTemplate.subOrg:Organisatieonderdeel`);
protected marginsLegend = input(
$localize`:@@orgTemplate.margins:Marges (mm, tussen ${MARGIN_MIN_MM}:min: en ${MARGIN_MAX_MM}:max:)`,
);
protected logoHeading = input($localize`:@@orgTemplate.logo:Logo`);
protected historyHeading = input($localize`:@@orgTemplate.history:Versiegeschiedenis`);
protected noHistory = input($localize`:@@orgTemplate.history.none:Nog niets gepubliceerd.`);
protected versionLabel = input($localize`:@@orgTemplate.version:Versie`);
protected rollbackLabel = input($localize`:@@orgTemplate.rollback:Terugzetten in concept`);
protected publishedLabel = input($localize`:@@orgTemplate.published:Gepubliceerde versie:`);
protected publishLabel = input($localize`:@@orgTemplate.publish:Publiceren`);
protected confirmLabel = input($localize`:@@orgTemplate.publish.confirm:Bevestigen`);
protected cancelLabel = input($localize`:@@orgTemplate.publish.cancel:Annuleren`);
protected proefbriefLabel = input($localize`:@@orgTemplate.proefbrief:Proefbrief`);
protected invalidHint = input(
$localize`:@@orgTemplate.invalid:Vul organisatienaam en ondertekenaar in; marges tussen ${MARGIN_MIN_MM}:min: en ${MARGIN_MAX_MM}:max: mm.`,
);
}
@@ -0,0 +1,104 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { OrgTemplateEditorComponent } from './org-template-editor.component';
import { OrgTemplate, OrgTemplateVersion, SubOrgSummary } from '@brief/domain/org-template';
import { UploadState, initialUpload } from '@shared/upload/upload.machine';
const draft: OrgTemplate = {
subOrgId: 'cibg-registers',
orgName: 'CIBG — Registers',
returnAddress: 'BIG-register\nPostbus 00000\n2500 AA Den Haag',
footerContact: 'www.bigregister.nl\ninfo@voorbeeld.example',
footerLegal: 'Ons kenmerk vermelden bij correspondentie.',
signatureName: 'A. de Vries',
signatureRole: 'Hoofd Registratie',
signatureClosing: 'Met vriendelijke groet,',
margins: { topMm: 25, rightMm: 25, bottomMm: 25, leftMm: 25 },
version: 3,
};
const subOrgs: SubOrgSummary[] = [
{ subOrgId: 'cibg-registers', orgName: 'CIBG — Registers', publishedVersion: 3 },
{ subOrgId: 'cibg-vakbekwaamheid', orgName: 'CIBG — Vakbekwaamheid', publishedVersion: 1 },
];
const history: OrgTemplateVersion[] = [
{ version: 3, publishedAt: '2026-06-20', template: draft },
{ version: 2, publishedAt: '2026-05-11', template: draft },
];
const uploadWithCategory: UploadState = {
...initialUpload,
categories: [
{
categoryId: 'org-logo',
label: 'Logo',
description: 'Logo van de organisatie',
required: false,
acceptedTypes: ['image/png', 'image/jpeg'],
maxSizeMb: 2,
multiple: false,
allowPostDelivery: false,
},
],
};
const meta: Meta<OrgTemplateEditorComponent> = {
title: 'Domein/Brief/Org Template Editor',
component: OrgTemplateEditorComponent,
args: {
draft,
logoUrl: null,
uploadState: uploadWithCategory,
subOrgs,
selectedSubOrgId: 'cibg-registers',
history,
publishedVersion: 3,
unsentBriefs: 4,
draftValid: true,
busy: false,
pendingPublish: false,
saveText: '',
},
};
export default meta;
type Story = StoryObj<OrgTemplateEditorComponent>;
export const Editing: Story = {};
export const PublishConfirm: Story = {
args: { pendingPublish: true },
};
export const Invalid: Story = {
args: {
draft: { ...draft, orgName: '', margins: { ...draft.margins, topMm: 5 } },
draftValid: false,
},
};
export const NoHistory: Story = {
args: { history: [], publishedVersion: 0 },
};
// Inline SVG so the story needs no backend/upload round-trip.
const sampleLogo =
'data:image/svg+xml;utf8,' +
encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="120" height="40"><rect width="120" height="40" fill="#003366"/><text x="60" y="25" font-size="14" fill="white" text-anchor="middle">CIBG</text></svg>',
);
/** Published logo (WP-26 AC2): the letterhead canvas shows it above the org name. */
export const MetLogo: Story = {
args: { logoUrl: sampleLogo },
};
/** Client-side upload rejection (existing `rejectReason`, WP-26 AC5) — type/size caught
before the file ever reaches the backend. */
export const LogoUploadFout: Story = {
args: {
uploadState: {
...uploadWithCategory,
rejections: { 'org-logo': 'Alleen PNG of JPEG, maximaal 2 MB.' },
},
},
};
@@ -0,0 +1,132 @@
import { Component, computed, effect, inject } from '@angular/core';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { ASYNC } from '@shared/ui/async/async.component';
import { AccessStore } from '@shared/application/access.store';
import { UploadAdapter } from '@shared/upload/upload.adapter';
import { OrgTemplateStore } from '@brief/application/org-template.store';
import { OrgTemplateEditorComponent } from '@brief/ui/org-template-editor/org-template-editor.component';
/** Page: thin container for the admin org-template editor (WP-26). Deny-by-default
capability gate (`orgtemplate:edit`) — a denial alert for non-admins, the editor
for admins. Loads once the capability resolves; wires store commands to the organism. */
@Component({
selector: 'app-org-template-page',
imports: [
PageShellComponent,
AlertComponent,
ButtonComponent,
...ASYNC,
OrgTemplateEditorComponent,
],
styles: [
`
.save {
color: var(--rhc-color-foreground-subtle);
font-size: 0.9em;
}
`,
],
template: `
<app-page-shell [heading]="heading" [intro]="intro" backLink="/brief">
@if (store.lastError(); as err) {
<app-alert type="error">{{ err }}</app-alert>
}
@if (!access.ready()) {
<!-- wait for /me before deciding — avoids flashing the denial to an admin -->
} @else if (!canEdit()) {
<app-alert type="error">{{ deniedText }}</app-alert>
} @else {
<app-async [data]="store.remoteData()">
<ng-template appAsyncError>
<app-alert type="error">{{ failedText }}</app-alert>
<app-button variant="secondary" (click)="reload()">{{ retryText }}</app-button>
</ng-template>
<ng-template appAsyncLoaded>
@if (store.draft(); as draft) {
<app-org-template-editor
[draft]="draft"
[logoUrl]="store.logoUrl()"
[uploadState]="store.uploadState()"
[subOrgs]="store.subOrgs()"
[selectedSubOrgId]="store.selectedSubOrgId()"
[history]="store.history()"
[publishedVersion]="store.publishedVersion()"
[unsentBriefs]="store.unsentBriefs()"
[draftValid]="store.draftValid()"
[busy]="store.busy()"
[pendingPublish]="store.pendingPublish()"
[saveText]="saveText()"
[previewUrlFor]="previewUrlFor"
(selectSubOrg)="store.selectSubOrg($event)"
(templateEdit)="
store.edit({ tag: 'FieldEdited', field: $event.field, value: $event.value })
"
(marginEdit)="
store.edit({ tag: 'MarginEdited', edge: $event.edge, value: $event.value })
"
(logoSelected)="store.onLogoSelected($event)"
(logoRemoved)="store.onLogoRemoved($event)"
(logoRetry)="store.onLogoRetry($event)"
(requestPublish)="store.requestPublish()"
(confirmPublish)="store.confirmPublish()"
(cancelPublish)="store.cancelPublish()"
(rollback)="store.rollback($event)"
(proefbrief)="store.proefbrief()"
/>
}
</ng-template>
</app-async>
}
</app-page-shell>
`,
})
export class OrgTemplatePage {
protected store = inject(OrgTemplateStore);
protected access = inject(AccessStore);
private uploadAdapter = inject(UploadAdapter);
protected canEdit = computed(() => this.access.can('orgtemplate:edit'));
protected previewUrlFor = (documentId: string) => this.uploadAdapter.contentUrl(documentId);
protected heading = $localize`:@@orgTemplate.page.heading:Huisstijl beheren`;
protected intro = $localize`:@@orgTemplate.page.intro:Beheer per organisatieonderdeel het uiterlijk van de brief: logo, afzender, ondertekening, voettekst en marges.`;
protected deniedText = $localize`:@@orgTemplate.page.denied:U hebt geen rechten om organisatiesjablonen te beheren.`;
protected failedText = $localize`:@@orgTemplate.page.failed:Het sjabloon kon niet worden geladen.`;
protected retryText = $localize`:@@orgTemplate.page.retry:Opnieuw proberen`;
private savingText = $localize`:@@orgTemplate.page.saving:Concept opslaan…`;
private savedText = $localize`:@@orgTemplate.page.saved:Concept opgeslagen`;
private saveErrorText = $localize`:@@orgTemplate.page.saveError:Opslaan mislukt`;
protected saveText = computed(() => {
switch (this.store.saveState().tag) {
case 'Saving':
return this.savingText;
case 'Saved':
return this.savedText;
case 'Error':
return this.saveErrorText;
default:
return '';
}
});
private loadRequested = false;
constructor() {
// Load once the capability resolves to `allowed` (a 403 GET would be wasted
// otherwise). Depends only on `canEdit()` + a plain flag — never on the store
// model, so dispatching `Loading` inside `load()` can't retrigger this effect.
effect(() => {
if (this.canEdit() && !this.loadRequested) {
this.loadRequested = true;
void this.store.load();
}
});
}
protected reload() {
void this.store.load();
}
}
@@ -0,0 +1,111 @@
import { Component, computed, input, output, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { CheckboxComponent } from '@shared/ui/checkbox/checkbox.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
import { textOf } from '@shared/kernel/rich-text';
import { LibraryPassage } from '@brief/domain/brief';
/** Molecule: multi-select list of the section's library passages. One "Voeg toe"
inserts ALL checked passages at once (a single message upstream) — there is no
single-insert path. Presentational: emits the chosen passages in list order.
Superseded by `besluit-panel` (WP-27's guided drafting): no consumer left in
`src/app` outside its own story (WP-28 audit). Kept for now rather than deleted
in-flight of an unrelated WP; a future cleanup can remove it. */
@Component({
selector: 'app-passage-picker',
imports: [FormsModule, CheckboxComponent, ButtonComponent, TextInputComponent],
styles: [
`
:host {
display: block;
background: var(--rhc-color-wit);
border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);
border-radius: var(--rhc-border-radius-md);
padding: var(--rhc-space-max-md);
}
.search {
margin-block-end: var(--rhc-space-max-md);
}
ul {
list-style: none;
margin: 0 0 var(--rhc-space-max-md);
padding: 0;
display: grid;
gap: var(--rhc-space-max-sm);
}
.scope {
color: var(--rhc-color-foreground-subtle);
}
.empty {
color: var(--rhc-color-foreground-subtle);
font-style: italic;
margin: 0 0 var(--rhc-space-max-md);
}
`,
],
template: `
<div class="search">
<app-text-input
[placeholder]="searchLabel()"
[attr.aria-label]="searchLabel()"
[ngModel]="query()"
(ngModelChange)="query.set($event)"
/>
</div>
<ul>
@for (p of filtered(); track p.passageId) {
<li>
<app-checkbox
[checkboxId]="'passage-' + p.passageId"
[label]="p.label"
[ngModel]="!!checked()[p.passageId]"
(ngModelChange)="set(p.passageId, $event)"
/>
<span class="scope"> · {{ p.scope === 'beroep' ? beroepLabel() : globalLabel() }}</span>
</li>
} @empty {
<li class="empty">{{ noMatchLabel() }}</li>
}
</ul>
<app-button variant="secondary" [disabled]="count() === 0" (click)="add()"
>{{ addLabel() }} ({{ count() }})</app-button
>
`,
})
export class PassagePickerComponent {
passages = input.required<readonly LibraryPassage[]>();
insert = output<LibraryPassage[]>();
addLabel = input($localize`:@@brief.picker.add:Voeg toe`);
globalLabel = input($localize`:@@brief.picker.global:algemeen`);
beroepLabel = input($localize`:@@brief.picker.beroep:beroepsspecifiek`);
searchLabel = input($localize`:@@brief.picker.search:Zoek in standaardteksten…`);
noMatchLabel = input($localize`:@@brief.picker.noMatch:Geen standaardteksten gevonden.`);
protected checked = signal<Record<string, boolean>>({});
protected query = signal('');
/** Client-side filter on label + rendered content text — the library is small, so no
server search (WP-27). Placeholder keys are searchable too (see `textOf`). */
protected filtered = computed(() => {
const q = this.query().trim().toLowerCase();
if (!q) return this.passages();
return this.passages().filter(
(p) => p.label.toLowerCase().includes(q) || textOf(p.content).includes(q),
);
});
protected count = () => Object.values(this.checked()).filter(Boolean).length;
protected set(id: string, on: boolean) {
this.checked.update((c) => ({ ...c, [id]: on }));
}
protected add() {
const chosen = this.passages().filter((p) => this.checked()[p.passageId]);
if (chosen.length) {
this.insert.emit(chosen);
this.checked.set({});
}
}
}
@@ -0,0 +1,37 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { LibraryPassage } from '@brief/domain/brief';
import { PassagePickerComponent } from './passage-picker.component';
const passages: LibraryPassage[] = [
{
passageId: 'p1',
scope: 'global',
sectionKey: 'aanhef',
label: 'Standaard aanhef',
version: 1,
content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Geachte heer/mevrouw,' }] }] },
},
{
passageId: 'p2',
scope: 'beroep',
beroep: 'arts',
sectionKey: 'aanhef',
label: 'Aanhef, arts-specifiek',
version: 1,
content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Geachte collega,' }] }] },
},
];
const meta: Meta<PassagePickerComponent> = {
title: 'Domein/Brief/Passage Picker',
component: PassagePickerComponent,
render: (args) => ({
props: args,
template: `<app-passage-picker [passages]="passages" (insert)="insert($event)" />`,
}),
args: { passages, insert: () => {} },
};
export default meta;
type Story = StoryObj<PassagePickerComponent>;
export const Default: Story = {};
@@ -0,0 +1,57 @@
import { Component, input, output, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
/** Molecule: shows the rejection comments (drafter view) or collects them from the
approver. The approver rejects WITH comments; they never edit the letter. */
@Component({
selector: 'app-rejection-comments',
imports: [FormsModule, AlertComponent, ButtonComponent],
styles: [
`
:host {
display: block;
}
textarea {
inline-size: 100%;
box-sizing: border-box;
min-block-size: 4rem;
margin-block: var(--rhc-space-max-sm);
}
label {
font-weight: 600;
}
`,
],
template: `
@if (mode() === 'show') {
<app-alert type="warning"
><strong>{{ rejectedTitle() }}</strong> {{ comments() }}</app-alert
>
} @else {
<label for="reject-comments">{{ entryLabel() }}</label>
<textarea id="reject-comments" [(ngModel)]="draft"></textarea>
<app-button variant="danger" [disabled]="!draft().trim() || busy()" (click)="submit()">{{
rejectLabel()
}}</app-button>
}
`,
})
export class RejectionCommentsComponent {
mode = input<'show' | 'entry'>('show');
comments = input('');
busy = input(false);
reject = output<string>();
rejectedTitle = input($localize`:@@brief.reject.title:Afgewezen:`);
entryLabel = input($localize`:@@brief.reject.entryLabel:Reden van afwijzing`);
rejectLabel = input($localize`:@@brief.reject.button:Afwijzen`);
protected draft = signal('');
protected submit() {
const c = this.draft().trim();
if (c) this.reject.emit(c);
}
}
@@ -0,0 +1,16 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { RejectionCommentsComponent } from './rejection-comments.component';
const meta: Meta<RejectionCommentsComponent> = {
title: 'Domein/Brief/Rejection Comments',
component: RejectionCommentsComponent,
args: { reject: () => {} },
};
export default meta;
type Story = StoryObj<RejectionCommentsComponent>;
export const Show: Story = {
args: { mode: 'show', comments: 'Graag de aanhef formeler.' },
};
export const Entry: Story = { args: { mode: 'entry' } };
export const EntryBusy: Story = { args: { mode: 'entry', busy: true } };