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:
eho
2026-07-20 18:53:53 +02:00
co-authored by Claude Opus 4.8
parent 62ba0b98c4
commit ba32e3dd9f
31 changed files with 4900 additions and 2878 deletions
+100
View File
@@ -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: [] });
});
});