feat(fp): WP-27 — brief UX layer (undo/redo, standaardbrief, passage search, diff badges)
CI / frontend (push) Failing after 1m15s
CI / storybook-a11y (push) Failing after 4m43s
CI / backend (push) Successful in 1m24s
CI / codeql (csharp) (push) Failing after 2m51s
CI / e2e (push) Failing after 3h4m8s
CI / codeql (javascript-typescript) (push) Failing after 1m30s
CI / api-client-drift (push) Successful in 1m53s

Brief letter-composition UX improvements:
- undo/redo history in the brief store (snapshot stacks, Ctrl/Cmd+Z) + retry-save
- "Standaardbrief invoegen" starter for empty sections; isDefault library passages
  (backend DTO/seed + adapter parse)
- passage-picker client-side search (rich-text textOf helper)
- rejection diff badges on the letter canvas + show/hide changes toggle
  (pure brief-diff domain fns + spec)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
eho
2026-07-20 14:36:32 +02:00
co-authored by Claude Opus 4.8
parent 950fb5f0b2
commit 5e36d68f11
21 changed files with 3537 additions and 1907 deletions
+1
View File
@@ -2,6 +2,7 @@
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "ng serve",
"type": "chrome",
@@ -139,7 +139,7 @@ public sealed record BriefStatusDto(
public sealed record LibraryPassageDto(
string PassageId, string Scope, string SectionKey, string Label,
RichTextBlockDto Content, int Version, string? Beroep = null);
RichTextBlockDto Content, int Version, string? Beroep = null, bool IsDefault = false);
public sealed record BriefDto(
string BriefId, string Beroep, string TemplateId,
@@ -226,10 +226,12 @@ public static class BriefSeed
{
new("p-aanhef-1", "global", "aanhef", "Standaard aanhef",
Block(T("Geachte heer/mevrouw "), P("naam_zorgverlener"), T(",")), 1),
// The "standaardbrief" kern starter set (IsDefault): one button drops these
// in when the kern is still empty (WP-27).
new("p-kern-1", "global", "kern", "Beoordeling ontvangen",
Block(T("Op "), P("datum"), T(" hebben wij uw aanvraag beoordeeld.")), 1),
Block(T("Op "), P("datum"), T(" hebben wij uw aanvraag beoordeeld.")), 1, IsDefault: true),
new("p-kern-arts", "beroep", "kern", "Toelichting arts",
Block(T("Als arts met BIG-nummer "), P("big_nummer"), T(" delen wij u het volgende mee.")), 1, Beroep: "arts"),
Block(T("Als arts met BIG-nummer "), P("big_nummer"), T(" delen wij u het volgende mee.")), 1, Beroep: "arts", IsDefault: true),
new("p-slot-1", "global", "slot", "Standaard slot",
Block(T("Met vriendelijke groet,")), 1),
// These reference a deprecated / not-fillable field so the linter's
+3
View File
@@ -1720,6 +1720,9 @@
"beroep": {
"type": "string",
"nullable": true
},
"isDefault": {
"type": "boolean"
}
},
"additionalProperties": false
+7 -7
View File
@@ -1,6 +1,6 @@
# WP-27 — Brief UX layer (undo/redo, standaardbrief, search, diff badges)
Status: todo
Status: done
Phase: 6 — Brief v2 (edit-on-the-letter, org templates, server-rendered preview)
## Why
@@ -62,16 +62,16 @@ pure function over two values. Say so in code comments and stories.
## Acceptance criteria
- [ ] Remove a block → Ctrl+Z restores it → Ctrl+Shift+Z re-removes; buttons mirror;
- [x] Remove a block → Ctrl+Z restores it → Ctrl+Shift+Z re-removes; buttons mirror;
history capped at 50; a new edit clears redo; restore re-triggers autosave.
- [ ] Empty kern + "Standaardbrief invoegen" → default passages inserted as one undo
- [x] Empty kern + "Standaardbrief invoegen" → default passages inserted as one undo
step; button gone once kern is non-empty.
- [ ] Search filters passages by label and content.
- [ ] Reject → edit → resubmit: approver toggles "Toon wijzigingen", changed/added/
- [x] Search filters passages by label and content.
- [x] Reject → edit → resubmit: approver toggles "Toon wijzigingen", changed/added/
removed blocks are badged (block granularity).
- [ ] Autosave failure shows "Niet opgeslagen — opnieuw proberen"; retry works;
- [x] Autosave failure shows "Niet opgeslagen — opnieuw proberen"; retry works;
content never lost locally.
- [ ] Full GREEN.
- [x] Full GREEN.
## Verification
+3004 -1877
View File
File diff suppressed because one or more lines are too long
+99 -1
View File
@@ -1,7 +1,7 @@
import { TestBed } from '@angular/core/testing';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { Result } from '@shared/kernel/fp';
import { Brief, BriefDecisions } from '@brief/domain/brief';
import { Brief, BriefDecisions, LetterBlock } from '@brief/domain/brief';
import { OrgTemplate } from '@brief/domain/org-template';
import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';
import { LetterPreviewAdapter } from '@brief/infrastructure/letter-preview.adapter';
@@ -105,6 +105,104 @@ describe('BriefStore action state (Idle | Busy | Failed)', () => {
});
});
// --- WP-27: undo/redo history + rejection diff ---
function block(id: string, text: string): LetterBlock {
return { type: 'freeText', blockId: id, content: { paragraphs: [{ nodes: [{ type: 'text', text }] }] } };
}
const kern = (blocks: LetterBlock[]) => ({
sectionKey: 'kern',
title: 'Kern',
required: true,
locked: false,
blocks,
});
const filledBrief: Brief = { ...brief, sections: [kern([block('local-1', 'x')])] };
const filledView: BriefView = { ...view, brief: filledBrief };
function loadedBrief(store: BriefStore): Brief {
const s = store.model();
if (s.tag !== 'loaded') throw new Error('not loaded');
return s.brief;
}
async function loadedStore(over: Partial<BriefAdapter> = {}): Promise<BriefStore> {
const ok = (v: BriefView): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: v });
const store = setup({ load: () => ok(filledView), save: () => ok(filledView), ...over });
await store.load();
return store;
}
describe('BriefStore undo/redo history', () => {
it('records an edit, undoes and redoes it; buttons mirror; a no-op edit is not recorded', async () => {
const store = await loadedStore();
expect(store.canUndo()).toBe(false);
store.edit({ tag: 'BlockRemoved', blockId: 'local-1' });
expect(loadedBrief(store).sections[0].blocks.length).toBe(0);
expect(store.canUndo()).toBe(true);
store.undo();
expect(loadedBrief(store).sections[0].blocks.length).toBe(1);
expect(store.canRedo()).toBe(true);
store.redo();
expect(loadedBrief(store).sections[0].blocks.length).toBe(0);
// A no-op edit (unknown block) changes nothing → leaves no dead history step.
store.undo(); // back to 1 block, redo available
store.edit({ tag: 'BlockRemoved', blockId: 'does-not-exist' });
expect(store.canRedo()).toBe(true); // future NOT cleared by a no-op
});
it('a new edit clears the redo future', async () => {
const store = await loadedStore();
store.edit({ tag: 'FreeTextBlockAdded', sectionKey: 'kern' });
store.undo();
expect(store.canRedo()).toBe(true);
store.edit({ tag: 'FreeTextBlockAdded', sectionKey: 'kern' });
expect(store.canRedo()).toBe(false);
});
it('caps history at 50 snapshots', async () => {
const store = await loadedStore();
for (let i = 0; i < 55; i++) store.edit({ tag: 'FreeTextBlockAdded', sectionKey: 'kern' });
let undos = 0;
while (store.canUndo()) {
store.undo();
undos++;
}
expect(undos).toBe(50);
});
});
describe('BriefStore rejection diff', () => {
it('captures the rejected letter and diffs a subsequent edit against it', async () => {
const submitted: Brief = { ...filledBrief, status: { tag: 'submitted', submittedBy: 'u', submittedAt: 't' } };
const rejected: Brief = {
...filledBrief,
status: { tag: 'rejected', rejectedBy: 'u2', rejectedAt: 't', comments: 'nee' },
};
const ok = (v: BriefView): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: v });
const store = setup({
load: () => ok({ ...filledView, brief: submitted }),
save: () => ok(filledView),
reject: () => ok({ ...filledView, brief: rejected }),
});
await store.load();
await store.reject('nee');
expect(store.hasRejectionDiff()).toBe(false); // nothing changed yet
store.edit({
tag: 'BlockContentEdited',
blockId: 'local-1',
content: { paragraphs: [{ nodes: [{ type: 'text', text: 'CHANGED' }] }] },
});
expect(store.blockDiffs().get('local-1')).toBe('changed');
expect(store.removedSinceReject()).toBe(0);
});
});
describe('BriefStore.previewLetter', () => {
// vi.spyOn reuses an existing spy (and its call history) if one is already on
// the property — window.open/URL.createObjectURL must be restored between tests.
+73 -1
View File
@@ -10,6 +10,7 @@ import {
unresolvedPlaceholders,
} from '@brief/domain/brief';
import { BriefMsg, BriefState, initial, reduce } from '@brief/domain/brief.machine';
import { BlockDiffKind, changedBlocks, diffBlocks } from '@brief/domain/brief-diff';
import { OrgTemplate } from '@brief/domain/org-template';
import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';
import { LetterPreviewAdapter } from '@brief/infrastructure/letter-preview.adapter';
@@ -52,6 +53,35 @@ export class BriefStore {
/** Surfaced autosave state for the indicator + aria-live region. */
readonly saveState = signal<SaveState>({ tag: 'Idle' });
/** Undo/redo is SHELL state, not machine state (WP-27): a stack of past/future
`Brief` snapshots. Each is a deep-frozen immutable value, so sharing is safe.
Only CONTENT edits are recorded (they flow through `edit()`); status transitions
never enter history, or undo would replay workflow state. Capped so a long session
can't grow unbounded. Restore re-dispatches the existing `Seed` Msg — zero machine
changes. */
private static readonly HISTORY_CAP = 50;
private past = signal<readonly Brief[]>([]);
private future = signal<readonly Brief[]>([]);
readonly canUndo = computed(() => this.past().length > 0);
readonly canRedo = computed(() => this.future().length > 0);
/** The letter as it stood when it was REJECTED, captured shell-side (WP-27). The
approver diffs it against the resubmitted letter. POC limit: in-memory only, so a
full page reload loses it — a real system would persist the rejected revision. */
private rejectionSnapshot = signal<Brief | null>(null);
/** Changed/added/removed blocks since rejection — a pure fold over two snapshots. */
readonly blockDiffs = computed<ReadonlyMap<string, BlockDiffKind>>(() => {
const before = this.rejectionSnapshot();
const after = this.brief();
return before && after ? changedBlocks(diffBlocks(before, after)) : new Map();
});
/** Count of blocks removed since rejection — badged as a summary, since a removed
block no longer renders inline. */
readonly removedSinceReject = computed(
() => [...this.blockDiffs().values()].filter((k) => k === 'removed').length,
);
readonly hasRejectionDiff = computed(() => this.blockDiffs().size > 0);
/** The org template the letter renders with (WP-24). Server-owned appearance data,
not letter state — held beside the machine, never inside it (`brief.machine.ts`
stays untouched by design). Set from every server view that carries it. */
@@ -104,18 +134,50 @@ export class BriefStore {
const r = await this.adapter.load();
if (r.ok) {
this.orgTemplate.set(r.value.orgTemplate);
this.clearHistory();
this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
} else {
this.store.dispatch({ tag: 'BriefLoadFailed', reason: r.error });
}
}
/** An edit: apply it optimistically in the pure reducer, then debounce-save. */
/** An edit: apply it optimistically in the pure reducer, then debounce-save. Records
an undo step only when the reducer actually changed the brief (a no-op edit — e.g.
a locked section — returns the same value and leaves no dead history step). */
edit(msg: BriefMsg) {
const before = this.brief();
this.store.dispatch(msg);
const after = this.brief();
if (before && after && after !== before) {
this.past.update((p) => [...p, before].slice(-BriefStore.HISTORY_CAP));
this.future.set([]);
}
this.scheduleSave();
}
/** Undo: restore the previous snapshot via the existing `Seed` Msg, push the current
onto the redo stack, then autosave. Redo is the mirror image. */
undo() {
this.step(this.past, this.future);
}
redo() {
this.step(this.future, this.past);
}
private step(from: typeof this.past, to: typeof this.future) {
const s = this.model();
const target = from().at(-1);
if (s.tag !== 'loaded' || !target) return;
from.update((x) => x.slice(0, -1));
to.update((x) => [...x, s.brief].slice(-BriefStore.HISTORY_CAP));
this.store.dispatch({ tag: 'Seed', state: { ...s, brief: target } });
this.scheduleSave();
}
private clearHistory() {
this.past.set([]);
this.future.set([]);
}
private saveTimer?: ReturnType<typeof setTimeout>;
private scheduleSave() {
if (!this.canEdit()) return;
@@ -136,6 +198,11 @@ export class BriefStore {
}
}
/** Retry a failed autosave — reuses the existing flush path, no new state (WP-27). */
retrySave() {
void this.flushSave();
}
/** Demo "start over": recreate the brief server-side and load the fresh view. */
async resetDemo() {
this.actionState.set({ tag: 'Busy' });
@@ -145,6 +212,8 @@ export class BriefStore {
if (r.ok) {
this.actionState.set({ tag: 'Idle' });
this.orgTemplate.set(r.value.orgTemplate);
this.clearHistory();
this.rejectionSnapshot.set(null);
this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
} else {
this.actionState.set({ tag: 'Failed', error: r.error });
@@ -198,6 +267,9 @@ export class BriefStore {
this.store.dispatch({ tag: 'Approved', by: s.approvedBy, at: s.approvedAt, decisions });
break;
case 'rejected':
// Capture the letter as-rejected for the resubmission diff (WP-27). This is the
// "before" snapshot the approver later compares against.
this.rejectionSnapshot.set(brief);
this.store.dispatch({
tag: 'Rejected',
by: s.rejectedBy,
+41
View File
@@ -0,0 +1,41 @@
import { describe, it, expect } from 'vitest';
import { Brief, LetterBlock } from './brief';
import { diffBlocks, changedBlocks } from './brief-diff';
function block(id: string, text: string): LetterBlock {
return { type: 'freeText', blockId: id, content: { paragraphs: [{ nodes: [{ type: 'text', text }] }] } };
}
function brief(blocks: LetterBlock[]): Brief {
return {
briefId: 'b1',
beroep: 'arts',
templateId: 't1',
placeholders: [],
sections: [{ sectionKey: 'kern', title: 'Kern', required: true, locked: false, blocks }],
status: { tag: 'draft' },
drafterId: 'u1',
};
}
describe('diffBlocks', () => {
it('marks added, removed, changed and unchanged by blockId', () => {
const before = brief([block('local-1', 'a'), block('local-2', 'b'), block('local-3', 'c')]);
const after = brief([block('local-1', 'a'), block('local-2', 'B!'), block('local-4', 'd')]);
const diffs = diffBlocks(before, after);
const byId = new Map(diffs.map((d) => [d.blockId, d.kind]));
expect(byId.get('local-1')).toBe('unchanged');
expect(byId.get('local-2')).toBe('changed');
expect(byId.get('local-3')).toBe('removed'); // gone from after
expect(byId.get('local-4')).toBe('added'); // new in after
});
it('changedBlocks drops unchanged and keeps added/removed/changed', () => {
const before = brief([block('local-1', 'a'), block('local-2', 'b')]);
const after = brief([block('local-1', 'a'), block('local-2', 'B'), block('local-3', 'c')]);
const map = changedBlocks(diffBlocks(before, after));
expect(map.has('local-1')).toBe(false);
expect(map.get('local-2')).toBe('changed');
expect(map.get('local-3')).toBe('added');
});
});
+50
View File
@@ -0,0 +1,50 @@
import { Brief, LetterBlock, allBlocks } from './brief';
/**
* The rejection diff as a PURE function over two immutable `Brief` values — the whole
* teaching payload of WP-27: because state is one value, "what changed since the letter
* was rejected" is just a fold over two snapshots, no change-tracking bookkeeping.
*
* Blocks are matched by `blockId` (stable `local-N`/seed ids):
* - in `after` but not `before` → `added`
* - in `before` but not `after` → `removed`
* - in both, different content → `changed`
* - in both, same content → `unchanged`
*/
export type BlockDiffKind = 'added' | 'removed' | 'changed' | 'unchanged';
export interface BlockDiff {
readonly blockId: string;
readonly kind: BlockDiffKind;
}
/** Content equality by value. Blocks are JSON-shaped immutable trees, so a canonical
stringify is an honest deep-equal here (no functions, no cycles). */
function contentEqual(a: LetterBlock, b: LetterBlock): boolean {
return JSON.stringify(a.content) === JSON.stringify(b.content);
}
export function diffBlocks(before: Brief, after: Brief): BlockDiff[] {
const beforeById = new Map(allBlocks(before).map((b) => [b.blockId, b]));
const afterById = new Map(allBlocks(after).map((b) => [b.blockId, b]));
const out: BlockDiff[] = [];
for (const a of afterById.values()) {
const b = beforeById.get(a.blockId);
out.push({
blockId: a.blockId,
kind: !b ? 'added' : contentEqual(a, b) ? 'unchanged' : 'changed',
});
}
for (const b of beforeById.values()) {
if (!afterById.has(b.blockId)) out.push({ blockId: b.blockId, kind: 'removed' });
}
return out;
}
/** Lookup of only the blocks that changed since rejection (drops `unchanged`), for
badging the canvas. Keyed by `blockId`; removed ids are present too (the caller
surfaces them as a count — a removed block no longer renders inline). */
export function changedBlocks(diffs: readonly BlockDiff[]): ReadonlyMap<string, BlockDiffKind> {
return new Map(diffs.filter((d) => d.kind !== 'unchanged').map((d) => [d.blockId, d.kind]));
}
+1
View File
@@ -25,6 +25,7 @@ export interface LibraryPassage {
readonly label: string;
readonly content: RichTextBlock;
readonly version: number; // library version, for provenance only
readonly isDefault?: boolean; // part of the "standaardbrief" (kern) starter set
}
/** A block inside a letter section: a frozen passage snapshot, or free text. */
@@ -249,6 +249,7 @@ function parsePassage(dto: LibraryPassageDto): Result<string, LibraryPassage> {
content: content.value,
version: dto.version,
...(dto.beroep != null ? { beroep: dto.beroep } : {}),
...(dto.isDefault ? { isDefault: true } : {}),
});
}
+48 -2
View File
@@ -12,6 +12,7 @@ import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-compos
@Component({
selector: 'app-brief-page',
imports: [PageShellComponent, AlertComponent, ButtonComponent, ...ASYNC, LetterComposerComponent],
host: { '(document:keydown)': 'onKey($event)' },
styles: [
`
.brief-toolbar {
@@ -21,6 +22,11 @@ import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-compos
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;
@@ -42,7 +48,30 @@ import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-compos
@if (loaded(); as s) {
@if (store.orgTemplate(); as orgTemplate) {
<div class="brief-toolbar">
<span class="save" role="status" aria-live="polite">{{ saveText() }}</span>
<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>
@@ -53,6 +82,8 @@ import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-compos
[logoUrl]="store.logoUrl()"
[availablePassages]="s.availablePassages"
[diagnostics]="store.diagnostics()"
[blockDiffs]="store.blockDiffs()"
[removedCount]="store.removedSinceReject()"
[canEdit]="store.canEdit()"
[canApprove]="store.canApprove()"
[canReject]="store.canReject()"
@@ -83,10 +114,13 @@ export class BriefPage {
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:Opslaan mislukt`;
private saveErrorText = $localize`:@@brief.page.saveError:Niet opgeslagen — opnieuw proberen`;
/** Debounced-save state, surfaced in a polite live region. */
protected saveText = computed(() => {
@@ -121,4 +155,16 @@ export class BriefPage {
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();
}
}
@@ -6,6 +6,7 @@ import {
effect,
inject,
input,
linkedSignal,
output,
signal,
viewChild,
@@ -21,6 +22,7 @@ import { OrgTemplate } from '@brief/domain/org-template';
import { OrgTemplateTextField } from '@brief/domain/org-template.machine';
import { Diagnostic } from '@brief/domain/placeholders';
import { BriefMsg } from '@brief/domain/brief.machine';
import { BlockDiffKind } from '@brief/domain/brief-diff';
import { LetterSectionComponent } from '@brief/ui/letter-section/letter-section.component';
/** A run of consecutive lines to render together: a list (bullet/number) or a single plain line. */
@@ -66,9 +68,40 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
}
.toolbar {
display: flex;
justify-content: flex-end;
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;
/* dark text, not white: oranje-500/groen-500 fail 4.5:1 contrast with white (WP-27 axe). */
color: var(--rhc-color-foreground-default);
background: var(--rhc-color-oranje-500);
}
.diff-badge.added {
background: var(--rhc-color-groen-500);
}
/* Portal-side chrome around the letter surface (not part of the contract file). */
.surface {
background: var(--rhc-color-grijs-100);
@@ -139,20 +172,40 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
}
</ng-template>
@if (editableRegions() === 'none') {
@if (editableRegions() !== 'template') {
<div class="toolbar">
<app-button
variant="subtle"
(click)="showSample.set(!showSample())"
[attr.aria-pressed]="showSample()"
>
{{ showSample() ? hideSampleLabel() : showSampleLabel() }}
</app-button>
<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]="zoom()">
<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" [class.from-template]="tintTemplate()">
@@ -211,7 +264,14 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
<section>
<h3>{{ section.title }}</h3>
@for (block of section.blocks; track block.blockId) {
@for (seg of segmentsOf(block); track $index) {
@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) {
@@ -242,7 +302,8 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
/>
</p>
}
}
}
</div>
}
</section>
}
@@ -315,7 +376,13 @@ export class LetterCanvasComponent {
availablePassages = input<readonly LibraryPassage[]>([]);
placeholders = input<readonly PlaceholderOption[]>([]);
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);
edit = output<BriefMsg>();
@@ -343,10 +410,26 @@ export class LetterCanvasComponent {
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.51.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();
/** The letterhead/signature/footer are tinted "not yours" only while composing —
in 'none' the whole surface is read-only, in 'template' they ARE the editable focus. */
protected tintTemplate = computed(() => this.editableRegions() === 'content');
@@ -148,6 +148,19 @@ 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: [] },
@@ -1,4 +1,4 @@
import { Component, computed, input, output } from '@angular/core';
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';
@@ -8,6 +8,7 @@ import { Brief, LibraryPassage } from '@brief/domain/brief';
import { OrgTemplate } from '@brief/domain/org-template';
import { Diagnostic } from '@brief/domain/placeholders';
import { BriefMsg } from '@brief/domain/brief.machine';
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';
@@ -62,6 +63,14 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
<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>
@@ -73,6 +82,10 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
<app-rejection-comments mode="show" [comments]="rejectComments()" />
}
@if (showDiff() && removedCount() > 0) {
<app-alert type="info">{{ removedText() }}</app-alert>
}
<app-letter-canvas
[brief]="brief()"
[orgTemplate]="orgTemplate()"
@@ -81,6 +94,8 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
[availablePassages]="availablePassages()"
[placeholders]="menu()"
[diagnostics]="diagnostics()"
[blockDiffs]="blockDiffs()"
[showDiff]="showDiff()"
(edit)="edit.emit($event)"
/>
@@ -149,6 +164,12 @@ export class LetterComposerComponent {
canSend = input(false);
canSubmit = 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);
edit = output<BriefMsg>();
submit = output<void>();
@@ -160,6 +181,12 @@ export class LetterComposerComponent {
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.`,
);
submitLabel = input($localize`:@@brief.submit:Indienen ter beoordeling`);
resubmitLabel = input($localize`:@@brief.resubmit:Opnieuw indienen`);
submitHint = input(
@@ -63,6 +63,11 @@ import { PassagePickerComponent } from '@brief/ui/passage-picker/passage-picker.
@if (editable()) {
<div class="actions">
@if (showStandardLetter()) {
<app-button variant="primary" (click)="insertStandardLetter()">{{
standardLetterLabel()
}}</app-button>
}
<app-button variant="secondary" (click)="pickerOpen.set(!pickerOpen())">{{
addPassageLabel()
}}</app-button>
@@ -89,11 +94,27 @@ export class LetterSectionComponent {
emptyLabel = input($localize`:@@brief.section.empty:Nog geen tekst in deze sectie.`);
addPassageLabel = input($localize`:@@brief.section.addPassage:Standaardtekst toevoegen`);
addFreeLabel = input($localize`:@@brief.section.addFree:Vrije tekst toevoegen`);
standardLetterLabel = input($localize`:@@brief.section.standardLetter:Standaardbrief invoegen`);
protected pickerOpen = signal(false);
protected sectionPassages = computed(() =>
this.availablePassages().filter((p) => p.sectionKey === this.section().sectionKey),
);
/** The "standaardbrief" starter set for this section (server-flagged defaults). */
protected defaultPassages = computed(() => this.sectionPassages().filter((p) => p.isDefault));
/** One-click starter, offered only while the section is still empty and defaults exist. */
protected showStandardLetter = computed(
() => this.section().blocks.length === 0 && this.defaultPassages().length > 0,
);
protected insertStandardLetter() {
// One Msg → one undo step (see brief.store `edit`).
this.edit.emit({
tag: 'PassagesInserted',
sectionKey: this.section().sectionKey,
passages: this.defaultPassages(),
});
}
protected onContent(blockId: string, content: RichTextBlock) {
this.edit.emit({ tag: 'BlockContentEdited', blockId, content });
@@ -36,6 +36,7 @@ const passages: LibraryPassage[] = [
sectionKey: 'kern',
label: 'Toelichting arts',
version: 1,
isDefault: true, // part of the standaardbrief starter set (WP-27)
content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Als arts ...' }] }] },
},
];
@@ -52,4 +53,5 @@ type Story = StoryObj<LetterSectionComponent>;
export const ReadOnly: Story = { args: { editable: false } };
export const Editable: Story = { args: { editable: true } };
/** Empty section: the one-click "Standaardbrief invoegen" starter appears (WP-27). */
export const EditableEmpty: Story = { args: { section: emptySection, editable: true } };
@@ -1,7 +1,9 @@
import { Component, input, output, signal } from '@angular/core';
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"
@@ -9,7 +11,7 @@ import { LibraryPassage } from '@brief/domain/brief';
single-insert path. Presentational: emits the chosen passages in list order. */
@Component({
selector: 'app-passage-picker',
imports: [FormsModule, CheckboxComponent, ButtonComponent],
imports: [FormsModule, CheckboxComponent, ButtonComponent, TextInputComponent],
styles: [
`
:host {
@@ -19,6 +21,9 @@ import { LibraryPassage } from '@brief/domain/brief';
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);
@@ -29,11 +34,24 @@ import { LibraryPassage } from '@brief/domain/brief';
.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 passages(); track p.passageId) {
@for (p of filtered(); track p.passageId) {
<li>
<app-checkbox
[checkboxId]="'passage-' + p.passageId"
@@ -43,6 +61,8 @@ import { LibraryPassage } from '@brief/domain/brief';
/>
<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()"
@@ -57,8 +77,20 @@ export class PassagePickerComponent {
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) {
@@ -1712,6 +1712,7 @@ export interface LibraryPassageDto {
content?: RichTextBlockDto;
version?: number;
beroep?: string | undefined;
isDefault?: boolean;
}
export interface ManualDiplomaPolicyDto {
+9
View File
@@ -49,6 +49,15 @@ export function deepCopyBlock(block: RichTextBlock): RichTextBlock {
return structuredClone(block) as RichTextBlock;
}
/** All visible text of a block as one lowercased string for client-side search over
passages. Placeholders contribute their key so "naam" matches a `naam_zorgverlener` chip. */
export function textOf(block: RichTextBlock): string {
return block.paragraphs
.flatMap((p) => p.nodes.map((n) => (n.type === 'text' ? n.text : n.type === 'placeholder' ? n.key : '')))
.join(' ')
.toLowerCase();
}
/** Every placeholder key used in a block, in document order (duplicates kept the
caller dedupes when it wants a set). */
export function placeholderKeysIn(block: RichTextBlock): string[] {