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 // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{ {
"name": "ng serve", "name": "ng serve",
"type": "chrome", "type": "chrome",
@@ -139,7 +139,7 @@ public sealed record BriefStatusDto(
public sealed record LibraryPassageDto( public sealed record LibraryPassageDto(
string PassageId, string Scope, string SectionKey, string Label, 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( public sealed record BriefDto(
string BriefId, string Beroep, string TemplateId, string BriefId, string Beroep, string TemplateId,
@@ -226,10 +226,12 @@ public static class BriefSeed
{ {
new("p-aanhef-1", "global", "aanhef", "Standaard aanhef", new("p-aanhef-1", "global", "aanhef", "Standaard aanhef",
Block(T("Geachte heer/mevrouw "), P("naam_zorgverlener"), T(",")), 1), 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", 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", 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", new("p-slot-1", "global", "slot", "Standaard slot",
Block(T("Met vriendelijke groet,")), 1), Block(T("Met vriendelijke groet,")), 1),
// These reference a deprecated / not-fillable field so the linter's // These reference a deprecated / not-fillable field so the linter's
+3
View File
@@ -1720,6 +1720,9 @@
"beroep": { "beroep": {
"type": "string", "type": "string",
"nullable": true "nullable": true
},
"isDefault": {
"type": "boolean"
} }
}, },
"additionalProperties": false "additionalProperties": false
+7 -7
View File
@@ -1,6 +1,6 @@
# WP-27 — Brief UX layer (undo/redo, standaardbrief, search, diff badges) # 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) Phase: 6 — Brief v2 (edit-on-the-letter, org templates, server-rendered preview)
## Why ## Why
@@ -62,16 +62,16 @@ pure function over two values. Say so in code comments and stories.
## Acceptance criteria ## 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. 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. step; button gone once kern is non-empty.
- [ ] Search filters passages by label and content. - [x] Search filters passages by label and content.
- [ ] Reject → edit → resubmit: approver toggles "Toon wijzigingen", changed/added/ - [x] Reject → edit → resubmit: approver toggles "Toon wijzigingen", changed/added/
removed blocks are badged (block granularity). 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. content never lost locally.
- [ ] Full GREEN. - [x] Full GREEN.
## Verification ## 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 { TestBed } from '@angular/core/testing';
import { describe, it, expect, vi, afterEach } from 'vitest'; import { describe, it, expect, vi, afterEach } from 'vitest';
import { Result } from '@shared/kernel/fp'; 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 { OrgTemplate } from '@brief/domain/org-template';
import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter'; import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';
import { LetterPreviewAdapter } from '@brief/infrastructure/letter-preview.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', () => { describe('BriefStore.previewLetter', () => {
// vi.spyOn reuses an existing spy (and its call history) if one is already on // 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. // the property — window.open/URL.createObjectURL must be restored between tests.
+73 -1
View File
@@ -10,6 +10,7 @@ import {
unresolvedPlaceholders, unresolvedPlaceholders,
} from '@brief/domain/brief'; } from '@brief/domain/brief';
import { BriefMsg, BriefState, initial, reduce } from '@brief/domain/brief.machine'; 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 { OrgTemplate } from '@brief/domain/org-template';
import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter'; import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';
import { LetterPreviewAdapter } from '@brief/infrastructure/letter-preview.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. */ /** Surfaced autosave state for the indicator + aria-live region. */
readonly saveState = signal<SaveState>({ tag: 'Idle' }); readonly saveState = signal<SaveState>({ tag: 'Idle' });
/** Undo/redo is SHELL state, not machine state (WP-27): a 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, /** 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` not letter state — held beside the machine, never inside it (`brief.machine.ts`
stays untouched by design). Set from every server view that carries it. */ stays untouched by design). Set from every server view that carries it. */
@@ -104,18 +134,50 @@ export class BriefStore {
const r = await this.adapter.load(); const r = await this.adapter.load();
if (r.ok) { if (r.ok) {
this.orgTemplate.set(r.value.orgTemplate); this.orgTemplate.set(r.value.orgTemplate);
this.clearHistory();
this.store.dispatch({ tag: 'BriefLoaded', ...r.value }); this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
} else { } else {
this.store.dispatch({ tag: 'BriefLoadFailed', reason: r.error }); 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) { edit(msg: BriefMsg) {
const before = this.brief();
this.store.dispatch(msg); 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(); 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 saveTimer?: ReturnType<typeof setTimeout>;
private scheduleSave() { private scheduleSave() {
if (!this.canEdit()) return; 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. */ /** Demo "start over": recreate the brief server-side and load the fresh view. */
async resetDemo() { async resetDemo() {
this.actionState.set({ tag: 'Busy' }); this.actionState.set({ tag: 'Busy' });
@@ -145,6 +212,8 @@ export class BriefStore {
if (r.ok) { if (r.ok) {
this.actionState.set({ tag: 'Idle' }); this.actionState.set({ tag: 'Idle' });
this.orgTemplate.set(r.value.orgTemplate); this.orgTemplate.set(r.value.orgTemplate);
this.clearHistory();
this.rejectionSnapshot.set(null);
this.store.dispatch({ tag: 'BriefLoaded', ...r.value }); this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
} else { } else {
this.actionState.set({ tag: 'Failed', error: r.error }); this.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 }); this.store.dispatch({ tag: 'Approved', by: s.approvedBy, at: s.approvedAt, decisions });
break; break;
case 'rejected': case 'rejected':
// Capture the letter as-rejected for the resubmission diff (WP-27). This is the
// "before" snapshot the approver later compares against.
this.rejectionSnapshot.set(brief);
this.store.dispatch({ this.store.dispatch({
tag: 'Rejected', tag: 'Rejected',
by: s.rejectedBy, 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 label: string;
readonly content: RichTextBlock; readonly content: RichTextBlock;
readonly version: number; // library version, for provenance only 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. */ /** 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, content: content.value,
version: dto.version, version: dto.version,
...(dto.beroep != null ? { beroep: dto.beroep } : {}), ...(dto.beroep != null ? { beroep: dto.beroep } : {}),
...(dto.isDefault ? { isDefault: true } : {}),
}); });
} }
+47 -1
View File
@@ -12,6 +12,7 @@ import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-compos
@Component({ @Component({
selector: 'app-brief-page', selector: 'app-brief-page',
imports: [PageShellComponent, AlertComponent, ButtonComponent, ...ASYNC, LetterComposerComponent], imports: [PageShellComponent, AlertComponent, ButtonComponent, ...ASYNC, LetterComposerComponent],
host: { '(document:keydown)': 'onKey($event)' },
styles: [ styles: [
` `
.brief-toolbar { .brief-toolbar {
@@ -21,6 +22,11 @@ import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-compos
gap: var(--rhc-space-max-md); gap: var(--rhc-space-max-md);
margin-block-end: var(--rhc-space-max-lg); margin-block-end: var(--rhc-space-max-lg);
} }
.toolbar-start {
display: flex;
align-items: center;
gap: var(--rhc-space-max-md);
}
.save { .save {
color: var(--rhc-color-foreground-subtle); color: var(--rhc-color-foreground-subtle);
font-size: 0.9em; font-size: 0.9em;
@@ -42,7 +48,30 @@ import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-compos
@if (loaded(); as s) { @if (loaded(); as s) {
@if (store.orgTemplate(); as orgTemplate) { @if (store.orgTemplate(); as orgTemplate) {
<div class="brief-toolbar"> <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> <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()">{{ <app-button variant="subtle" [disabled]="store.busy()" (click)="resetDemo()">{{
resetLabel resetLabel
}}</app-button> }}</app-button>
@@ -53,6 +82,8 @@ import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-compos
[logoUrl]="store.logoUrl()" [logoUrl]="store.logoUrl()"
[availablePassages]="s.availablePassages" [availablePassages]="s.availablePassages"
[diagnostics]="store.diagnostics()" [diagnostics]="store.diagnostics()"
[blockDiffs]="store.blockDiffs()"
[removedCount]="store.removedSinceReject()"
[canEdit]="store.canEdit()" [canEdit]="store.canEdit()"
[canApprove]="store.canApprove()" [canApprove]="store.canApprove()"
[canReject]="store.canReject()" [canReject]="store.canReject()"
@@ -83,10 +114,13 @@ export class BriefPage {
protected failedText = $localize`:@@brief.page.failed:De brief kon niet worden geladen.`; protected failedText = $localize`:@@brief.page.failed:De brief kon niet worden geladen.`;
protected retryText = $localize`:@@brief.page.retry:Opnieuw proberen`; protected retryText = $localize`:@@brief.page.retry:Opnieuw proberen`;
protected resetLabel = $localize`:@@brief.page.reset:Opnieuw beginnen (demo)`; 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 savingText = $localize`:@@brief.page.saving:Concept opslaan…`;
private savedText = $localize`:@@brief.page.saved:Concept opgeslagen`; 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. */ /** Debounced-save state, surfaced in a polite live region. */
protected saveText = computed(() => { protected saveText = computed(() => {
@@ -121,4 +155,16 @@ export class BriefPage {
protected reload() { protected reload() {
void this.store.load(); void this.store.load();
} }
/** Ctrl/Cmd+Z = undo, Ctrl/Cmd+Shift+Z = redo (WP-27). Ignored while focus is in the
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, effect,
inject, inject,
input, input,
linkedSignal,
output, output,
signal, signal,
viewChild, viewChild,
@@ -21,6 +22,7 @@ import { OrgTemplate } from '@brief/domain/org-template';
import { OrgTemplateTextField } from '@brief/domain/org-template.machine'; import { OrgTemplateTextField } from '@brief/domain/org-template.machine';
import { Diagnostic } from '@brief/domain/placeholders'; import { Diagnostic } from '@brief/domain/placeholders';
import { BriefMsg } from '@brief/domain/brief.machine'; import { BriefMsg } from '@brief/domain/brief.machine';
import { BlockDiffKind } from '@brief/domain/brief-diff';
import { LetterSectionComponent } from '@brief/ui/letter-section/letter-section.component'; 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. */ /** 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 { .toolbar {
display: flex; 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); 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). */ /* Portal-side chrome around the letter surface (not part of the contract file). */
.surface { .surface {
background: var(--rhc-color-grijs-100); background: var(--rhc-color-grijs-100);
@@ -139,8 +172,27 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
} }
</ng-template> </ng-template>
@if (editableRegions() === 'none') { @if (editableRegions() !== 'template') {
<div class="toolbar"> <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 <app-button
variant="subtle" variant="subtle"
(click)="showSample.set(!showSample())" (click)="showSample.set(!showSample())"
@@ -148,11 +200,12 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
> >
{{ showSample() ? hideSampleLabel() : showSampleLabel() }} {{ showSample() ? hideSampleLabel() : showSampleLabel() }}
</app-button> </app-button>
}
</div> </div>
} }
<div class="surface"> <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 <!-- div, not <header>/<footer>: the CIBG huisstijl styles those bare elements
(robijn footer background) the letter surface must stay letter.css-only. --> (robijn footer background) the letter surface must stay letter.css-only. -->
<div class="letter__letterhead" [class.from-template]="tintTemplate()"> <div class="letter__letterhead" [class.from-template]="tintTemplate()">
@@ -211,6 +264,13 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
<section> <section>
<h3>{{ section.title }}</h3> <h3>{{ section.title }}</h3>
@for (block of section.blocks; track block.blockId) { @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) { @for (seg of segmentsOf(block); track $index) {
@if (seg.list === 'bullet') { @if (seg.list === 'bullet') {
<ul> <ul>
@@ -243,6 +303,7 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
</p> </p>
} }
} }
</div>
} }
</section> </section>
} }
@@ -315,7 +376,13 @@ export class LetterCanvasComponent {
availablePassages = input<readonly LibraryPassage[]>([]); availablePassages = input<readonly LibraryPassage[]>([]);
placeholders = input<readonly PlaceholderOption[]>([]); placeholders = input<readonly PlaceholderOption[]>([]);
diagnostics = input<readonly Diagnostic[]>([]); diagnostics = input<readonly Diagnostic[]>([]);
/** Initial zoom; the in-canvas controls take over from here (WP-27). */
zoom = input(1); 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. */ /** The org logo's content URL (letterhead), or null when none is set. */
logoUrl = input<string | null>(null); logoUrl = input<string | null>(null);
edit = output<BriefMsg>(); edit = output<BriefMsg>();
@@ -343,10 +410,26 @@ export class LetterCanvasComponent {
signatureRoleLabel = input($localize`:@@brief.canvas.signatureRole:Functie ondertekenaar`); signatureRoleLabel = input($localize`:@@brief.canvas.signatureRole:Functie ondertekenaar`);
footerContactLabel = input($localize`:@@brief.canvas.footerContact:Contactgegevens (voettekst)`); footerContactLabel = input($localize`:@@brief.canvas.footerContact:Contactgegevens (voettekst)`);
footerLegalLabel = input($localize`:@@brief.canvas.footerLegal:Juridische voettekst`); footerLegalLabel = input($localize`:@@brief.canvas.footerLegal:Juridische voettekst`);
zoomGroupLabel = input($localize`:@@brief.canvas.zoom:Zoomniveau`);
zoomInLabel = input($localize`:@@brief.canvas.zoomIn:Inzoomen`);
zoomOutLabel = input($localize`:@@brief.canvas.zoomOut:Uitzoomen`);
zoomResetLabel = input($localize`:@@brief.canvas.zoomReset:100%`);
addedLabel = input($localize`:@@brief.diff.added:nieuw`);
changedLabel = input($localize`:@@brief.diff.changed:gewijzigd sinds afwijzing`);
protected showSample = signal(false); protected showSample = signal(false);
protected letterDate = formatDatumNl(new Date()); protected letterDate = formatDatumNl(new Date());
/** 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 /** 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. */ in 'none' the whole surface is read-only, in 'template' they ARE the editable focus. */
protected tintTemplate = computed(() => this.editableRegions() === 'content'); 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 } }; 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. */ /** Long letter: the approximate ±page-break marks appear per A4 interval. */
export const PageBreak: Story = { export const PageBreak: Story = {
args: { editableRegions: 'none', brief: longBrief, diagnostics: [] }, 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 { HeadingComponent } from '@shared/ui/heading/heading.component';
import { StatusBadgeComponent } from '@shared/ui/status-badge/status-badge.component'; import { StatusBadgeComponent } from '@shared/ui/status-badge/status-badge.component';
import { ButtonComponent } from '@shared/ui/button/button.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 { OrgTemplate } from '@brief/domain/org-template';
import { Diagnostic } from '@brief/domain/placeholders'; import { Diagnostic } from '@brief/domain/placeholders';
import { BriefMsg } from '@brief/domain/brief.machine'; 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 { LetterCanvasComponent } from '@brief/ui/letter-canvas/letter-canvas.component';
import { DiagnosticsPanelComponent } from '@brief/ui/diagnostics-panel/diagnostics-panel.component'; import { DiagnosticsPanelComponent } from '@brief/ui/diagnostics-panel/diagnostics-panel.component';
import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejection-comments.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"> <div class="head">
<app-heading [level]="2">{{ title() }}</app-heading> <app-heading [level]="2">{{ title() }}</app-heading>
<div class="head-end"> <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()">{{ <app-button variant="subtle" [disabled]="busy()" (click)="preview.emit()">{{
previewLabel() previewLabel()
}}</app-button> }}</app-button>
@@ -73,6 +82,10 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
<app-rejection-comments mode="show" [comments]="rejectComments()" /> <app-rejection-comments mode="show" [comments]="rejectComments()" />
} }
@if (showDiff() && removedCount() > 0) {
<app-alert type="info">{{ removedText() }}</app-alert>
}
<app-letter-canvas <app-letter-canvas
[brief]="brief()" [brief]="brief()"
[orgTemplate]="orgTemplate()" [orgTemplate]="orgTemplate()"
@@ -81,6 +94,8 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
[availablePassages]="availablePassages()" [availablePassages]="availablePassages()"
[placeholders]="menu()" [placeholders]="menu()"
[diagnostics]="diagnostics()" [diagnostics]="diagnostics()"
[blockDiffs]="blockDiffs()"
[showDiff]="showDiff()"
(edit)="edit.emit($event)" (edit)="edit.emit($event)"
/> />
@@ -149,6 +164,12 @@ export class LetterComposerComponent {
canSend = input(false); canSend = input(false);
canSubmit = input(false); canSubmit = input(false);
busy = 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>(); edit = output<BriefMsg>();
submit = output<void>(); submit = output<void>();
@@ -160,6 +181,12 @@ export class LetterComposerComponent {
title = input($localize`:@@brief.title:Brief aan de zorgverlener`); title = input($localize`:@@brief.title:Brief aan de zorgverlener`);
previewLabel = input($localize`:@@brief.preview.open:Voorbeeld`); 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`); submitLabel = input($localize`:@@brief.submit:Indienen ter beoordeling`);
resubmitLabel = input($localize`:@@brief.resubmit:Opnieuw indienen`); resubmitLabel = input($localize`:@@brief.resubmit:Opnieuw indienen`);
submitHint = input( submitHint = input(
@@ -63,6 +63,11 @@ import { PassagePickerComponent } from '@brief/ui/passage-picker/passage-picker.
@if (editable()) { @if (editable()) {
<div class="actions"> <div class="actions">
@if (showStandardLetter()) {
<app-button variant="primary" (click)="insertStandardLetter()">{{
standardLetterLabel()
}}</app-button>
}
<app-button variant="secondary" (click)="pickerOpen.set(!pickerOpen())">{{ <app-button variant="secondary" (click)="pickerOpen.set(!pickerOpen())">{{
addPassageLabel() addPassageLabel()
}}</app-button> }}</app-button>
@@ -89,11 +94,27 @@ export class LetterSectionComponent {
emptyLabel = input($localize`:@@brief.section.empty:Nog geen tekst in deze sectie.`); emptyLabel = input($localize`:@@brief.section.empty:Nog geen tekst in deze sectie.`);
addPassageLabel = input($localize`:@@brief.section.addPassage:Standaardtekst toevoegen`); addPassageLabel = input($localize`:@@brief.section.addPassage:Standaardtekst toevoegen`);
addFreeLabel = input($localize`:@@brief.section.addFree:Vrije tekst toevoegen`); addFreeLabel = input($localize`:@@brief.section.addFree:Vrije tekst toevoegen`);
standardLetterLabel = input($localize`:@@brief.section.standardLetter:Standaardbrief invoegen`);
protected pickerOpen = signal(false); protected pickerOpen = signal(false);
protected sectionPassages = computed(() => protected sectionPassages = computed(() =>
this.availablePassages().filter((p) => p.sectionKey === this.section().sectionKey), 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) { protected onContent(blockId: string, content: RichTextBlock) {
this.edit.emit({ tag: 'BlockContentEdited', blockId, content }); this.edit.emit({ tag: 'BlockContentEdited', blockId, content });
@@ -36,6 +36,7 @@ const passages: LibraryPassage[] = [
sectionKey: 'kern', sectionKey: 'kern',
label: 'Toelichting arts', label: 'Toelichting arts',
version: 1, version: 1,
isDefault: true, // part of the standaardbrief starter set (WP-27)
content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Als arts ...' }] }] }, 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 ReadOnly: Story = { args: { editable: false } };
export const Editable: Story = { args: { editable: true } }; 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 } }; 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 { FormsModule } from '@angular/forms';
import { CheckboxComponent } from '@shared/ui/checkbox/checkbox.component'; import { CheckboxComponent } from '@shared/ui/checkbox/checkbox.component';
import { ButtonComponent } from '@shared/ui/button/button.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'; import { LibraryPassage } from '@brief/domain/brief';
/** Molecule: multi-select list of the section's library passages. One "Voeg toe" /** 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. */ single-insert path. Presentational: emits the chosen passages in list order. */
@Component({ @Component({
selector: 'app-passage-picker', selector: 'app-passage-picker',
imports: [FormsModule, CheckboxComponent, ButtonComponent], imports: [FormsModule, CheckboxComponent, ButtonComponent, TextInputComponent],
styles: [ styles: [
` `
:host { :host {
@@ -19,6 +21,9 @@ import { LibraryPassage } from '@brief/domain/brief';
border-radius: var(--rhc-border-radius-md); border-radius: var(--rhc-border-radius-md);
padding: var(--rhc-space-max-md); padding: var(--rhc-space-max-md);
} }
.search {
margin-block-end: var(--rhc-space-max-md);
}
ul { ul {
list-style: none; list-style: none;
margin: 0 0 var(--rhc-space-max-md); margin: 0 0 var(--rhc-space-max-md);
@@ -29,11 +34,24 @@ import { LibraryPassage } from '@brief/domain/brief';
.scope { .scope {
color: var(--rhc-color-foreground-subtle); 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: ` template: `
<div class="search">
<app-text-input
[placeholder]="searchLabel()"
[attr.aria-label]="searchLabel()"
[ngModel]="query()"
(ngModelChange)="query.set($event)"
/>
</div>
<ul> <ul>
@for (p of passages(); track p.passageId) { @for (p of filtered(); track p.passageId) {
<li> <li>
<app-checkbox <app-checkbox
[checkboxId]="'passage-' + p.passageId" [checkboxId]="'passage-' + p.passageId"
@@ -43,6 +61,8 @@ import { LibraryPassage } from '@brief/domain/brief';
/> />
<span class="scope"> · {{ p.scope === 'beroep' ? beroepLabel() : globalLabel() }}</span> <span class="scope"> · {{ p.scope === 'beroep' ? beroepLabel() : globalLabel() }}</span>
</li> </li>
} @empty {
<li class="empty">{{ noMatchLabel() }}</li>
} }
</ul> </ul>
<app-button variant="secondary" [disabled]="count() === 0" (click)="add()" <app-button variant="secondary" [disabled]="count() === 0" (click)="add()"
@@ -57,8 +77,20 @@ export class PassagePickerComponent {
addLabel = input($localize`:@@brief.picker.add:Voeg toe`); addLabel = input($localize`:@@brief.picker.add:Voeg toe`);
globalLabel = input($localize`:@@brief.picker.global:algemeen`); globalLabel = input($localize`:@@brief.picker.global:algemeen`);
beroepLabel = input($localize`:@@brief.picker.beroep:beroepsspecifiek`); 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 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 count = () => Object.values(this.checked()).filter(Boolean).length;
protected set(id: string, on: boolean) { protected set(id: string, on: boolean) {
@@ -1712,6 +1712,7 @@ export interface LibraryPassageDto {
content?: RichTextBlockDto; content?: RichTextBlockDto;
version?: number; version?: number;
beroep?: string | undefined; beroep?: string | undefined;
isDefault?: boolean;
} }
export interface ManualDiplomaPolicyDto { export interface ManualDiplomaPolicyDto {
+9
View File
@@ -49,6 +49,15 @@ export function deepCopyBlock(block: RichTextBlock): RichTextBlock {
return structuredClone(block) as 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 /** Every placeholder key used in a block, in document order (duplicates kept the
caller dedupes when it wants a set). */ caller dedupes when it wants a set). */
export function placeholderKeysIn(block: RichTextBlock): string[] { export function placeholderKeysIn(block: RichTextBlock): string[] {