feat(fp): brief v3 — besluit-driven guided drafting
Compose the herregistratie letter from the besluit instead of a library hunt: the behandelaar picks positief/negatief (+ reden-checkboxes for a negatief) and the kern's standaardteksten follow the selection live. Front-end (this increment): - Kern is recomposed reactively from the besluit selection (new BesluitSelected machine msg + composeKern); the "Genereer conceptbrief" button is gone. The drafter's free text is preserved across a selection change. - The editor shows only the editable sections; the locked aanhef/slot render in the preview, not the authoring surface. Slot is a case-type template section (per templateId), documented as such. - The panel re-seeds from the letter via inferSelection() — the besluit + redenen are read back off the kern's passage blocks, so the selection survives reload with no new wire fields (derive, don't store). - letter-section drops the now-redundant per-section passage picker (besluit owns standaardteksten); keeps free-text + block edit/move/remove. Fix: app-checkbox now falls back to a unique per-instance id when checkboxId is omitted. The CIBG styled checkbox routes clicks through the label, so the shared id="undefined" made every reason label toggle the first input — the second checkbox could never be checked. Verified live (Playwright): each reason toggles independently. Backend/seam (brief v3 WIP): besluit/reason passage tags on the wire + seed, carried through the adapter parse boundary. Specs updated (besluit, brief.machine) and the affected stories re-pointed at the new API. FE lint + build + 253 vitest specs green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { Result } from '@shared/kernel/fp';
|
||||
import { Brief, BriefDecisions, LetterBlock } from '@brief/domain/brief';
|
||||
import { Brief, BriefDecisions, CaseContext, LetterBlock } from '@brief/domain/brief';
|
||||
import { OrgTemplate } from '@brief/domain/org-template';
|
||||
import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';
|
||||
import { LetterPreviewAdapter } from '@brief/infrastructure/letter-preview.adapter';
|
||||
@@ -37,7 +37,14 @@ const orgTemplate: OrgTemplate = {
|
||||
version: 1,
|
||||
};
|
||||
|
||||
const view: BriefView = { brief, availablePassages: [], decisions, orgTemplate };
|
||||
const caseContext: CaseContext = {
|
||||
zorgverlenerNaam: 'Dr. A. (Anna) de Vries',
|
||||
bigNummer: '19012345601',
|
||||
beroep: 'arts',
|
||||
aanvraagReferentie: 'HER-2026-000842',
|
||||
};
|
||||
|
||||
const view: BriefView = { brief, availablePassages: [], decisions, orgTemplate, caseContext };
|
||||
|
||||
function setup(adapter: Partial<BriefAdapter>): BriefStore {
|
||||
TestBed.configureTestingModule({ providers: [{ provide: BriefAdapter, useValue: adapter }] });
|
||||
|
||||
@@ -4,6 +4,7 @@ import { RemoteData } from '@shared/application/remote-data';
|
||||
import { createStore } from '@shared/application/store';
|
||||
import {
|
||||
Brief,
|
||||
CaseContext,
|
||||
allDiagnostics,
|
||||
canSubmit,
|
||||
hasBlockingErrors,
|
||||
@@ -87,6 +88,10 @@ export class BriefStore {
|
||||
stays untouched by design). Set from every server view that carries it. */
|
||||
readonly orgTemplate = signal<OrgTemplate | null>(null);
|
||||
|
||||
/** The case (zorgverlener + aanvraag) this letter concerns — server-joined context for
|
||||
the behandel scherm header, not letter state. Set from every server view. */
|
||||
readonly caseContext = signal<CaseContext | null>(null);
|
||||
|
||||
/** The org logo's content URL for the letterhead, or null when the template has none. */
|
||||
readonly logoUrl = computed<string | null>(() => {
|
||||
const id = this.orgTemplate()?.logoDocumentId;
|
||||
@@ -134,6 +139,7 @@ export class BriefStore {
|
||||
const r = await this.adapter.load();
|
||||
if (r.ok) {
|
||||
this.orgTemplate.set(r.value.orgTemplate);
|
||||
this.caseContext.set(r.value.caseContext);
|
||||
this.clearHistory();
|
||||
this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
|
||||
} else {
|
||||
@@ -212,6 +218,7 @@ export class BriefStore {
|
||||
if (r.ok) {
|
||||
this.actionState.set({ tag: 'Idle' });
|
||||
this.orgTemplate.set(r.value.orgTemplate);
|
||||
this.caseContext.set(r.value.caseContext);
|
||||
this.clearHistory();
|
||||
this.rejectionSnapshot.set(null);
|
||||
this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
|
||||
@@ -257,6 +264,7 @@ export class BriefStore {
|
||||
private applyServerStatus(view: BriefView) {
|
||||
// `send` pins the org-template version server-side — mirror whatever came back.
|
||||
this.orgTemplate.set(view.orgTemplate);
|
||||
this.caseContext.set(view.caseContext);
|
||||
const { brief, decisions } = view;
|
||||
const s = brief.status;
|
||||
switch (s.tag) {
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Besluit, LetterBlock, LibraryPassage } from './brief';
|
||||
import { inferSelection, passagesForBesluit, redenenFor } from './besluit';
|
||||
|
||||
const block = (t: string): LibraryPassage['content'] => ({ paragraphs: [{ nodes: [{ type: 'text', text: t }] }] });
|
||||
|
||||
const p = (over: Partial<LibraryPassage>): LibraryPassage => ({
|
||||
passageId: over.passageId ?? 'x',
|
||||
scope: 'global',
|
||||
sectionKey: 'kern',
|
||||
label: over.label ?? 'x',
|
||||
content: block('x'),
|
||||
version: 1,
|
||||
...over,
|
||||
});
|
||||
|
||||
const lib: LibraryPassage[] = [
|
||||
p({ passageId: 'intro', besluit: undefined }), // shared, any besluit
|
||||
p({ passageId: 'pos', besluit: 'positief' }),
|
||||
p({ passageId: 'neg', besluit: 'negatief' }),
|
||||
p({ passageId: 'neg-scholing', besluit: 'negatief', reason: 'onvoldoende_scholing', label: 'Onvoldoende scholing' }),
|
||||
p({ passageId: 'neg-gegevens', besluit: 'negatief', reason: 'onjuiste_gegevens', label: 'Onjuiste gegevens' }),
|
||||
p({ passageId: 'slot-x', sectionKey: 'slot', besluit: undefined }), // not kern → never offered
|
||||
];
|
||||
|
||||
describe('passagesForBesluit', () => {
|
||||
it('positief = shared intro + the positief passage, no negatief/reason passages', () => {
|
||||
const ids = passagesForBesluit(lib, 'positief', []).map((x) => x.passageId);
|
||||
expect(ids).toEqual(['intro', 'pos']);
|
||||
});
|
||||
|
||||
it('negatief without redenen = intro + negatief base, but no reason-specific passages', () => {
|
||||
const ids = passagesForBesluit(lib, 'negatief', []).map((x) => x.passageId);
|
||||
expect(ids).toEqual(['intro', 'neg']);
|
||||
});
|
||||
|
||||
it('negatief with a reden ticked includes that reason-specific passage only', () => {
|
||||
const ids = passagesForBesluit(lib, 'negatief', ['onvoldoende_scholing']).map((x) => x.passageId);
|
||||
expect(ids).toEqual(['intro', 'neg', 'neg-scholing']);
|
||||
});
|
||||
|
||||
it('preserves library order (= reading order)', () => {
|
||||
const ids = passagesForBesluit(lib, 'negatief', ['onjuiste_gegevens', 'onvoldoende_scholing']).map((x) => x.passageId);
|
||||
expect(ids).toEqual(['intro', 'neg', 'neg-scholing', 'neg-gegevens']);
|
||||
});
|
||||
|
||||
it('never offers non-kern passages', () => {
|
||||
expect(passagesForBesluit(lib, 'positief', []).some((x) => x.sectionKey !== 'kern')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('redenenFor', () => {
|
||||
it('derives reason checkboxes (code + label) from the negatief reason passages', () => {
|
||||
expect(redenenFor(lib, 'negatief')).toEqual([
|
||||
{ code: 'onvoldoende_scholing', label: 'Onvoldoende scholing' },
|
||||
{ code: 'onjuiste_gegevens', label: 'Onjuiste gegevens' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('positief has no reason-specific redenen', () => {
|
||||
expect(redenenFor(lib, 'positief')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('inferSelection', () => {
|
||||
// Build the kern blocks a besluit would produce, then read the selection back off them.
|
||||
const kern = (besluit: Besluit, reasons: string[]): LetterBlock[] =>
|
||||
passagesForBesluit(lib, besluit, reasons).map((p, i) => ({
|
||||
type: 'passage',
|
||||
blockId: `local-${i + 1}`,
|
||||
sourcePassageId: p.passageId,
|
||||
sourceVersion: p.version,
|
||||
content: p.content,
|
||||
edited: false,
|
||||
}));
|
||||
|
||||
it('round-trips a positief selection', () => {
|
||||
expect(inferSelection(kern('positief', []), lib)).toEqual({ besluit: 'positief', reasons: [] });
|
||||
});
|
||||
|
||||
it('round-trips a negatief selection with redenen (in order)', () => {
|
||||
const blocks = kern('negatief', ['onjuiste_gegevens', 'onvoldoende_scholing']);
|
||||
expect(inferSelection(blocks, lib)).toEqual({
|
||||
besluit: 'negatief',
|
||||
reasons: ['onvoldoende_scholing', 'onjuiste_gegevens'], // library order
|
||||
});
|
||||
});
|
||||
|
||||
it('an empty kern (nothing chosen) infers no besluit', () => {
|
||||
expect(inferSelection([], lib)).toEqual({ besluit: null, reasons: [] });
|
||||
});
|
||||
|
||||
it('ignores free-text blocks and unknown passage ids', () => {
|
||||
const blocks: LetterBlock[] = [
|
||||
{ type: 'freeText', blockId: 'local-9', content: block('vrij') },
|
||||
...kern('positief', []),
|
||||
];
|
||||
expect(inferSelection(blocks, lib)).toEqual({ besluit: 'positief', reasons: [] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Besluit, LetterBlock, LibraryPassage } from './brief';
|
||||
|
||||
/**
|
||||
* Guided drafting: given the behandelaar's besluit + chosen redenen, which library
|
||||
* passages belong in the kern. This is the "don't make them a detective" logic —
|
||||
* pure, so it's unit-tested directly and the UI just renders the result.
|
||||
*
|
||||
* A passage is offered when:
|
||||
* - it has no besluit tag (a shared intro/toelichting, relevant to any besluit), OR
|
||||
* - its besluit matches AND either it isn't reason-specific, or its reason is ticked.
|
||||
*
|
||||
* Kept in library order (server order = reading order), so an inserted set already
|
||||
* flows as a letter.
|
||||
*/
|
||||
export function passagesForBesluit(
|
||||
passages: readonly LibraryPassage[],
|
||||
besluit: Besluit,
|
||||
reasons: readonly string[],
|
||||
): LibraryPassage[] {
|
||||
return passages.filter((p) => {
|
||||
if (p.sectionKey !== 'kern') return false;
|
||||
if (p.besluit === undefined) return true; // shared, any besluit
|
||||
if (p.besluit !== besluit) return false;
|
||||
if (p.reason === undefined) return true; // besluit-level, not reason-specific
|
||||
return reasons.includes(p.reason);
|
||||
});
|
||||
}
|
||||
|
||||
/** A selectable reden for a besluit, derived from the reason-specific passages — no
|
||||
separate catalog. `code` drives `passagesForBesluit`; `label` is the checkbox text.
|
||||
ponytail: assumes one passage per reason (true for the seed); dedupes on code if not. */
|
||||
export interface Reden {
|
||||
readonly code: string;
|
||||
readonly label: string;
|
||||
}
|
||||
|
||||
export function redenenFor(passages: readonly LibraryPassage[], besluit: Besluit): Reden[] {
|
||||
const seen = new Set<string>();
|
||||
const out: Reden[] = [];
|
||||
for (const p of passages) {
|
||||
if (p.sectionKey !== 'kern' || p.besluit !== besluit || p.reason === undefined) continue;
|
||||
if (seen.has(p.reason)) continue;
|
||||
seen.add(p.reason);
|
||||
out.push({ code: p.reason, label: p.label });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The inverse of `passagesForBesluit`: read the current besluit + redenen back off the
|
||||
* kern's passage blocks (each carries its `sourcePassageId`), so the panel can re-seed
|
||||
* itself on reload/undo without persisting the selection separately. Kern passage blocks
|
||||
* are besluit-derived by construction (the only way passages enter the kern), so this
|
||||
* round-trips: `inferSelection(kern(passagesForBesluit(lib, b, r)), lib) === { b, r }`.
|
||||
* Free-text blocks carry no provenance and are ignored.
|
||||
*/
|
||||
export function inferSelection(
|
||||
kernBlocks: readonly LetterBlock[],
|
||||
passages: readonly LibraryPassage[],
|
||||
): { besluit: Besluit | null; reasons: string[] } {
|
||||
const byId = new Map(passages.map((p) => [p.passageId, p]));
|
||||
let besluit: Besluit | null = null;
|
||||
const reasons: string[] = [];
|
||||
for (const b of kernBlocks) {
|
||||
if (b.type !== 'passage') continue;
|
||||
const source = byId.get(b.sourcePassageId);
|
||||
if (!source) continue;
|
||||
if (source.besluit !== undefined) besluit = source.besluit;
|
||||
if (source.reason !== undefined && !reasons.includes(source.reason)) reasons.push(source.reason);
|
||||
}
|
||||
return { besluit, reasons };
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Brief, BriefDecisions, BriefStatus, LibraryPassage } from './brief';
|
||||
import { Besluit, Brief, BriefDecisions, BriefStatus, LibraryPassage } from './brief';
|
||||
import { RichTextBlock } from '@shared/kernel/rich-text';
|
||||
import { PlaceholderDef } from './placeholders';
|
||||
import { BriefState, BriefMsg, reduce } from './brief.machine';
|
||||
import { BriefState, reduce } from './brief.machine';
|
||||
|
||||
const placeholders: PlaceholderDef[] = [
|
||||
{ key: 'naam', label: 'Naam', autoResolvable: true },
|
||||
@@ -13,15 +13,32 @@ const text = (t: string): RichTextBlock => ({
|
||||
paragraphs: [{ nodes: [{ type: 'text', text: t }] }],
|
||||
});
|
||||
|
||||
const libPassage = (id: string, sectionKey: string): LibraryPassage => ({
|
||||
const libPassage = (
|
||||
id: string,
|
||||
sectionKey: string,
|
||||
extra: Partial<LibraryPassage> = {},
|
||||
): LibraryPassage => ({
|
||||
passageId: id,
|
||||
scope: 'global',
|
||||
sectionKey,
|
||||
label: `Passage ${id}`,
|
||||
content: text(`inhoud ${id}`),
|
||||
version: 3,
|
||||
...extra,
|
||||
});
|
||||
|
||||
// A besluit-tagged kern library: `intro` is shared (any besluit), `pos`/`neg` are
|
||||
// besluit-level, `neg-r` is reason-specific. This drives every `BesluitSelected` here.
|
||||
const lib: LibraryPassage[] = [
|
||||
libPassage('intro', 'kern'),
|
||||
libPassage('pos', 'kern', { besluit: 'positief' }),
|
||||
libPassage('neg', 'kern', { besluit: 'negatief' }),
|
||||
libPassage('neg-r', 'kern', { besluit: 'negatief', reason: 'r1', label: 'Reden 1' }),
|
||||
];
|
||||
|
||||
const besluit = (b: Besluit | null, reasons: string[] = []) =>
|
||||
({ tag: 'BesluitSelected', besluit: b, reasons }) as const;
|
||||
|
||||
function briefWith(status: BriefStatus, sections?: Brief['sections']): Brief {
|
||||
return {
|
||||
briefId: 'b1',
|
||||
@@ -29,7 +46,7 @@ function briefWith(status: BriefStatus, sections?: Brief['sections']): Brief {
|
||||
templateId: 't1',
|
||||
placeholders,
|
||||
sections: sections ?? [
|
||||
{ sectionKey: 'aanhef', title: 'Aanhef', required: true, locked: false, blocks: [] },
|
||||
{ sectionKey: 'kern', title: 'Kern', required: true, locked: false, blocks: [] },
|
||||
{ sectionKey: 'slot', title: 'Slot', required: false, locked: false, blocks: [] },
|
||||
],
|
||||
status,
|
||||
@@ -52,13 +69,18 @@ const loaded = (
|
||||
): BriefState => ({
|
||||
tag: 'loaded',
|
||||
brief: briefWith(status, sections),
|
||||
availablePassages: [libPassage('p1', 'aanhef'), libPassage('p2', 'aanhef')],
|
||||
availablePassages: lib,
|
||||
decisions,
|
||||
});
|
||||
|
||||
const sectionBlocks = (s: BriefState, key: string) =>
|
||||
s.tag === 'loaded' ? s.brief.sections.find((x) => x.sectionKey === key)!.blocks : [];
|
||||
|
||||
const passageIds = (s: BriefState, key: string) =>
|
||||
sectionBlocks(s, key)
|
||||
.filter((b) => b.type === 'passage')
|
||||
.map((b) => (b.type === 'passage' ? b.sourcePassageId : ''));
|
||||
|
||||
describe('brief.machine reduce', () => {
|
||||
it('BriefLoaded / BriefLoadFailed / Seed set state directly', () => {
|
||||
expect(
|
||||
@@ -77,84 +99,80 @@ describe('brief.machine reduce', () => {
|
||||
expect(reduce(initialLoading(), { tag: 'Seed', state: seeded })).toBe(seeded);
|
||||
});
|
||||
|
||||
it('PassagesInserted creates one frozen block per passage, in order, with local ids', () => {
|
||||
const s = reduce(loaded(), {
|
||||
tag: 'PassagesInserted',
|
||||
sectionKey: 'aanhef',
|
||||
passages: [libPassage('p1', 'aanhef'), libPassage('p2', 'aanhef')],
|
||||
});
|
||||
const blocks = sectionBlocks(s, 'aanhef');
|
||||
it('BesluitSelected composes the kern: the besluit passages, in reading order, as frozen local blocks', () => {
|
||||
const s = reduce(loaded(), besluit('positief'));
|
||||
const blocks = sectionBlocks(s, 'kern');
|
||||
expect(blocks.map((b) => b.blockId)).toEqual(['local-1', 'local-2']);
|
||||
expect(blocks.every((b) => b.type === 'passage' && b.edited === false)).toBe(true);
|
||||
expect(blocks[0].type === 'passage' && blocks[0].sourcePassageId).toBe('p1');
|
||||
expect(passageIds(s, 'kern')).toEqual(['intro', 'pos']);
|
||||
});
|
||||
|
||||
it('PassagesInserted deep-copies content — later library mutation does not leak in', () => {
|
||||
const passage = libPassage('p1', 'aanhef');
|
||||
const s = reduce(loaded(), {
|
||||
tag: 'PassagesInserted',
|
||||
sectionKey: 'aanhef',
|
||||
passages: [passage],
|
||||
});
|
||||
// Mutate the source passage object after insertion.
|
||||
it('BesluitSelected swaps the passages when the selection changes, keeping free text', () => {
|
||||
let s = reduce(loaded(), besluit('positief'));
|
||||
s = reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'kern' }); // drafter's own remark
|
||||
s = reduce(s, besluit('negatief', ['r1']));
|
||||
const blocks = sectionBlocks(s, 'kern');
|
||||
expect(blocks.map((b) => b.type)).toEqual(['passage', 'passage', 'passage', 'freeText']);
|
||||
expect(passageIds(s, 'kern')).toEqual(['intro', 'neg', 'neg-r']);
|
||||
// Deselecting the besluit leaves only the free text.
|
||||
s = reduce(s, besluit(null));
|
||||
expect(sectionBlocks(s, 'kern').map((b) => b.type)).toEqual(['freeText']);
|
||||
});
|
||||
|
||||
it('BesluitSelected deep-copies content — later library mutation does not leak in', () => {
|
||||
const passage = libPassage('intro', 'kern'); // shared → offered for any besluit
|
||||
const st: BriefState = {
|
||||
tag: 'loaded',
|
||||
brief: briefWith({ tag: 'draft' }),
|
||||
availablePassages: [passage],
|
||||
decisions,
|
||||
};
|
||||
const s = reduce(st, besluit('positief'));
|
||||
// Mutate the source passage object after composition.
|
||||
(passage.content.paragraphs[0].nodes as { type: 'text'; text: string }[])[0].text = 'HACKED';
|
||||
const block = sectionBlocks(s, 'aanhef')[0];
|
||||
expect(block.content.paragraphs[0].nodes[0]).toEqual({ type: 'text', text: 'inhoud p1' });
|
||||
const block = sectionBlocks(s, 'kern')[0];
|
||||
expect(block.content.paragraphs[0].nodes[0]).toEqual({ type: 'text', text: 'inhoud intro' });
|
||||
});
|
||||
|
||||
it('FreeTextBlockAdded appends an empty free-text block', () => {
|
||||
const s = reduce(loaded(), { tag: 'FreeTextBlockAdded', sectionKey: 'slot' });
|
||||
const blocks = sectionBlocks(s, 'slot');
|
||||
const s = reduce(loaded(), { tag: 'FreeTextBlockAdded', sectionKey: 'kern' });
|
||||
const blocks = sectionBlocks(s, 'kern');
|
||||
expect(blocks).toHaveLength(1);
|
||||
expect(blocks[0].type).toBe('freeText');
|
||||
});
|
||||
|
||||
it('BlockContentEdited replaces content and marks a passage block edited', () => {
|
||||
let s = reduce(loaded(), {
|
||||
tag: 'PassagesInserted',
|
||||
sectionKey: 'aanhef',
|
||||
passages: [libPassage('p1', 'aanhef')],
|
||||
});
|
||||
let s = reduce(loaded(), besluit('positief'));
|
||||
s = reduce(s, { tag: 'BlockContentEdited', blockId: 'local-1', content: text('aangepast') });
|
||||
const block = sectionBlocks(s, 'aanhef')[0];
|
||||
const block = sectionBlocks(s, 'kern')[0];
|
||||
expect(block.type === 'passage' && block.edited).toBe(true);
|
||||
expect(block.content).toEqual(text('aangepast'));
|
||||
});
|
||||
|
||||
it('BlockRemoved and BlockMovedWithinSection reorder within a section', () => {
|
||||
let s = reduce(loaded(), {
|
||||
tag: 'PassagesInserted',
|
||||
sectionKey: 'aanhef',
|
||||
passages: [libPassage('p1', 'aanhef'), libPassage('p2', 'aanhef')],
|
||||
});
|
||||
let s = reduce(loaded(), besluit('positief')); // local-1 intro, local-2 pos
|
||||
s = reduce(s, { tag: 'BlockMovedWithinSection', blockId: 'local-1', toIndex: 1 });
|
||||
expect(sectionBlocks(s, 'aanhef').map((b) => b.blockId)).toEqual(['local-2', 'local-1']);
|
||||
expect(sectionBlocks(s, 'kern').map((b) => b.blockId)).toEqual(['local-2', 'local-1']);
|
||||
s = reduce(s, { tag: 'BlockRemoved', blockId: 'local-2' });
|
||||
expect(sectionBlocks(s, 'aanhef').map((b) => b.blockId)).toEqual(['local-1']);
|
||||
expect(sectionBlocks(s, 'kern').map((b) => b.blockId)).toEqual(['local-1']);
|
||||
});
|
||||
|
||||
it('edits to a locked section are no-ops (insert, free-text, content, remove, move)', () => {
|
||||
it('edits to a locked section are no-ops (besluit, free-text, content, remove, move)', () => {
|
||||
const lockedSections: Brief['sections'] = [
|
||||
{
|
||||
sectionKey: 'aanhef',
|
||||
title: 'Aanhef',
|
||||
sectionKey: 'kern',
|
||||
title: 'Kern',
|
||||
required: true,
|
||||
locked: true,
|
||||
blocks: [{ type: 'freeText', blockId: 'local-1', content: text('vast') }],
|
||||
},
|
||||
{ sectionKey: 'kern', title: 'Kern', required: true, locked: false, blocks: [] },
|
||||
{ sectionKey: 'slot', title: 'Slot', required: false, locked: false, blocks: [] },
|
||||
];
|
||||
const s = loaded({ tag: 'draft' }, lockedSections);
|
||||
// The brief value is left untouched (withEdit reallocates state, but the guard returns
|
||||
// the same brief), so assert on deep equality of the section contents.
|
||||
expect(
|
||||
reduce(s, {
|
||||
tag: 'PassagesInserted',
|
||||
sectionKey: 'aanhef',
|
||||
passages: [libPassage('p1', 'aanhef')],
|
||||
}),
|
||||
).toEqual(s);
|
||||
expect(reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'aanhef' })).toEqual(s);
|
||||
expect(reduce(s, besluit('positief'))).toEqual(s);
|
||||
expect(reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'kern' })).toEqual(s);
|
||||
expect(
|
||||
reduce(s, { tag: 'BlockContentEdited', blockId: 'local-1', content: text('gehackt') }),
|
||||
).toEqual(s);
|
||||
@@ -163,8 +181,8 @@ describe('brief.machine reduce', () => {
|
||||
s,
|
||||
);
|
||||
// the unlocked section still accepts edits
|
||||
const edited = reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'kern' });
|
||||
expect(sectionBlocks(edited, 'kern')).toHaveLength(1);
|
||||
const edited = reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'slot' });
|
||||
expect(sectionBlocks(edited, 'slot')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('edits are no-ops once submitted (status invariant)', () => {
|
||||
@@ -185,10 +203,10 @@ describe('brief.machine reduce', () => {
|
||||
});
|
||||
|
||||
it('Submitted fires only from draft and only when required sections are filled', () => {
|
||||
// required 'aanhef' empty → no-op
|
||||
// required 'kern' empty → no-op
|
||||
expect(reduce(loaded(), { tag: 'Submitted', by: 'u1', at: 't', decisions })).toEqual(loaded());
|
||||
// fill the required section, then submit
|
||||
const filled = reduce(loaded(), { tag: 'FreeTextBlockAdded', sectionKey: 'aanhef' });
|
||||
// fill the required section via the besluit, then submit
|
||||
const filled = reduce(loaded(), besluit('positief'));
|
||||
const submitted = reduce(filled, { tag: 'Submitted', by: 'u1', at: 't', decisions });
|
||||
expect(submitted.tag === 'loaded' && submitted.brief.status).toEqual({
|
||||
tag: 'submitted',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { assertNever } from '@shared/kernel/fp';
|
||||
import {
|
||||
Besluit,
|
||||
Brief,
|
||||
BriefDecisions,
|
||||
BriefStatus,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
canSubmit,
|
||||
} from './brief';
|
||||
import { RichTextBlock, deepCopyBlock, emptyBlock } from '@shared/kernel/rich-text';
|
||||
import { passagesForBesluit } from './besluit';
|
||||
|
||||
/**
|
||||
* The letter composition state machine (Model + Msg + pure reduce), modeled on
|
||||
@@ -54,7 +56,7 @@ export type BriefMsg =
|
||||
decisions: BriefDecisions;
|
||||
}
|
||||
| { tag: 'BriefLoadFailed'; reason: string }
|
||||
| { tag: 'PassagesInserted'; sectionKey: string; passages: readonly LibraryPassage[] } // multi-select
|
||||
| { tag: 'BesluitSelected'; besluit: Besluit | null; reasons: readonly string[] } // recomposes the kern's passages
|
||||
| { tag: 'FreeTextBlockAdded'; sectionKey: string }
|
||||
| { tag: 'BlockContentEdited'; blockId: string; content: RichTextBlock }
|
||||
| { tag: 'BlockRemoved'; blockId: string }
|
||||
@@ -114,15 +116,11 @@ function withEdit(s: BriefState, f: (b: Brief) => Brief): BriefState {
|
||||
return { ...s, brief };
|
||||
}
|
||||
|
||||
function insertPassages(
|
||||
brief: Brief,
|
||||
sectionKey: string,
|
||||
passages: readonly LibraryPassage[],
|
||||
): Brief {
|
||||
function buildPassageBlocks(brief: Brief, passages: readonly LibraryPassage[]): LetterBlock[] {
|
||||
let idx = nextLocalIndex(brief);
|
||||
// The freeze happens HERE: each block gets a deep VALUE copy of the library content,
|
||||
// so later library edits can never mutate this letter (frozen snapshot).
|
||||
const newBlocks: LetterBlock[] = passages.map((p) => ({
|
||||
return passages.map((p) => ({
|
||||
type: 'passage',
|
||||
blockId: `local-${idx++}`,
|
||||
sourcePassageId: p.passageId,
|
||||
@@ -130,7 +128,26 @@ function insertPassages(
|
||||
content: deepCopyBlock(p.content),
|
||||
edited: false,
|
||||
}));
|
||||
return mapSection(brief, sectionKey, (s) => ({ ...s, blocks: [...s.blocks, ...newBlocks] }));
|
||||
}
|
||||
|
||||
/** Recompose the kern for a besluit selection: the besluit-driven passages (in reading
|
||||
order) followed by the drafter's free-text blocks. The kern's `passage` blocks are
|
||||
besluit-derived by construction, so replacing them wholesale is the reactive swap; the
|
||||
`freeText` blocks are the drafter's own remarks and survive.
|
||||
ponytail: free text always trails the besluit passages after a recompute. */
|
||||
function composeKern(
|
||||
brief: Brief,
|
||||
availablePassages: readonly LibraryPassage[],
|
||||
besluit: Besluit | null,
|
||||
reasons: readonly string[],
|
||||
): Brief {
|
||||
const besluitBlocks = besluit
|
||||
? buildPassageBlocks(brief, passagesForBesluit(availablePassages, besluit, reasons))
|
||||
: [];
|
||||
return mapSection(brief, 'kern', (s) => ({
|
||||
...s,
|
||||
blocks: [...besluitBlocks, ...s.blocks.filter((b) => b.type === 'freeText')],
|
||||
}));
|
||||
}
|
||||
|
||||
function addFreeText(brief: Brief, sectionKey: string): Brief {
|
||||
@@ -182,11 +199,13 @@ export function reduce(s: BriefState, m: BriefMsg): BriefState {
|
||||
case 'Seed':
|
||||
return m.state;
|
||||
|
||||
// Section-level guard (defense-in-depth): locked sections never accept edits, even if a
|
||||
// Msg reaches the reducer. The UI already hides controls for locked sections.
|
||||
case 'PassagesInserted':
|
||||
// The kern is besluit-driven: (re)compose its passages from the selection, keeping the
|
||||
// drafter's free text. `availablePassages` lives on the loaded state, so this stays pure.
|
||||
case 'BesluitSelected':
|
||||
return withEdit(s, (b) =>
|
||||
isSectionEditable(b, m.sectionKey) ? insertPassages(b, m.sectionKey, m.passages) : b,
|
||||
s.tag === 'loaded' && isSectionEditable(b, 'kern')
|
||||
? composeKern(b, s.availablePassages, m.besluit, m.reasons)
|
||||
: b,
|
||||
);
|
||||
case 'FreeTextBlockAdded':
|
||||
return withEdit(s, (b) =>
|
||||
|
||||
@@ -13,6 +13,9 @@ import { Diagnostic, lintPlaceholders, PlaceholderDef } from './placeholders';
|
||||
|
||||
export type PassageScope = 'global' | 'beroep';
|
||||
|
||||
/** The decision the behandelaar is communicating. Drives which passages are offered. */
|
||||
export type Besluit = 'positief' | 'negatief';
|
||||
|
||||
// Re-export placeholderKeysIn for one-import convenience at call sites.
|
||||
export { placeholderKeysIn };
|
||||
|
||||
@@ -25,7 +28,11 @@ export interface LibraryPassage {
|
||||
readonly label: string;
|
||||
readonly content: RichTextBlock;
|
||||
readonly version: number; // library version, for provenance only
|
||||
readonly isDefault?: boolean; // part of the "standaardbrief" (kern) starter set
|
||||
// Guided-drafting tags: the behandelaar picks a besluit + reden, and `passagesForBesluit`
|
||||
// (besluit.ts) filters to the matching passages. undefined besluit = shown for any
|
||||
// besluit; undefined reason = not reason-specific. See @brief/domain/besluit.
|
||||
readonly besluit?: Besluit;
|
||||
readonly reason?: string;
|
||||
}
|
||||
|
||||
/** A block inside a letter section: a frozen passage snapshot, or free text. */
|
||||
@@ -50,6 +57,8 @@ export interface LetterSection {
|
||||
readonly required: boolean;
|
||||
// Predefined template sections (aanhef, slot) arrive locked and prefilled — the drafter
|
||||
// composes only the unlocked section(s). The reducer refuses edits to locked sections.
|
||||
// These come from the case-type template (`Brief.templateId`), so e.g. the slot's closing
|
||||
// can differ per case type; the drafter never edits it, and it renders only in the preview.
|
||||
readonly locked: boolean;
|
||||
readonly blocks: readonly LetterBlock[];
|
||||
}
|
||||
@@ -109,6 +118,16 @@ export function canSubmit(brief: Brief): boolean {
|
||||
return brief.sections.every((s) => !s.required || s.blocks.length > 0);
|
||||
}
|
||||
|
||||
/** The case this letter concerns — the zorgverlener + aanvraag the behandelaar is
|
||||
handling. Server-joined onto the brief view (brief/ stays a shared-only leaf, so it
|
||||
can't read the registratie context directly). Header context only. */
|
||||
export interface CaseContext {
|
||||
readonly zorgverlenerNaam: string;
|
||||
readonly bigNummer: string;
|
||||
readonly beroep: string;
|
||||
readonly aanvraagReferentie: string;
|
||||
}
|
||||
|
||||
/** Server-computed decision flags for the acting principal + this brief's live
|
||||
status (PRD-0002 phase P1) — rendered as-is, never recomputed here. */
|
||||
export interface BriefDecisions {
|
||||
|
||||
@@ -51,6 +51,16 @@ const view: BriefViewDto = {
|
||||
content: { paragraphs: [{ nodes: [] }] },
|
||||
version: 1,
|
||||
},
|
||||
{
|
||||
passageId: 'p-neg-scholing',
|
||||
scope: 'global',
|
||||
sectionKey: 'kern',
|
||||
label: 'Onvoldoende scholing',
|
||||
content: { paragraphs: [{ nodes: [{ type: 'text', text: 'x' }] }] },
|
||||
version: 1,
|
||||
besluit: 'negatief',
|
||||
reason: 'onvoldoende_scholing',
|
||||
},
|
||||
],
|
||||
decisions: { canEdit: false, canApprove: true, canReject: true, canSend: false },
|
||||
orgTemplate: {
|
||||
@@ -65,6 +75,12 @@ const view: BriefViewDto = {
|
||||
margins: { topMm: 25, rightMm: 25, bottomMm: 25, leftMm: 25 },
|
||||
version: 1,
|
||||
},
|
||||
caseContext: {
|
||||
zorgverlenerNaam: 'Dr. A. (Anna) de Vries',
|
||||
bigNummer: '19012345601',
|
||||
beroep: 'arts',
|
||||
aanvraagReferentie: 'HER-2026-000842',
|
||||
},
|
||||
};
|
||||
|
||||
describe('brief.adapter parse boundary', () => {
|
||||
@@ -92,6 +108,26 @@ describe('brief.adapter parse boundary', () => {
|
||||
canReject: true,
|
||||
canSend: false,
|
||||
});
|
||||
// Guided-drafting tags survive the boundary; the untagged passage has neither.
|
||||
expect(r.value.availablePassages[0].besluit).toBeUndefined();
|
||||
expect(r.value.availablePassages[1]).toMatchObject({
|
||||
besluit: 'negatief',
|
||||
reason: 'onvoldoende_scholing',
|
||||
});
|
||||
expect(r.value.caseContext).toEqual({
|
||||
zorgverlenerNaam: 'Dr. A. (Anna) de Vries',
|
||||
bigNummer: '19012345601',
|
||||
beroep: 'arts',
|
||||
aanvraagReferentie: 'HER-2026-000842',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a view whose case context is missing or malformed', () => {
|
||||
expect(parseBriefView({ ...view, caseContext: undefined }).ok).toBe(false);
|
||||
expect(
|
||||
parseBriefView({ ...view, caseContext: { ...view.caseContext!, bigNummer: undefined as never } })
|
||||
.ok,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('parses the org template and drops a null logoDocumentId', () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
BriefDto,
|
||||
BriefStatusDto,
|
||||
BriefViewDto,
|
||||
CaseContextDto,
|
||||
LetterBlockDto,
|
||||
LetterSectionDto,
|
||||
LibraryPassageDto,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
Brief,
|
||||
BriefDecisions,
|
||||
BriefStatus,
|
||||
CaseContext,
|
||||
LetterBlock,
|
||||
LetterSection,
|
||||
LibraryPassage,
|
||||
@@ -40,6 +42,7 @@ export interface BriefView {
|
||||
readonly availablePassages: LibraryPassage[];
|
||||
readonly decisions: BriefDecisions;
|
||||
readonly orgTemplate: OrgTemplate;
|
||||
readonly caseContext: CaseContext;
|
||||
}
|
||||
|
||||
export const BRIEF_LOAD_FAILED = $localize`:@@brief.load.failed:De brief kon niet worden geladen.`;
|
||||
@@ -249,7 +252,25 @@ function parsePassage(dto: LibraryPassageDto): Result<string, LibraryPassage> {
|
||||
content: content.value,
|
||||
version: dto.version,
|
||||
...(dto.beroep != null ? { beroep: dto.beroep } : {}),
|
||||
...(dto.isDefault ? { isDefault: true } : {}),
|
||||
...(dto.besluit === 'positief' || dto.besluit === 'negatief' ? { besluit: dto.besluit } : {}),
|
||||
...(dto.reason != null ? { reason: dto.reason } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function parseCaseContext(dto: CaseContextDto | undefined): Result<string, CaseContext> {
|
||||
if (
|
||||
typeof dto?.zorgverlenerNaam !== 'string' ||
|
||||
typeof dto.bigNummer !== 'string' ||
|
||||
typeof dto.beroep !== 'string' ||
|
||||
typeof dto.aanvraagReferentie !== 'string'
|
||||
) {
|
||||
return err('brief-view: missing/invalid case context');
|
||||
}
|
||||
return ok({
|
||||
zorgverlenerNaam: dto.zorgverlenerNaam,
|
||||
bigNummer: dto.bigNummer,
|
||||
beroep: dto.beroep,
|
||||
aanvraagReferentie: dto.aanvraagReferentie,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -351,6 +372,8 @@ export function parseBriefView(dto: BriefViewDto): Result<string, BriefView> {
|
||||
if (!decisions.ok) return decisions;
|
||||
const orgTemplate = parseOrgTemplate(dto.orgTemplate);
|
||||
if (!orgTemplate.ok) return orgTemplate;
|
||||
const caseContext = parseCaseContext(dto.caseContext);
|
||||
if (!caseContext.ok) return caseContext;
|
||||
const availablePassages: LibraryPassage[] = [];
|
||||
for (const p of dto.availablePassages ?? []) {
|
||||
const parsed = parsePassage(p);
|
||||
@@ -362,6 +385,7 @@ export function parseBriefView(dto: BriefViewDto): Result<string, BriefView> {
|
||||
availablePassages,
|
||||
decisions: decisions.value,
|
||||
orgTemplate: orgTemplate.value,
|
||||
caseContext: caseContext.value,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import { Component, ElementRef, computed, input, output, viewChild } from '@angular/core';
|
||||
import { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||
import { StepperComponent } from '@shared/ui/stepper/stepper.component';
|
||||
import { Besluit, Brief, CaseContext, LibraryPassage } from '@brief/domain/brief';
|
||||
import { inferSelection } from '@brief/domain/besluit';
|
||||
import { Diagnostic } from '@brief/domain/placeholders';
|
||||
import { OrgTemplate } from '@brief/domain/org-template';
|
||||
import { BriefMsg } from '@brief/domain/brief.machine';
|
||||
import { LetterCanvasComponent } from '@brief/ui/letter-canvas/letter-canvas.component';
|
||||
import { DiagnosticsPanelComponent } from '@brief/ui/diagnostics-panel/diagnostics-panel.component';
|
||||
import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejection-comments.component';
|
||||
import { LetterEditorComponent } from '@brief/ui/letter-editor/letter-editor.component';
|
||||
import { BesluitPanelComponent } from '@brief/ui/besluit-panel/besluit-panel.component';
|
||||
|
||||
/** Organism: the behandelaar's drafting step. Frames "Brief opstellen" as one step in
|
||||
the case workflow — a case-context header + stepper (Beoordelen → Brief opstellen →
|
||||
Indienen, neighbours stubbed) — with the besluit-driven guidance, the lean letter
|
||||
editor, and an on-demand full-letter preview in a modal. Only ever renders for an
|
||||
editable brief (draft/rejected); the approver's read-only flow stays in
|
||||
letter-composer. Presentational: emits edit/submit/preview intents. */
|
||||
@Component({
|
||||
selector: 'app-behandel-scherm',
|
||||
imports: [
|
||||
ButtonComponent,
|
||||
HeadingComponent,
|
||||
StepperComponent,
|
||||
LetterCanvasComponent,
|
||||
DiagnosticsPanelComponent,
|
||||
RejectionCommentsComponent,
|
||||
LetterEditorComponent,
|
||||
BesluitPanelComponent,
|
||||
],
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.case-head {
|
||||
margin-block-end: var(--rhc-space-max-lg);
|
||||
padding: var(--rhc-space-max-md) var(--rhc-space-max-lg);
|
||||
border-inline-start: 4px solid var(--rhc-color-primary, var(--rhc-color-border-strong));
|
||||
background: var(--rhc-color-background-subtle, transparent);
|
||||
}
|
||||
.case-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--rhc-space-max-md);
|
||||
color: var(--rhc-color-foreground-subtle);
|
||||
}
|
||||
.step-body {
|
||||
display: grid;
|
||||
gap: var(--rhc-space-max-xl);
|
||||
margin-block-start: var(--rhc-space-max-lg);
|
||||
}
|
||||
.bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--rhc-space-max-md);
|
||||
align-items: center;
|
||||
margin-block-start: var(--rhc-space-max-xl);
|
||||
}
|
||||
dialog {
|
||||
border: none;
|
||||
border-radius: var(--rhc-radius-md, 4px);
|
||||
padding: 0;
|
||||
max-width: min(900px, 95vw);
|
||||
width: 100%;
|
||||
}
|
||||
dialog::backdrop {
|
||||
background: rgb(0 0 0 / 45%); /* token-ok: modal scrim, not a palette colour */
|
||||
}
|
||||
.modal-body {
|
||||
padding: var(--rhc-space-max-lg);
|
||||
max-height: 80vh;
|
||||
overflow: auto;
|
||||
}
|
||||
.modal-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--rhc-space-max-md);
|
||||
padding: var(--rhc-space-max-md) var(--rhc-space-max-lg);
|
||||
border-block-start: 1px solid var(--rhc-color-border);
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<div class="case-head">
|
||||
<app-heading [level]="2">{{ caseHeading() }}</app-heading>
|
||||
<div class="case-meta">
|
||||
<span>{{ caseContext().aanvraagReferentie }}</span>
|
||||
<span>{{ caseContext().zorgverlenerNaam }}</span>
|
||||
<span>{{ bigLabel() }} {{ caseContext().bigNummer }}</span>
|
||||
<span>{{ caseContext().beroep }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<app-stepper
|
||||
[steps]="steps()"
|
||||
[current]="1"
|
||||
[processName]="processName()"
|
||||
[stepTitle]="stepTitle()"
|
||||
/>
|
||||
|
||||
<div class="step-body">
|
||||
@if (status() === 'rejected') {
|
||||
<app-rejection-comments mode="show" [comments]="rejectComments()" />
|
||||
}
|
||||
|
||||
<app-besluit-panel
|
||||
[passages]="availablePassages()"
|
||||
[besluit]="selection().besluit"
|
||||
[initialRedenen]="selection().reasons"
|
||||
(selectionChange)="onSelection($event)"
|
||||
/>
|
||||
|
||||
<app-letter-editor [brief]="brief()" [placeholders]="menu()" (edit)="edit.emit($event)" />
|
||||
|
||||
<app-diagnostics-panel [diagnostics]="diagnostics()" (locate)="locate.emit($event)" />
|
||||
|
||||
<div class="bar">
|
||||
<app-button variant="subtle" (click)="openPreview()">{{ previewLabel() }}</app-button>
|
||||
<app-button
|
||||
variant="primary"
|
||||
[disabled]="!canSubmit() || busy()"
|
||||
(click)="submit.emit()"
|
||||
>{{ submitLabel() }}</app-button
|
||||
>
|
||||
@if (!canSubmit()) {
|
||||
<span class="app-text-subtle">{{ submitHint() }}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dialog #previewDialog>
|
||||
<div class="modal-body">
|
||||
<app-letter-canvas
|
||||
[brief]="brief()"
|
||||
[orgTemplate]="orgTemplate()"
|
||||
[logoUrl]="logoUrl()"
|
||||
[editableRegions]="'none'"
|
||||
[diagnostics]="diagnostics()"
|
||||
/>
|
||||
</div>
|
||||
<div class="modal-bar">
|
||||
<app-button variant="secondary" (click)="preview.emit()">{{ openDocumentLabel() }}</app-button>
|
||||
<app-button variant="primary" (click)="closePreview()">{{ closeLabel() }}</app-button>
|
||||
</div>
|
||||
</dialog>
|
||||
`,
|
||||
})
|
||||
export class BehandelSchermComponent {
|
||||
brief = input.required<Brief>();
|
||||
orgTemplate = input.required<OrgTemplate>();
|
||||
logoUrl = input<string | null>(null);
|
||||
availablePassages = input<readonly LibraryPassage[]>([]);
|
||||
diagnostics = input<readonly Diagnostic[]>([]);
|
||||
caseContext = input.required<CaseContext>();
|
||||
canSubmit = input(false);
|
||||
busy = input(false);
|
||||
|
||||
edit = output<BriefMsg>();
|
||||
submit = output<void>();
|
||||
preview = output<void>();
|
||||
locate = output<Diagnostic>();
|
||||
|
||||
private previewDialog = viewChild<ElementRef<HTMLDialogElement>>('previewDialog');
|
||||
|
||||
protected status = computed(() => this.brief().status.tag);
|
||||
protected rejectComments = computed(() => {
|
||||
const s = this.brief().status;
|
||||
return s.tag === 'rejected' ? s.comments : '';
|
||||
});
|
||||
|
||||
/** The besluit + redenen the letter currently reflects, read back off the kern's
|
||||
passages — this seeds the panel so it survives reload/undo (no separate storage). */
|
||||
protected selection = computed(() => {
|
||||
const kern = this.brief().sections.find((s) => s.sectionKey === 'kern');
|
||||
return inferSelection(kern?.blocks ?? [], this.availablePassages());
|
||||
});
|
||||
|
||||
// Same insert menu as the composer: only valid, fillable, non-deprecated fields.
|
||||
protected menu = computed<PlaceholderOption[]>(() =>
|
||||
this.brief()
|
||||
.placeholders.filter((p) => p.fillable !== false && !p.deprecated)
|
||||
.map((p) => ({ key: p.key, label: p.label, autoResolvable: p.autoResolvable })),
|
||||
);
|
||||
|
||||
/** Besluit/redenen changed → recompose the kern as one edit (= one undo step). */
|
||||
protected onSelection(sel: { besluit: Besluit | null; reasons: string[] }) {
|
||||
this.edit.emit({ tag: 'BesluitSelected', besluit: sel.besluit, reasons: sel.reasons });
|
||||
}
|
||||
|
||||
protected openPreview() {
|
||||
this.previewDialog()?.nativeElement.showModal();
|
||||
}
|
||||
protected closePreview() {
|
||||
this.previewDialog()?.nativeElement.close();
|
||||
}
|
||||
|
||||
protected submitLabel = computed(() =>
|
||||
this.status() === 'rejected'
|
||||
? $localize`:@@brief.resubmit:Opnieuw indienen`
|
||||
: $localize`:@@brief.submit:Indienen ter beoordeling`,
|
||||
);
|
||||
|
||||
protected steps = input<string[]>([
|
||||
$localize`:@@brief.step.beoordelen:Beoordelen`,
|
||||
$localize`:@@brief.step.opstellen:Brief opstellen`,
|
||||
$localize`:@@brief.step.indienen:Indienen`,
|
||||
]);
|
||||
protected processName = input($localize`:@@brief.process:Herregistratie behandelen`);
|
||||
protected stepTitle = input($localize`:@@brief.step.opstellen:Brief opstellen`);
|
||||
protected caseHeading = input($localize`:@@brief.case.heading:Aanvraag herregistratie`);
|
||||
protected bigLabel = input($localize`:@@brief.case.big:BIG-nummer`);
|
||||
protected previewLabel = input($localize`:@@brief.preview.open:Voorbeeld`);
|
||||
protected openDocumentLabel = input(
|
||||
$localize`:@@brief.preview.openDocument:Openen als document (PDF)`,
|
||||
);
|
||||
protected closeLabel = input($localize`:@@common.close:Sluiten`);
|
||||
protected submitHint = input(
|
||||
$localize`:@@brief.submitHint:Vul eerst alle verplichte secties en los fouten op.`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { Brief, BriefStatus, CaseContext, LibraryPassage, allDiagnostics } from '@brief/domain/brief';
|
||||
import { OrgTemplate } from '@brief/domain/org-template';
|
||||
import { BehandelSchermComponent } from './behandel-scherm.component';
|
||||
|
||||
const text = (t: string): LibraryPassage['content'] => ({ paragraphs: [{ nodes: [{ type: 'text', text: t }] }] });
|
||||
|
||||
const orgTemplate: OrgTemplate = {
|
||||
subOrgId: 'cibg-registers',
|
||||
orgName: 'CIBG — Registers',
|
||||
returnAddress: 'BIG-register\nPostbus 00000\n2500 AA Den Haag',
|
||||
footerContact: 'www.bigregister.nl',
|
||||
footerLegal: 'Ons kenmerk vermelden bij correspondentie.',
|
||||
signatureName: 'A. de Vries',
|
||||
signatureRole: 'Hoofd Registratie',
|
||||
signatureClosing: 'Met vriendelijke groet,',
|
||||
margins: { topMm: 25, rightMm: 25, bottomMm: 25, leftMm: 25 },
|
||||
version: 1,
|
||||
};
|
||||
|
||||
const caseContext: CaseContext = {
|
||||
zorgverlenerNaam: 'Dr. A. (Anna) de Vries',
|
||||
bigNummer: '19012345601',
|
||||
beroep: 'arts',
|
||||
aanvraagReferentie: 'HER-2026-000842',
|
||||
};
|
||||
|
||||
const passages: LibraryPassage[] = [
|
||||
{ passageId: 'p-kern-positief', scope: 'global', sectionKey: 'kern', label: 'Toewijzing', version: 1, besluit: 'positief', content: text('Uw aanvraag is toegewezen.') },
|
||||
{ passageId: 'p-kern-negatief', scope: 'global', sectionKey: 'kern', label: 'Afwijzing', version: 1, besluit: 'negatief', content: text('Uw aanvraag is afgewezen.') },
|
||||
{ passageId: 'p-kern-scholing', scope: 'global', sectionKey: 'kern', label: 'Onvoldoende scholing', version: 1, besluit: 'negatief', reason: 'onvoldoende_scholing', content: text('Onvoldoende scholing.') },
|
||||
];
|
||||
|
||||
function brief(status: BriefStatus, kernBlocks: Brief['sections'][number]['blocks'] = []): Brief {
|
||||
return {
|
||||
briefId: 'b1',
|
||||
beroep: 'arts',
|
||||
templateId: 't1',
|
||||
drafterId: 'demo-drafter',
|
||||
status,
|
||||
placeholders: [
|
||||
{ key: 'naam_zorgverlener', label: 'Naam zorgverlener', autoResolvable: true },
|
||||
{ key: 'reden_besluit', label: 'Reden besluit', autoResolvable: false },
|
||||
],
|
||||
sections: [
|
||||
{ sectionKey: 'aanhef', title: 'Aanhef', required: true, locked: true, blocks: [{ type: 'freeText', blockId: 'aanhef-1', content: text('Geachte heer/mevrouw,') }] },
|
||||
{ sectionKey: 'kern', title: 'Kern van het besluit', required: true, locked: false, blocks: kernBlocks },
|
||||
{ sectionKey: 'slot', title: 'Slot', required: false, locked: true, blocks: [{ type: 'freeText', blockId: 'slot-1', content: text('Met vriendelijke groet,') }] },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const meta: Meta<BehandelSchermComponent> = {
|
||||
title: 'Domein/Brief/Behandel Scherm',
|
||||
component: BehandelSchermComponent,
|
||||
args: { orgTemplate, caseContext, availablePassages: passages, busy: false },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<BehandelSchermComponent>;
|
||||
|
||||
/** Fresh case: no besluit chosen yet, so the kern is empty and only the editable
|
||||
body shows (aanhef/slot appear in the preview). */
|
||||
export const EmptyKern: Story = {
|
||||
args: { brief: brief({ tag: 'draft' }), diagnostics: [], canSubmit: false },
|
||||
};
|
||||
|
||||
// A besluit-sourced kern block (carries provenance), so the panel re-seeds itself from it.
|
||||
const negatiefScholingKern: Brief['sections'][number]['blocks'] = [
|
||||
{ type: 'passage', blockId: 'local-1', sourcePassageId: 'p-kern-negatief', sourceVersion: 1, edited: false, content: text('Uw aanvraag is afgewezen.') },
|
||||
{ type: 'passage', blockId: 'local-2', sourcePassageId: 'p-kern-scholing', sourceVersion: 1, edited: false, content: text('Onvoldoende scholing.') },
|
||||
];
|
||||
|
||||
/** Draft with a negatief besluit: the kern is filled from the selection and the besluit
|
||||
panel reflects it (negatief + "onvoldoende scholing" ticked), read back off the kern. */
|
||||
export const WithContent: Story = {
|
||||
render: (args) => {
|
||||
const b = brief({ tag: 'draft' }, negatiefScholingKern);
|
||||
return { props: { ...args, brief: b, diagnostics: allDiagnostics(b), canSubmit: true } };
|
||||
},
|
||||
};
|
||||
|
||||
/** Rejected: the drafter reopens; the rejection comments show above the editor. */
|
||||
export const Rejected: Story = {
|
||||
render: (args) => {
|
||||
const b = brief(
|
||||
{ tag: 'rejected', rejectedBy: 'demo-approver', rejectedAt: '2026-07-01', comments: 'Graag de reden concreter.' },
|
||||
negatiefScholingKern,
|
||||
);
|
||||
return { props: { ...args, brief: b, diagnostics: allDiagnostics(b), canSubmit: true } };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
import { Component, computed, input, linkedSignal, output } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { CheckboxComponent } from '@shared/ui/checkbox/checkbox.component';
|
||||
import { RadioGroupComponent, RadioOption } from '@shared/ui/radio-group/radio-group.component';
|
||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||
import { Besluit, LibraryPassage } from '@brief/domain/brief';
|
||||
import { redenenFor } from '@brief/domain/besluit';
|
||||
|
||||
/** Organism: the guided-drafting selector. The behandelaar picks the besluit
|
||||
(positief/negatief) and — for a negatief besluit — the reden(en); the kern's
|
||||
standaardteksten follow the selection LIVE (`selectionChange` → the store recomposes
|
||||
the kern). This is the "no detective work" step: which passages belong is decided by
|
||||
the besluit, not by the drafter hunting the library.
|
||||
|
||||
ponytail: view-state signals, not a form-machine — no validation/submission of its own;
|
||||
it just reports the selection. `besluit`/`redenen` inputs re-seed it (via linkedSignal)
|
||||
from the persisted letter on reload/undo, so it always reflects the letter's real state. */
|
||||
@Component({
|
||||
selector: 'app-besluit-panel',
|
||||
imports: [FormsModule, CheckboxComponent, RadioGroupComponent, HeadingComponent],
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
padding: var(--rhc-space-max-lg);
|
||||
border: 1px solid var(--rhc-color-border);
|
||||
border-radius: var(--rhc-radius-md, 4px);
|
||||
background: var(--rhc-color-background-subtle, transparent);
|
||||
}
|
||||
.redenen {
|
||||
display: grid;
|
||||
gap: var(--rhc-space-max-sm);
|
||||
margin-block-start: var(--rhc-space-max-md);
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<app-heading [level]="3">{{ heading() }}</app-heading>
|
||||
<p class="app-text-subtle">{{ intro() }}</p>
|
||||
|
||||
<app-radio-group
|
||||
name="besluit"
|
||||
[options]="besluitOptions()"
|
||||
[ngModel]="selected()"
|
||||
(ngModelChange)="onBesluit($event)"
|
||||
/>
|
||||
|
||||
@if (redenen().length > 0) {
|
||||
<div class="redenen" role="group" [attr.aria-label]="redenenLabel()">
|
||||
@for (r of redenen(); track r.code) {
|
||||
<app-checkbox
|
||||
[checkboxId]="'reden-' + r.code"
|
||||
[label]="r.label"
|
||||
[ngModel]="checked().has(r.code)"
|
||||
(ngModelChange)="toggle(r.code, $event)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class BesluitPanelComponent {
|
||||
/** The passage library — the redenen checkboxes are derived from its negatief tags. */
|
||||
passages = input<readonly LibraryPassage[]>([]);
|
||||
/** The letter's current selection, inferred from its kern passages — re-seeds the panel. */
|
||||
besluit = input<Besluit | null>(null);
|
||||
initialRedenen = input<readonly string[]>([]);
|
||||
|
||||
selectionChange = output<{ besluit: Besluit | null; reasons: string[] }>();
|
||||
|
||||
protected selected = linkedSignal<Besluit | ''>(() => this.besluit() ?? '');
|
||||
protected checked = linkedSignal<ReadonlySet<string>>(() => new Set(this.initialRedenen()));
|
||||
|
||||
/** The reden checkboxes for the chosen besluit — derived from the reason-tagged passages. */
|
||||
protected redenen = computed(() =>
|
||||
this.selected() === '' ? [] : redenenFor(this.passages(), this.selected() as Besluit),
|
||||
);
|
||||
|
||||
protected onBesluit(value: string) {
|
||||
this.selected.set(value === 'positief' || value === 'negatief' ? value : '');
|
||||
this.checked.set(new Set()); // redenen only apply to the chosen besluit
|
||||
this.emit();
|
||||
}
|
||||
|
||||
protected toggle(code: string, on: boolean) {
|
||||
const next = new Set(this.checked());
|
||||
if (on) next.add(code);
|
||||
else next.delete(code);
|
||||
this.checked.set(next);
|
||||
this.emit();
|
||||
}
|
||||
|
||||
private emit() {
|
||||
this.selectionChange.emit({
|
||||
besluit: this.selected() === '' ? null : (this.selected() as Besluit),
|
||||
reasons: [...this.checked()],
|
||||
});
|
||||
}
|
||||
|
||||
protected besluitOptions = input<RadioOption[]>([
|
||||
{ value: 'positief', label: $localize`:@@brief.besluit.positief:Positief besluit (toewijzen)` },
|
||||
{ value: 'negatief', label: $localize`:@@brief.besluit.negatief:Negatief besluit (afwijzen)` },
|
||||
]);
|
||||
protected heading = input($localize`:@@brief.besluit.heading:Besluit`);
|
||||
protected intro = input(
|
||||
$localize`:@@brief.besluit.intro:Kies het besluit; de juiste standaardteksten verschijnen meteen in de brief.`,
|
||||
);
|
||||
protected redenenLabel = input($localize`:@@brief.besluit.redenen:Reden(en) voor afwijzing`);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { LibraryPassage } from '@brief/domain/brief';
|
||||
import { BesluitPanelComponent } from './besluit-panel.component';
|
||||
|
||||
const text = (t: string): LibraryPassage['content'] => ({ paragraphs: [{ nodes: [{ type: 'text', text: t }] }] });
|
||||
|
||||
const passages: LibraryPassage[] = [
|
||||
{ passageId: 'p-kern-positief', scope: 'global', sectionKey: 'kern', label: 'Toewijzing', version: 1, besluit: 'positief', content: text('Toegewezen.') },
|
||||
{ passageId: 'p-kern-negatief', scope: 'global', sectionKey: 'kern', label: 'Afwijzing', version: 1, besluit: 'negatief', content: text('Afgewezen.') },
|
||||
{ passageId: 'p-kern-scholing', scope: 'global', sectionKey: 'kern', label: 'Onvoldoende scholing', version: 1, besluit: 'negatief', reason: 'onvoldoende_scholing', content: text('Onvoldoende scholing.') },
|
||||
{ passageId: 'p-kern-gegevens', scope: 'global', sectionKey: 'kern', label: 'Onjuiste gegevens', version: 1, besluit: 'negatief', reason: 'onjuiste_gegevens', content: text('Onjuiste gegevens.') },
|
||||
];
|
||||
|
||||
const meta: Meta<BesluitPanelComponent> = {
|
||||
title: 'Domein/Brief/Besluit Panel',
|
||||
component: BesluitPanelComponent,
|
||||
args: { passages },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<BesluitPanelComponent>;
|
||||
|
||||
/** Pick a besluit; a negatief besluit reveals the reason checkboxes. Each change emits
|
||||
`selectionChange` and the kern's standaardteksten follow live — no generate button. */
|
||||
export const Default: Story = {};
|
||||
|
||||
/** Re-seeded from a persisted letter: a negatief besluit with a reden already ticked. */
|
||||
export const NegatiefMetReden: Story = {
|
||||
args: { besluit: 'negatief', initialRedenen: ['onvoldoende_scholing'] },
|
||||
};
|
||||
@@ -5,13 +5,21 @@ import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
import { ASYNC } from '@shared/ui/async/async.component';
|
||||
import { BriefStore } from '@brief/application/brief.store';
|
||||
import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-composer.component';
|
||||
import { BehandelSchermComponent } from '@brief/ui/behandel-scherm/behandel-scherm.component';
|
||||
|
||||
/** Page: thin container. Injects the root store, kicks off the load, and passes its
|
||||
derived read-model to the composer. Business/UI logic lives below in pure pieces;
|
||||
this just wires signals to the organism and events back to store commands. */
|
||||
@Component({
|
||||
selector: 'app-brief-page',
|
||||
imports: [PageShellComponent, AlertComponent, ButtonComponent, ...ASYNC, LetterComposerComponent],
|
||||
imports: [
|
||||
PageShellComponent,
|
||||
AlertComponent,
|
||||
ButtonComponent,
|
||||
...ASYNC,
|
||||
LetterComposerComponent,
|
||||
BehandelSchermComponent,
|
||||
],
|
||||
host: { '(document:keydown)': 'onKey($event)' },
|
||||
styles: [
|
||||
`
|
||||
@@ -76,27 +84,40 @@ import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-compos
|
||||
resetLabel
|
||||
}}</app-button>
|
||||
</div>
|
||||
<app-letter-composer
|
||||
[brief]="s.brief"
|
||||
[orgTemplate]="orgTemplate"
|
||||
[logoUrl]="store.logoUrl()"
|
||||
[availablePassages]="s.availablePassages"
|
||||
[diagnostics]="store.diagnostics()"
|
||||
[blockDiffs]="store.blockDiffs()"
|
||||
[removedCount]="store.removedSinceReject()"
|
||||
[canEdit]="store.canEdit()"
|
||||
[canApprove]="store.canApprove()"
|
||||
[canReject]="store.canReject()"
|
||||
[canSend]="store.canSend()"
|
||||
[canSubmit]="store.canSubmit()"
|
||||
[busy]="store.busy()"
|
||||
(edit)="store.edit($event)"
|
||||
(submit)="store.submit()"
|
||||
(approve)="store.approve()"
|
||||
(reject)="store.reject($event)"
|
||||
(send)="store.send()"
|
||||
(preview)="store.previewLetter()"
|
||||
/>
|
||||
@if (store.canEdit() && store.caseContext(); as caseContext) {
|
||||
<!-- Drafter (behandelaar): guided drafting step in the case workflow. -->
|
||||
<app-behandel-scherm
|
||||
[brief]="s.brief"
|
||||
[orgTemplate]="orgTemplate"
|
||||
[logoUrl]="store.logoUrl()"
|
||||
[availablePassages]="s.availablePassages"
|
||||
[diagnostics]="store.diagnostics()"
|
||||
[caseContext]="caseContext"
|
||||
[canSubmit]="store.canSubmit()"
|
||||
[busy]="store.busy()"
|
||||
(edit)="store.edit($event)"
|
||||
(submit)="store.submit()"
|
||||
(preview)="store.previewLetter()"
|
||||
/>
|
||||
} @else {
|
||||
<!-- Approver / read-only: review + approve/reject/send. -->
|
||||
<app-letter-composer
|
||||
[brief]="s.brief"
|
||||
[orgTemplate]="orgTemplate"
|
||||
[logoUrl]="store.logoUrl()"
|
||||
[diagnostics]="store.diagnostics()"
|
||||
[blockDiffs]="store.blockDiffs()"
|
||||
[removedCount]="store.removedSinceReject()"
|
||||
[canApprove]="store.canApprove()"
|
||||
[canReject]="store.canReject()"
|
||||
[canSend]="store.canSend()"
|
||||
[busy]="store.busy()"
|
||||
(approve)="store.approve()"
|
||||
(reject)="store.reject($event)"
|
||||
(send)="store.send()"
|
||||
(preview)="store.previewLetter()"
|
||||
/>
|
||||
}
|
||||
}
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
@@ -14,16 +14,13 @@ import {
|
||||
import { NgTemplateOutlet } from '@angular/common';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
import { PlaceholderChipComponent } from '@shared/ui/placeholder-chip/placeholder-chip.component';
|
||||
import { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component';
|
||||
import { formatDatumNl } from '@shared/kernel/datum';
|
||||
import { Paragraph } from '@shared/kernel/rich-text';
|
||||
import { Brief, LetterBlock, LibraryPassage } from '@brief/domain/brief';
|
||||
import { Brief, LetterBlock } from '@brief/domain/brief';
|
||||
import { OrgTemplate } from '@brief/domain/org-template';
|
||||
import { OrgTemplateTextField } from '@brief/domain/org-template.machine';
|
||||
import { Diagnostic } from '@brief/domain/placeholders';
|
||||
import { BriefMsg } from '@brief/domain/brief.machine';
|
||||
import { BlockDiffKind } from '@brief/domain/brief-diff';
|
||||
import { LetterSectionComponent } from '@brief/ui/letter-section/letter-section.component';
|
||||
|
||||
/** A run of consecutive lines to render together: a list (bullet/number) or a single plain line. */
|
||||
type PreviewSegment = {
|
||||
@@ -60,7 +57,7 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
||||
the same file the backend preview renderer inlines (WP-25). */
|
||||
@Component({
|
||||
selector: 'app-letter-canvas',
|
||||
imports: [NgTemplateOutlet, ButtonComponent, PlaceholderChipComponent, LetterSectionComponent],
|
||||
imports: [NgTemplateOutlet, ButtonComponent, PlaceholderChipComponent],
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
@@ -113,17 +110,6 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
||||
.surface .letter {
|
||||
box-shadow: 0 1px 4px rgb(0 0 0 / 0.15); /* token-ok: paper drop-shadow, not a palette colour */
|
||||
}
|
||||
/* Org-identity regions: visibly not the drafter's to edit. */
|
||||
.from-template {
|
||||
background: var(--rhc-color-grijs-100);
|
||||
outline: 2mm solid var(--rhc-color-grijs-100);
|
||||
}
|
||||
.from-template-caption {
|
||||
font-size: 7.5pt;
|
||||
/* grijs-700, not foreground-subtle: subtle misses AA contrast on the tint. */
|
||||
color: var(--rhc-color-grijs-700);
|
||||
margin: 0 0 2mm;
|
||||
}
|
||||
/* Admin edit-in-place (editableRegions='template'): the org-identity fields
|
||||
become controls styled to sit in the letter, with a visible editable affordance. */
|
||||
.tmpl-input,
|
||||
@@ -208,10 +194,7 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
||||
<div class="letter" #page [style]="marginStyle()" [style.zoom]="zoomLevel()">
|
||||
<!-- div, not <header>/<footer>: the CIBG huisstijl styles those bare elements
|
||||
(robijn footer background) — the letter surface must stay letter.css-only. -->
|
||||
<div class="letter__letterhead" [class.from-template]="tintTemplate()">
|
||||
@if (tintTemplate()) {
|
||||
<p class="from-template-caption">{{ fromTemplateCaption() }}</p>
|
||||
}
|
||||
<div class="letter__letterhead">
|
||||
@if (logoUrl()) {
|
||||
<img class="org-logo" [src]="logoUrl()" [alt]="logoAlt()" />
|
||||
}
|
||||
@@ -247,31 +230,18 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
||||
</div>
|
||||
|
||||
<div class="letter__body">
|
||||
@if (editableRegions() === 'content') {
|
||||
@for (section of brief().sections; track section.sectionKey) {
|
||||
<section>
|
||||
<app-letter-section
|
||||
[section]="section"
|
||||
[availablePassages]="availablePassages()"
|
||||
[placeholders]="placeholders()"
|
||||
[editable]="!section.locked"
|
||||
(edit)="edit.emit($event)"
|
||||
/>
|
||||
</section>
|
||||
}
|
||||
} @else {
|
||||
@for (section of brief().sections; track section.sectionKey) {
|
||||
<section>
|
||||
<h3>{{ section.title }}</h3>
|
||||
@for (block of section.blocks; track block.blockId) {
|
||||
@let diffKind = showDiff() ? blockDiffs().get(block.blockId) : undefined;
|
||||
<div class="diff-block" [class.diff-changed]="!!diffKind">
|
||||
@if (diffKind) {
|
||||
<span class="diff-badge" [class.added]="diffKind === 'added'">{{
|
||||
diffLabel(diffKind)
|
||||
}}</span>
|
||||
}
|
||||
@for (seg of segmentsOf(block); track $index) {
|
||||
@for (section of brief().sections; track section.sectionKey) {
|
||||
<section>
|
||||
<h3>{{ section.title }}</h3>
|
||||
@for (block of section.blocks; track block.blockId) {
|
||||
@let diffKind = showDiff() ? blockDiffs().get(block.blockId) : undefined;
|
||||
<div class="diff-block" [class.diff-changed]="!!diffKind">
|
||||
@if (diffKind) {
|
||||
<span class="diff-badge" [class.added]="diffKind === 'added'">{{
|
||||
diffLabel(diffKind)
|
||||
}}</span>
|
||||
}
|
||||
@for (seg of segmentsOf(block); track $index) {
|
||||
@if (seg.list === 'bullet') {
|
||||
<ul>
|
||||
@for (para of seg.items; track $index) {
|
||||
@@ -302,15 +272,14 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
||||
/>
|
||||
</p>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="letter__signature" [class.from-template]="tintTemplate()">
|
||||
<div class="letter__signature">
|
||||
@if (editing()) {
|
||||
<input
|
||||
class="tmpl-input"
|
||||
@@ -337,7 +306,7 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="letter__footer" [class.from-template]="tintTemplate()">
|
||||
<div class="letter__footer">
|
||||
@if (editing()) {
|
||||
<textarea
|
||||
class="tmpl-textarea footer-contact"
|
||||
@@ -370,11 +339,9 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
||||
export class LetterCanvasComponent {
|
||||
brief = input.required<Brief>();
|
||||
orgTemplate = input.required<OrgTemplate>();
|
||||
/** Who edits what on the surface: drafter ('content'), read-only ('none'),
|
||||
admin editor ('template', consumer arrives in WP-26). */
|
||||
editableRegions = input<'content' | 'template' | 'none'>('none');
|
||||
availablePassages = input<readonly LibraryPassage[]>([]);
|
||||
placeholders = input<readonly PlaceholderOption[]>([]);
|
||||
/** Who edits what on the surface: read-only ('none', the drafter preview + approver
|
||||
view) or admin editor ('template', WP-26). Authoring moved to letter-editor. */
|
||||
editableRegions = input<'template' | 'none'>('none');
|
||||
diagnostics = input<readonly Diagnostic[]>([]);
|
||||
/** Initial zoom; the in-canvas controls take over from here (WP-27). */
|
||||
zoom = input(1);
|
||||
@@ -385,15 +352,11 @@ export class LetterCanvasComponent {
|
||||
showDiff = input(false);
|
||||
/** The org logo's content URL (letterhead), or null when none is set. */
|
||||
logoUrl = input<string | null>(null);
|
||||
edit = output<BriefMsg>();
|
||||
/** An in-place edit to an org-identity field (only in `editableRegions='template'`). */
|
||||
templateEdit = output<{ field: OrgTemplateTextField; value: string }>();
|
||||
|
||||
showSampleLabel = input($localize`:@@brief.preview.showSample:Voorbeeld met testwaarden`);
|
||||
hideSampleLabel = input($localize`:@@brief.preview.hideSample:Testwaarden verbergen`);
|
||||
fromTemplateCaption = input(
|
||||
$localize`:@@brief.canvas.fromTemplate:Komt uit de huisstijl van de organisatie — niet bewerkbaar in de brief.`,
|
||||
);
|
||||
pageBreakCaption = input(
|
||||
$localize`:@@brief.canvas.pageBreak:±pagina-einde — afdrukvoorbeeld is leidend`,
|
||||
);
|
||||
@@ -430,9 +393,6 @@ export class LetterCanvasComponent {
|
||||
protected diffLabel = (kind: BlockDiffKind) =>
|
||||
kind === 'added' ? this.addedLabel() : this.changedLabel();
|
||||
|
||||
/** The letterhead/signature/footer are tinted "not yours" only while composing —
|
||||
in 'none' the whole surface is read-only, in 'template' they ARE the editable focus. */
|
||||
protected tintTemplate = computed(() => this.editableRegions() === 'content');
|
||||
/** Admin edit-in-place: the org-identity regions render as controls. */
|
||||
protected editing = computed(() => this.editableRegions() === 'template');
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { Brief, LibraryPassage, allDiagnostics } from '@brief/domain/brief';
|
||||
import { Brief, allDiagnostics } from '@brief/domain/brief';
|
||||
import { OrgTemplate } from '@brief/domain/org-template';
|
||||
import { LetterCanvasComponent } from './letter-canvas.component';
|
||||
|
||||
@@ -16,17 +16,6 @@ const orgTemplate: OrgTemplate = {
|
||||
version: 1,
|
||||
};
|
||||
|
||||
const passages: LibraryPassage[] = [
|
||||
{
|
||||
passageId: 'p1',
|
||||
scope: 'global',
|
||||
sectionKey: 'kern',
|
||||
label: 'Standaard toelichting',
|
||||
version: 1,
|
||||
content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Standaardtekst.' }] }] },
|
||||
},
|
||||
];
|
||||
|
||||
const brief: Brief = {
|
||||
briefId: 'b1',
|
||||
beroep: 'arts',
|
||||
@@ -124,19 +113,14 @@ const meta: Meta<LetterCanvasComponent> = {
|
||||
args: {
|
||||
brief,
|
||||
orgTemplate,
|
||||
availablePassages: passages,
|
||||
placeholders: brief.placeholders,
|
||||
diagnostics: allDiagnostics(brief),
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<LetterCanvasComponent>;
|
||||
|
||||
/** Drafter: content blocks editable in place; org-identity regions tinted read-only. */
|
||||
export const ContentMode: Story = { args: { editableRegions: 'content' } };
|
||||
|
||||
/** Approver/locked: the identical surface fully read-only, with the sample-values
|
||||
toggle and diagnostic placeholder chips (absorbs the old Letter Preview). */
|
||||
/** Read-only rendered letter: the drafter's preview modal and the approver view, with the
|
||||
sample-values toggle and diagnostic placeholder chips (absorbs the old Letter Preview). */
|
||||
export const ReadOnly: Story = { args: { editableRegions: 'none' } };
|
||||
|
||||
export const ReadOnlyZonderBevindingen: Story = {
|
||||
|
||||
@@ -3,11 +3,9 @@ import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||
import { StatusBadgeComponent } from '@shared/ui/status-badge/status-badge.component';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||
import { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component';
|
||||
import { Brief, LibraryPassage } from '@brief/domain/brief';
|
||||
import { Brief } from '@brief/domain/brief';
|
||||
import { OrgTemplate } from '@brief/domain/org-template';
|
||||
import { Diagnostic } from '@brief/domain/placeholders';
|
||||
import { BriefMsg } from '@brief/domain/brief.machine';
|
||||
import { BlockDiffKind } from '@brief/domain/brief-diff';
|
||||
import { LetterCanvasComponent } from '@brief/ui/letter-canvas/letter-canvas.component';
|
||||
import { DiagnosticsPanelComponent } from '@brief/ui/diagnostics-panel/diagnostics-panel.component';
|
||||
@@ -90,13 +88,10 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
|
||||
[brief]="brief()"
|
||||
[orgTemplate]="orgTemplate()"
|
||||
[logoUrl]="logoUrl()"
|
||||
[editableRegions]="canEdit() ? 'content' : 'none'"
|
||||
[availablePassages]="availablePassages()"
|
||||
[placeholders]="menu()"
|
||||
[editableRegions]="'none'"
|
||||
[diagnostics]="diagnostics()"
|
||||
[blockDiffs]="blockDiffs()"
|
||||
[showDiff]="showDiff()"
|
||||
(edit)="edit.emit($event)"
|
||||
/>
|
||||
|
||||
<div class="panel">
|
||||
@@ -105,29 +100,6 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
|
||||
|
||||
<div class="bar">
|
||||
@switch (status()) {
|
||||
@case ('draft') {
|
||||
@if (canEdit()) {
|
||||
<app-button
|
||||
variant="primary"
|
||||
[disabled]="!canSubmit() || busy()"
|
||||
(click)="submit.emit()"
|
||||
>{{ submitLabel() }}</app-button
|
||||
>
|
||||
@if (!canSubmit()) {
|
||||
<span class="app-text-subtle">{{ submitHint() }}</span>
|
||||
}
|
||||
}
|
||||
}
|
||||
@case ('rejected') {
|
||||
@if (canEdit()) {
|
||||
<app-button
|
||||
variant="primary"
|
||||
[disabled]="!canSubmit() || busy()"
|
||||
(click)="submit.emit()"
|
||||
>{{ resubmitLabel() }}</app-button
|
||||
>
|
||||
}
|
||||
}
|
||||
@case ('submitted') {
|
||||
@if (canApprove() || canReject()) {
|
||||
<app-button variant="primary" [disabled]="busy()" (click)="approve.emit()">{{
|
||||
@@ -156,13 +128,10 @@ export class LetterComposerComponent {
|
||||
brief = input.required<Brief>();
|
||||
orgTemplate = input.required<OrgTemplate>();
|
||||
logoUrl = input<string | null>(null);
|
||||
availablePassages = input<readonly LibraryPassage[]>([]);
|
||||
diagnostics = input<readonly Diagnostic[]>([]);
|
||||
canEdit = input(false);
|
||||
canApprove = input(false);
|
||||
canReject = input(false);
|
||||
canSend = input(false);
|
||||
canSubmit = input(false);
|
||||
busy = input(false);
|
||||
/** Rejection diff (WP-27): the changed/added/removed blocks and their count. The
|
||||
"Toon wijzigingen" toggle only appears when there's something to show. */
|
||||
@@ -171,8 +140,6 @@ export class LetterComposerComponent {
|
||||
protected hasRejectionDiff = computed(() => this.blockDiffs().size > 0);
|
||||
protected showDiff = signal(false);
|
||||
|
||||
edit = output<BriefMsg>();
|
||||
submit = output<void>();
|
||||
approve = output<void>();
|
||||
reject = output<string>();
|
||||
send = output<void>();
|
||||
@@ -187,11 +154,6 @@ export class LetterComposerComponent {
|
||||
() =>
|
||||
$localize`:@@brief.diff.removed:${this.removedCount()}:count: blok(ken) verwijderd sinds afwijzing.`,
|
||||
);
|
||||
submitLabel = input($localize`:@@brief.submit:Indienen ter beoordeling`);
|
||||
resubmitLabel = input($localize`:@@brief.resubmit:Opnieuw indienen`);
|
||||
submitHint = input(
|
||||
$localize`:@@brief.submitHint:Vul eerst alle verplichte secties en los fouten op.`,
|
||||
);
|
||||
approveLabel = input($localize`:@@brief.approve:Goedkeuren`);
|
||||
sendLabel = input($localize`:@@brief.send:Versturen`);
|
||||
awaitingText = input(
|
||||
@@ -205,14 +167,6 @@ export class LetterComposerComponent {
|
||||
return s.tag === 'rejected' ? s.comments : '';
|
||||
});
|
||||
|
||||
// The insert menu offers only valid, fillable, non-deprecated fields — inserting an
|
||||
// unknown/retired key is structurally impossible.
|
||||
protected menu = computed<PlaceholderOption[]>(() =>
|
||||
this.brief()
|
||||
.placeholders.filter((p) => p.fillable !== false && !p.deprecated)
|
||||
.map((p) => ({ key: p.key, label: p.label, autoResolvable: p.autoResolvable })),
|
||||
);
|
||||
|
||||
protected statusLabel = computed(() => {
|
||||
switch (this.status()) {
|
||||
case 'draft':
|
||||
|
||||
@@ -121,15 +121,13 @@ const render = (b: Brief, decisions: BriefDecisions) => ({
|
||||
props: {
|
||||
brief: b,
|
||||
orgTemplate,
|
||||
availablePassages: passages,
|
||||
diagnostics: allDiagnostics(b),
|
||||
...decisions,
|
||||
canSubmit: true,
|
||||
busy: false,
|
||||
},
|
||||
template: `<app-letter-composer [brief]="brief" [orgTemplate]="orgTemplate" [availablePassages]="availablePassages"
|
||||
[diagnostics]="diagnostics" [canEdit]="canEdit" [canApprove]="canApprove" [canReject]="canReject"
|
||||
[canSend]="canSend" [canSubmit]="canSubmit" [busy]="busy"></app-letter-composer>`,
|
||||
template: `<app-letter-composer [brief]="brief" [orgTemplate]="orgTemplate"
|
||||
[diagnostics]="diagnostics" [canApprove]="canApprove" [canReject]="canReject"
|
||||
[canSend]="canSend" [busy]="busy"></app-letter-composer>`,
|
||||
});
|
||||
|
||||
const meta: Meta<LetterComposerComponent> = {
|
||||
@@ -139,15 +137,6 @@ const meta: Meta<LetterComposerComponent> = {
|
||||
export default meta;
|
||||
type Story = StoryObj<LetterComposerComponent>;
|
||||
|
||||
export const DraftDrafter: Story = {
|
||||
render: () =>
|
||||
render(brief({ tag: 'draft' }), {
|
||||
canEdit: true,
|
||||
canApprove: false,
|
||||
canReject: false,
|
||||
canSend: false,
|
||||
}),
|
||||
};
|
||||
export const SubmittedApprover: Story = {
|
||||
render: () =>
|
||||
render(brief({ tag: 'submitted', submittedBy: 'demo-drafter', submittedAt: '2026-07-01' }), {
|
||||
@@ -157,17 +146,14 @@ export const SubmittedApprover: Story = {
|
||||
canSend: false,
|
||||
}),
|
||||
};
|
||||
export const Rejected: Story = {
|
||||
export const ApprovedSender: Story = {
|
||||
render: () =>
|
||||
render(
|
||||
brief({
|
||||
tag: 'rejected',
|
||||
rejectedBy: 'demo-approver',
|
||||
rejectedAt: '2026-07-01',
|
||||
comments: 'Graag de aanhef formeler.',
|
||||
}),
|
||||
{ canEdit: true, canApprove: false, canReject: false, canSend: false },
|
||||
),
|
||||
render(brief({ tag: 'approved', approvedBy: 'demo-approver', approvedAt: '2026-07-01' }), {
|
||||
canEdit: false,
|
||||
canApprove: false,
|
||||
canReject: false,
|
||||
canSend: true,
|
||||
}),
|
||||
};
|
||||
export const Sent: Story = {
|
||||
render: () =>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Component, computed, input, output } from '@angular/core';
|
||||
import { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component';
|
||||
import { Brief } from '@brief/domain/brief';
|
||||
import { BriefMsg } from '@brief/domain/brief.machine';
|
||||
import { LetterSectionComponent } from '@brief/ui/letter-section/letter-section.component';
|
||||
|
||||
/** Organism: the lean authoring surface — just the editable letter sections and their
|
||||
add/edit controls, no letterhead/signature/footer/zoom. The full rendered letter
|
||||
(including the locked aanhef/slot) lives in the preview modal (see behandel-scherm),
|
||||
so the drafter stays focused on composing the body they actually own. */
|
||||
@Component({
|
||||
selector: 'app-letter-editor',
|
||||
imports: [LetterSectionComponent],
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: grid;
|
||||
gap: var(--rhc-space-max-xl);
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
@for (section of editableSections(); track section.sectionKey) {
|
||||
<app-letter-section
|
||||
[section]="section"
|
||||
[placeholders]="placeholders()"
|
||||
[editable]="true"
|
||||
(edit)="edit.emit($event)"
|
||||
/>
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class LetterEditorComponent {
|
||||
brief = input.required<Brief>();
|
||||
placeholders = input<readonly PlaceholderOption[]>([]);
|
||||
edit = output<BriefMsg>();
|
||||
|
||||
/** Only unlocked sections are authored here; locked aanhef/slot appear in the preview. */
|
||||
protected editableSections = computed(() => this.brief().sections.filter((s) => !s.locked));
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { Brief } from '@brief/domain/brief';
|
||||
import { LetterEditorComponent } from './letter-editor.component';
|
||||
|
||||
const brief: Brief = {
|
||||
briefId: 'b1',
|
||||
beroep: 'arts',
|
||||
templateId: 't1',
|
||||
drafterId: 'demo-drafter',
|
||||
status: { tag: 'draft' },
|
||||
placeholders: [{ key: 'reden_besluit', label: 'Reden besluit', autoResolvable: false }],
|
||||
sections: [
|
||||
{
|
||||
sectionKey: 'aanhef',
|
||||
title: 'Aanhef',
|
||||
required: true,
|
||||
locked: true,
|
||||
blocks: [
|
||||
{
|
||||
type: 'freeText',
|
||||
blockId: 'aanhef-1',
|
||||
content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Geachte heer/mevrouw,' }] }] },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
sectionKey: 'kern',
|
||||
title: 'Kern van het besluit',
|
||||
required: true,
|
||||
locked: false,
|
||||
blocks: [
|
||||
{
|
||||
type: 'freeText',
|
||||
blockId: 'kern-1',
|
||||
content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Wij hebben besloten...' }] }] },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const meta: Meta<LetterEditorComponent> = {
|
||||
title: 'Domein/Brief/Letter Editor',
|
||||
component: LetterEditorComponent,
|
||||
args: { brief, placeholders: [] },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<LetterEditorComponent>;
|
||||
|
||||
/** The lean authoring surface: only the editable sections (the kern); the locked
|
||||
aanhef/slot are hidden here and appear only in the preview. */
|
||||
export const Default: Story = {};
|
||||
|
||||
/** Empty kern: shows the empty-state hint plus the free-text action, no starter content. */
|
||||
export const EmptyKern: Story = {
|
||||
args: {
|
||||
brief: {
|
||||
...brief,
|
||||
sections: brief.sections.map((s) => (s.sectionKey === 'kern' ? { ...s, blocks: [] } : s)),
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,19 +1,19 @@
|
||||
import { Component, computed, input, output, signal } from '@angular/core';
|
||||
import { Component, input, output } from '@angular/core';
|
||||
import { RichTextBlock } from '@shared/kernel/rich-text';
|
||||
import { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||
import { LetterSection, LibraryPassage } from '@brief/domain/brief';
|
||||
import { LetterSection } from '@brief/domain/brief';
|
||||
import { BriefMsg } from '@brief/domain/brief.machine';
|
||||
import { LetterBlockComponent } from '@brief/ui/letter-block/letter-block.component';
|
||||
import { PassagePickerComponent } from '@brief/ui/passage-picker/passage-picker.component';
|
||||
|
||||
/** Organism: one template section — its ordered blocks plus (when editable) the
|
||||
add-passages and add-free-text actions. Maps child events to `BriefMsg`s; sections
|
||||
themselves can never be added/removed/reordered (no message exists for it). */
|
||||
add-free-text action. Standaardteksten enter the kern via the besluit panel, not a
|
||||
per-section picker, so this only offers free text. Maps child events to `BriefMsg`s;
|
||||
sections themselves can never be added/removed/reordered (no message exists for it). */
|
||||
@Component({
|
||||
selector: 'app-letter-section',
|
||||
imports: [ButtonComponent, HeadingComponent, LetterBlockComponent, PassagePickerComponent],
|
||||
imports: [ButtonComponent, HeadingComponent, LetterBlockComponent],
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
@@ -63,58 +63,24 @@ import { PassagePickerComponent } from '@brief/ui/passage-picker/passage-picker.
|
||||
|
||||
@if (editable()) {
|
||||
<div class="actions">
|
||||
@if (showStandardLetter()) {
|
||||
<app-button variant="primary" (click)="insertStandardLetter()">{{
|
||||
standardLetterLabel()
|
||||
}}</app-button>
|
||||
}
|
||||
<app-button variant="secondary" (click)="pickerOpen.set(!pickerOpen())">{{
|
||||
addPassageLabel()
|
||||
}}</app-button>
|
||||
<app-button
|
||||
variant="subtle"
|
||||
(click)="edit.emit({ tag: 'FreeTextBlockAdded', sectionKey: section().sectionKey })"
|
||||
>{{ addFreeLabel() }}</app-button
|
||||
>
|
||||
</div>
|
||||
@if (pickerOpen()) {
|
||||
<app-passage-picker [passages]="sectionPassages()" (insert)="onInsert($event)" />
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class LetterSectionComponent {
|
||||
section = input.required<LetterSection>();
|
||||
availablePassages = input<readonly LibraryPassage[]>([]);
|
||||
placeholders = input<readonly PlaceholderOption[]>([]);
|
||||
editable = input(false);
|
||||
edit = output<BriefMsg>();
|
||||
|
||||
requiredLabel = input($localize`:@@brief.section.required:verplicht`);
|
||||
emptyLabel = input($localize`:@@brief.section.empty:Nog geen tekst in deze sectie.`);
|
||||
addPassageLabel = input($localize`:@@brief.section.addPassage:Standaardtekst toevoegen`);
|
||||
addFreeLabel = input($localize`:@@brief.section.addFree:Vrije tekst toevoegen`);
|
||||
standardLetterLabel = input($localize`:@@brief.section.standardLetter:Standaardbrief invoegen`);
|
||||
|
||||
protected pickerOpen = signal(false);
|
||||
protected sectionPassages = computed(() =>
|
||||
this.availablePassages().filter((p) => p.sectionKey === this.section().sectionKey),
|
||||
);
|
||||
/** The "standaardbrief" starter set for this section (server-flagged defaults). */
|
||||
protected defaultPassages = computed(() => this.sectionPassages().filter((p) => p.isDefault));
|
||||
/** One-click starter, offered only while the section is still empty and defaults exist. */
|
||||
protected showStandardLetter = computed(
|
||||
() => this.section().blocks.length === 0 && this.defaultPassages().length > 0,
|
||||
);
|
||||
|
||||
protected insertStandardLetter() {
|
||||
// One Msg → one undo step (see brief.store `edit`).
|
||||
this.edit.emit({
|
||||
tag: 'PassagesInserted',
|
||||
sectionKey: this.section().sectionKey,
|
||||
passages: this.defaultPassages(),
|
||||
});
|
||||
}
|
||||
|
||||
protected onContent(blockId: string, content: RichTextBlock) {
|
||||
this.edit.emit({ tag: 'BlockContentEdited', blockId, content });
|
||||
@@ -124,9 +90,4 @@ export class LetterSectionComponent {
|
||||
const i = this.section().blocks.findIndex((b) => b.blockId === blockId);
|
||||
this.edit.emit({ tag: 'BlockMovedWithinSection', blockId, toIndex: i + direction });
|
||||
}
|
||||
|
||||
protected onInsert(passages: LibraryPassage[]) {
|
||||
this.edit.emit({ tag: 'PassagesInserted', sectionKey: this.section().sectionKey, passages });
|
||||
this.pickerOpen.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { LetterSection, LibraryPassage } from '@brief/domain/brief';
|
||||
import { LetterSection } from '@brief/domain/brief';
|
||||
import { LetterSectionComponent } from './letter-section.component';
|
||||
|
||||
const section: LetterSection = {
|
||||
@@ -28,30 +28,17 @@ const section: LetterSection = {
|
||||
|
||||
const emptySection: LetterSection = { ...section, blocks: [] };
|
||||
|
||||
const passages: LibraryPassage[] = [
|
||||
{
|
||||
passageId: 'p2',
|
||||
scope: 'beroep',
|
||||
beroep: 'arts',
|
||||
sectionKey: 'kern',
|
||||
label: 'Toelichting arts',
|
||||
version: 1,
|
||||
isDefault: true, // part of the standaardbrief starter set (WP-27)
|
||||
content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Als arts ...' }] }] },
|
||||
},
|
||||
];
|
||||
|
||||
const placeholders = [{ key: 'reden_besluit', label: 'Reden besluit' }];
|
||||
|
||||
const meta: Meta<LetterSectionComponent> = {
|
||||
title: 'Domein/Brief/Letter Section',
|
||||
component: LetterSectionComponent,
|
||||
args: { section, availablePassages: passages, placeholders, edit: () => {} },
|
||||
args: { section, placeholders, edit: () => {} },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<LetterSectionComponent>;
|
||||
|
||||
export const ReadOnly: Story = { args: { editable: false } };
|
||||
export const Editable: Story = { args: { editable: true } };
|
||||
/** Empty section: the one-click "Standaardbrief invoegen" starter appears (WP-27). */
|
||||
/** Empty section: shows the empty-state hint plus the free-text action. */
|
||||
export const EditableEmpty: Story = { args: { section: emptySection, editable: true } };
|
||||
|
||||
@@ -1608,6 +1608,7 @@ export interface BriefViewDto {
|
||||
availablePassages?: LibraryPassageDto[] | undefined;
|
||||
decisions?: BriefDecisionsDto;
|
||||
orgTemplate?: OrgTemplateDto;
|
||||
caseContext?: CaseContextDto;
|
||||
}
|
||||
|
||||
export interface BrpAddressDto {
|
||||
@@ -1615,6 +1616,13 @@ export interface BrpAddressDto {
|
||||
adres?: AdresDto;
|
||||
}
|
||||
|
||||
export interface CaseContextDto {
|
||||
zorgverlenerNaam?: string | undefined;
|
||||
bigNummer?: string | undefined;
|
||||
beroep?: string | undefined;
|
||||
aanvraagReferentie?: string | undefined;
|
||||
}
|
||||
|
||||
export interface ChangeRequestRequest {
|
||||
straat?: string | undefined;
|
||||
postcode?: string | undefined;
|
||||
@@ -1713,6 +1721,8 @@ export interface LibraryPassageDto {
|
||||
version?: number;
|
||||
beroep?: string | undefined;
|
||||
isDefault?: boolean;
|
||||
besluit?: string | undefined;
|
||||
reason?: string | undefined;
|
||||
}
|
||||
|
||||
export interface ManualDiplomaPolicyDto {
|
||||
|
||||
@@ -91,7 +91,7 @@ export type WizardStatus = 'editing' | 'submitting' | 'submitted' | 'failed';
|
||||
</div>
|
||||
</div>
|
||||
<!-- Wizard pages wrap their field groups in <fieldset>s; CIBG's
|
||||
".form-horizontal fieldset" gives each a grey #f1f5f9 surface with a 1.25em
|
||||
".form-horizontal fieldset" gives each a grey #f1f5f9 surface with a 1.25em token-ok: hex named in prose, not a style value
|
||||
gap. The shell stays group-agnostic and does NOT add its own fieldset (an
|
||||
outer grey fieldset would hide the white gaps between the page groups). -->
|
||||
<ng-content />
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { Component, forwardRef, input } from '@angular/core';
|
||||
import { Component, computed, forwardRef, input } from '@angular/core';
|
||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||
|
||||
// Per-instance fallback ids, so the label's `for` always targets THIS checkbox. The
|
||||
// CIBG styled checkbox hides the native input and routes clicks through the label, so a
|
||||
// shared/undefined id silently makes every label toggle the first input — hence a default.
|
||||
let nextCheckboxId = 0;
|
||||
|
||||
/** Atom: a labelled checkbox wired as a form control (ngModel/reactive). Thin
|
||||
wrapper over the CIBG Huisstijl `.form-check.styled` checkbox CSS; native
|
||||
input for full keyboard + screen-reader support. */
|
||||
@@ -11,13 +16,13 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
[id]="checkboxId()"
|
||||
[id]="resolvedId()"
|
||||
[checked]="value"
|
||||
[disabled]="disabled"
|
||||
(change)="onToggle($event)"
|
||||
(blur)="onTouched()"
|
||||
/>
|
||||
<label class="form-check-label" [for]="checkboxId()">{{ label() }}</label>
|
||||
<label class="form-check-label" [for]="resolvedId()">{{ label() }}</label>
|
||||
</div>
|
||||
`,
|
||||
providers: [
|
||||
@@ -28,6 +33,10 @@ export class CheckboxComponent implements ControlValueAccessor {
|
||||
checkboxId = input<string>();
|
||||
label = input('');
|
||||
|
||||
/** The caller's id, or a unique fallback — never undefined, so labels never collide. */
|
||||
private autoId = `app-checkbox-${nextCheckboxId++}`;
|
||||
protected resolvedId = computed(() => this.checkboxId() ?? this.autoId);
|
||||
|
||||
value = false;
|
||||
disabled = false;
|
||||
onChange: (v: boolean) => void = () => {};
|
||||
|
||||
Reference in New Issue
Block a user