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:
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user