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
@@ -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.`,
);
}