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
@@ -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 = {