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