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
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:
Vendored
+1
@@ -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
|
||||||
|
|||||||
@@ -1720,6 +1720,9 @@
|
|||||||
"beroep": {
|
"beroep": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"nullable": true
|
"nullable": true
|
||||||
|
},
|
||||||
|
"isDefault": {
|
||||||
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
|
|||||||
@@ -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
File diff suppressed because one or more lines are too long
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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]));
|
||||||
|
}
|
||||||
@@ -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 } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
<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()">{{
|
<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,20 +172,40 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
|||||||
}
|
}
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
||||||
@if (editableRegions() === 'none') {
|
@if (editableRegions() !== 'template') {
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<app-button
|
<div class="zoom" role="group" [attr.aria-label]="zoomGroupLabel()">
|
||||||
variant="subtle"
|
<app-button
|
||||||
(click)="showSample.set(!showSample())"
|
variant="subtle"
|
||||||
[attr.aria-pressed]="showSample()"
|
[disabled]="zoomLevel() <= 0.5"
|
||||||
>
|
[attr.aria-label]="zoomOutLabel()"
|
||||||
{{ showSample() ? hideSampleLabel() : showSampleLabel() }}
|
(click)="zoomBy(-0.1)"
|
||||||
</app-button>
|
>−</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>
|
||||||
}
|
}
|
||||||
|
|
||||||
<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,7 +264,14 @@ 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) {
|
||||||
@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') {
|
@if (seg.list === 'bullet') {
|
||||||
<ul>
|
<ul>
|
||||||
@for (para of seg.items; track $index) {
|
@for (para of seg.items; track $index) {
|
||||||
@@ -242,7 +302,8 @@ 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.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();
|
||||||
|
|
||||||
/** 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 {
|
||||||
|
|||||||
@@ -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[] {
|
||||||
|
|||||||
Reference in New Issue
Block a user