feat(brief): besluit guidance + read-only notice (role clarity)

Surface the besluit-driven assistance that was previously silent: a pure
besluitGuidance() (kern passage count + needs-reason flag) rendered as a hint below
the besluit panel in behandel-scherm (warning to pick a reden, else info on how many
standaardteksten were inserted). Add a read-only notice on letter-composer for a pure
viewer (no edit/approve/reject/send right, e.g. admin) so the read-only letter isn't
mistaken for a broken editor — the "reverted to a view of the letter" confusion was a
role issue (non-drafter sees the composer). +besluitGuidance spec.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
eho
2026-07-23 17:14:10 +02:00
co-authored by Claude Opus 4.8
parent 2bb16d1161
commit ee2413f2fe
7 changed files with 4126 additions and 1309 deletions
+18 -1
View File
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import { Besluit, LetterBlock, LibraryPassage } from './brief';
import { inferSelection, passagesForBesluit, redenenFor } from './besluit';
import { besluitGuidance, inferSelection, passagesForBesluit, redenenFor } from './besluit';
const block = (t: string): LibraryPassage['content'] => ({
paragraphs: [{ nodes: [{ type: 'text', text: t }] }],
@@ -117,3 +117,20 @@ describe('inferSelection', () => {
expect(inferSelection(blocks, lib)).toEqual({ besluit: 'positief', reasons: [] });
});
});
describe('besluitGuidance', () => {
it('positief: counts inserted passages, no reden needed (positief has no redenen)', () => {
expect(besluitGuidance(lib, 'positief', [])).toEqual({ insertedCount: 2, needsReason: false });
});
it('negatief without a reden: flags that a reden must be chosen', () => {
expect(besluitGuidance(lib, 'negatief', [])).toEqual({ insertedCount: 2, needsReason: true });
});
it('negatief with a reden: no longer flags, and the reason passage is counted', () => {
expect(besluitGuidance(lib, 'negatief', ['onvoldoende_scholing'])).toEqual({
insertedCount: 3,
needsReason: false,
});
});
});
+20
View File
@@ -34,6 +34,26 @@ export interface Reden {
readonly label: string;
}
/** Visible assistance for the behandelaar on top of the silent auto-insert: how many kern
standaardteksten the current besluit+redenen produced, and whether a reden still needs
choosing (the besluit has reason-specific motivering passages but none is ticked). Pure
DATA — the component maps it to localized copy. */
export interface BesluitGuidance {
readonly insertedCount: number;
readonly needsReason: boolean;
}
export function besluitGuidance(
passages: readonly LibraryPassage[],
besluit: Besluit,
reasons: readonly string[],
): BesluitGuidance {
return {
insertedCount: passagesForBesluit(passages, besluit, reasons).length,
needsReason: redenenFor(passages, besluit).length > 0 && reasons.length === 0,
};
}
export function redenenFor(passages: readonly LibraryPassage[], besluit: Besluit): Reden[] {
const seen = new Set<string>();
const out: Reden[] = [];
@@ -3,9 +3,10 @@ import { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.
import { ButtonComponent } from '@shared/ui/button/button.component';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { MaskedValueComponent } from '@shared/ui/masked-value/masked-value.component';
import { AlertComponent } from '@shared/ui/alert/alert.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 { besluitGuidance, 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';
@@ -27,6 +28,7 @@ import { BesluitPanelComponent } from '@brief/ui/besluit-panel/besluit-panel.com
ButtonComponent,
HeadingComponent,
MaskedValueComponent,
AlertComponent,
StepperComponent,
LetterCanvasComponent,
DiagnosticsPanelComponent,
@@ -125,6 +127,14 @@ import { BesluitPanelComponent } from '@brief/ui/besluit-panel/besluit-panel.com
(selectionChange)="onSelection($event)"
/>
@if (guidance(); as g) {
@if (g.needsReason) {
<app-alert type="warning">{{ needsReasonHint }}</app-alert>
} @else {
<app-alert type="info">{{ insertedHint(g.insertedCount) }}</app-alert>
}
}
<app-letter-editor [brief]="brief()" [placeholders]="menu()" (edit)="edit.emit($event)" />
<app-diagnostics-panel [diagnostics]="diagnostics()" (locate)="locate.emit($event)" />
@@ -198,6 +208,16 @@ export class BehandelSchermComponent {
return inferSelection(kern?.blocks ?? [], this.availablePassages());
});
/** Visible guidance for the current selection — null until a besluit is chosen (the
panel's own intro copy prompts that first step). */
protected guidance = computed(() => {
const s = this.selection();
return s.besluit ? besluitGuidance(this.availablePassages(), s.besluit, s.reasons) : null;
});
protected needsReasonHint = $localize`:@@brief.guidance.needsReason:Kies een reden, zodat de juiste motivering aan de brief wordt toegevoegd.`;
protected insertedHint = (n: number) =>
$localize`:@@brief.guidance.inserted:${n}:count: standaardtekst(en) toegevoegd op basis van het besluit. Vul aan met vrije tekst waar nodig.`;
// Same insert menu as the composer: only valid, fillable, non-deprecated fields.
protected menu = computed<PlaceholderOption[]>(() =>
this.brief()
@@ -80,6 +80,10 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
<app-rejection-comments mode="show" [comments]="rejectComments()" />
}
@if (pureViewer()) {
<app-alert type="info">{{ readonlyNotice() }}</app-alert>
}
@if (showDiff() && removedCount() > 0) {
<app-alert type="info">{{ removedText() }}</app-alert>
}
@@ -162,6 +166,14 @@ export class LetterComposerComponent {
sentText = input($localize`:@@brief.sent:De brief is verzonden.`);
protected status = computed(() => this.brief().status.tag);
/** A pure viewer has no action on this letter (not the behandelaar, not an approver with
approve/reject/send) — e.g. an admin. Show a notice so the read-only letter isn't
mistaken for a broken editor. */
protected pureViewer = computed(() => !this.canApprove() && !this.canReject() && !this.canSend());
readonlyNotice = input(
$localize`:@@brief.readonlyNotice:Alleen-lezen weergave. De behandelaar stelt de brief op.`,
);
protected rejectComments = computed(() => {
const s = this.brief().status;
return s.tag === 'rejected' ? s.comments : '';