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:
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 : '';
|
||||
|
||||
Reference in New Issue
Block a user