feat(fp): WP-24 — letter canvas (edit on the letter)
CI / frontend (push) Failing after 59s
CI / storybook-a11y (push) Successful in 4m22s
CI / backend (push) Successful in 1m18s
CI / codeql (csharp) (push) Failing after 1m47s
CI / codeql (javascript-typescript) (push) Failing after 1m20s
CI / api-client-drift (push) Successful in 1m42s
CI / e2e (push) Failing after 3h8m54s
CI / frontend (push) Failing after 59s
CI / storybook-a11y (push) Successful in 4m22s
CI / backend (push) Successful in 1m18s
CI / codeql (csharp) (push) Failing after 1m47s
CI / codeql (javascript-typescript) (push) Failing after 1m20s
CI / api-client-drift (push) Successful in 1m42s
CI / e2e (push) Failing after 3h8m54s
One letter surface for every role: LetterCanvasComponent renders the org template's letterhead/signature/footer around the case-type sections, with editableRegions content|template|none. public/letter.css is the FE⇄BE rendering contract (WP-25 inlines it verbatim). letter-preview deleted — its read-only rendering absorbed into 'none' mode. brief.machine.ts byte-identical; orgTemplate parses at the adapter boundary and lives beside the machine in BriefStore. Also fixes passage-picker multi-select (checkboxes all shared id="undefined", so labels only toggled the first box) and keeps the ±page-break marks from drawing through canvas content. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,317 @@
|
||||
import {
|
||||
Component,
|
||||
DestroyRef,
|
||||
ElementRef,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
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 { OrgTemplate } from '@brief/domain/org-template';
|
||||
import { Diagnostic } from '@brief/domain/placeholders';
|
||||
import { BriefMsg } from '@brief/domain/brief.machine';
|
||||
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 = {
|
||||
readonly list: 'bullet' | 'number' | null;
|
||||
readonly items: readonly Paragraph[];
|
||||
};
|
||||
|
||||
function groupParagraphs(paras: readonly Paragraph[]): PreviewSegment[] {
|
||||
const out: { list: 'bullet' | 'number' | null; items: Paragraph[] }[] = [];
|
||||
for (const para of paras) {
|
||||
const kind = para.list ?? null;
|
||||
const last = out[out.length - 1];
|
||||
if (kind && last && last.list === kind) last.items.push(para);
|
||||
else out.push({ list: kind, items: [para] });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Illustrative values for the "Voorbeeld" toggle — what send resolves server-side.
|
||||
const SAMPLE_VALUES: Record<string, string> = {
|
||||
naam_zorgverlener: 'J. Jansen',
|
||||
big_nummer: '12345678901',
|
||||
};
|
||||
|
||||
/** A4 height in CSS px (1in = 96px = 25.4mm) — for the approximate page-break marks. */
|
||||
const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
||||
|
||||
/** Organism: the letter as one surface — the org template's letterhead, signature and
|
||||
footer around the case-type template's sections. `editableRegions` picks who edits
|
||||
what: `'content'` hosts the editable letter-sections in place (drafter), `'none'`
|
||||
renders everything read-only (approver/locked, absorbs the old letter-preview),
|
||||
`'template'` reserves the org-identity regions for the admin editor (WP-26).
|
||||
Letter typography/geometry come from the shared `public/letter.css` contract —
|
||||
the same file the backend preview renderer inlines (WP-25). */
|
||||
@Component({
|
||||
selector: 'app-letter-canvas',
|
||||
imports: [NgTemplateOutlet, ButtonComponent, PlaceholderChipComponent, LetterSectionComponent],
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-block-end: var(--rhc-space-max-sm);
|
||||
}
|
||||
/* Portal-side chrome around the letter surface (not part of the contract file). */
|
||||
.surface {
|
||||
background: var(--rhc-color-grijs-100, #f0f0f0);
|
||||
border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);
|
||||
border-radius: var(--rhc-border-radius-md);
|
||||
padding: var(--rhc-space-max-lg);
|
||||
overflow-x: auto;
|
||||
}
|
||||
.surface .letter {
|
||||
box-shadow: 0 1px 4px rgb(0 0 0 / 0.15);
|
||||
}
|
||||
/* Org-identity regions: visibly not the drafter's to edit. */
|
||||
.from-template {
|
||||
background: var(--rhc-color-grijs-100, #f5f5f5);
|
||||
outline: 2mm solid var(--rhc-color-grijs-100, #f5f5f5);
|
||||
}
|
||||
.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, #334155);
|
||||
margin: 0 0 2mm;
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<ng-template #line let-nodes>
|
||||
@for (node of nodes; track $index) {
|
||||
@switch (node.type) {
|
||||
@case ('text') {
|
||||
<span>{{ node.text }}</span>
|
||||
}
|
||||
@case ('lineBreak') {
|
||||
<br />
|
||||
}
|
||||
@case ('placeholder') {
|
||||
@if (showSample() && autoFor(node.key)) {
|
||||
<span>{{ sampleFor(node.key) }}</span>
|
||||
} @else {
|
||||
<app-placeholder-chip
|
||||
[label]="labelFor(node.key)"
|
||||
[autoResolvable]="autoFor(node.key)"
|
||||
[state]="stateFor(node.key)"
|
||||
/>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
@if (editableRegions() === 'none') {
|
||||
<div class="toolbar">
|
||||
<app-button
|
||||
variant="subtle"
|
||||
(click)="showSample.set(!showSample())"
|
||||
[attr.aria-pressed]="showSample()"
|
||||
>
|
||||
{{ showSample() ? hideSampleLabel() : showSampleLabel() }}
|
||||
</app-button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="surface">
|
||||
<div class="letter" #page [style]="marginStyle()" [style.zoom]="zoom()">
|
||||
<!-- 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>
|
||||
}
|
||||
<p class="org-wordmark">{{ orgTemplate().orgName }}</p>
|
||||
<address class="return-address">{{ orgTemplate().returnAddress }}</address>
|
||||
<address class="address-window">{{ recipientText() }}</address>
|
||||
<dl class="reference">
|
||||
<div>
|
||||
<dt>{{ referenceLabel() }}</dt>
|
||||
<dd>{{ brief().briefId }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ dateLabel() }}</dt>
|
||||
<dd>{{ letterDate }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</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) {
|
||||
@for (seg of segmentsOf(block); track $index) {
|
||||
@if (seg.list === 'bullet') {
|
||||
<ul>
|
||||
@for (para of seg.items; track $index) {
|
||||
<li>
|
||||
<ng-container
|
||||
[ngTemplateOutlet]="line"
|
||||
[ngTemplateOutletContext]="{ $implicit: para.nodes }"
|
||||
/>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
} @else if (seg.list === 'number') {
|
||||
<ol>
|
||||
@for (para of seg.items; track $index) {
|
||||
<li>
|
||||
<ng-container
|
||||
[ngTemplateOutlet]="line"
|
||||
[ngTemplateOutletContext]="{ $implicit: para.nodes }"
|
||||
/>
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
} @else {
|
||||
<p>
|
||||
<ng-container
|
||||
[ngTemplateOutlet]="line"
|
||||
[ngTemplateOutletContext]="{ $implicit: seg.items[0].nodes }"
|
||||
/>
|
||||
</p>
|
||||
}
|
||||
}
|
||||
}
|
||||
</section>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="letter__signature" [class.from-template]="tintTemplate()">
|
||||
<p>{{ orgTemplate().signatureClosing }}</p>
|
||||
<p class="signature-name">{{ orgTemplate().signatureName }}</p>
|
||||
<p>{{ orgTemplate().signatureRole }}</p>
|
||||
</div>
|
||||
|
||||
<div class="letter__footer" [class.from-template]="tintTemplate()">
|
||||
<div class="footer-contact">{{ orgTemplate().footerContact }}</div>
|
||||
<div class="footer-legal">{{ orgTemplate().footerLegal }}</div>
|
||||
</div>
|
||||
|
||||
@for (top of pageBreaks(); track $index) {
|
||||
<div class="letter__page-break" [style.top.px]="top" aria-hidden="true">
|
||||
<span>{{ pageBreakCaption() }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
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[]>([]);
|
||||
diagnostics = input<readonly Diagnostic[]>([]);
|
||||
zoom = input(1);
|
||||
edit = output<BriefMsg>();
|
||||
|
||||
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`,
|
||||
);
|
||||
recipientText = input(
|
||||
$localize`:@@brief.canvas.recipient:Adres van de geadresseerde\n(wordt ingevuld bij verzending)`,
|
||||
);
|
||||
referenceLabel = input($localize`:@@brief.canvas.reference:Ons kenmerk`);
|
||||
dateLabel = input($localize`:@@brief.canvas.date:Datum`);
|
||||
|
||||
protected showSample = signal(false);
|
||||
protected letterDate = formatDatumNl(new Date());
|
||||
|
||||
/** 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');
|
||||
|
||||
protected marginStyle = computed(() => {
|
||||
const m = this.orgTemplate().margins;
|
||||
return {
|
||||
'--letter-margin-top': `${m.topMm}mm`,
|
||||
'--letter-margin-right': `${m.rightMm}mm`,
|
||||
'--letter-margin-bottom': `${m.bottomMm}mm`,
|
||||
'--letter-margin-left': `${m.leftMm}mm`,
|
||||
};
|
||||
});
|
||||
|
||||
// --- read-only rendering helpers (migrated from the superseded letter-preview) ---
|
||||
|
||||
private defs = computed(() => new Map(this.brief().placeholders.map((p) => [p.key, p])));
|
||||
private worst = computed(() => {
|
||||
const m = new Map<string, 'error' | 'warning'>();
|
||||
for (const d of this.diagnostics()) {
|
||||
if (!d.placeholderKey) continue;
|
||||
if (d.severity === 'error') m.set(d.placeholderKey, 'error');
|
||||
else if (!m.has(d.placeholderKey)) m.set(d.placeholderKey, 'warning');
|
||||
}
|
||||
return m;
|
||||
});
|
||||
|
||||
protected segmentsOf = (block: LetterBlock) => groupParagraphs(block.content.paragraphs);
|
||||
protected labelFor = (key: string) => this.defs().get(key)?.label ?? key;
|
||||
protected autoFor = (key: string) => this.defs().get(key)?.autoResolvable ?? false;
|
||||
protected stateFor = (key: string): 'ok' | 'warning' | 'error' => this.worst().get(key) ?? 'ok';
|
||||
protected sampleFor = (key: string) =>
|
||||
SAMPLE_VALUES[key] ?? (key === 'datum' ? this.letterDate : this.labelFor(key));
|
||||
|
||||
// --- approximate page-break marks (PRD §2b: honest "±", print preview is leading) ---
|
||||
|
||||
private page = viewChild<ElementRef<HTMLElement>>('page');
|
||||
protected pageBreaks = signal<readonly number[]>([]);
|
||||
|
||||
constructor() {
|
||||
// ponytail: whole-surface height / A4-interval — ignores that a break never truly
|
||||
// falls mid-line; the caption says "±" and WP-25's server preview is authoritative.
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
// ~1cm tolerance so a letter ending on a page boundary gets no edge-hugging mark.
|
||||
const pages = Math.ceil((entry.target.scrollHeight - 40) / A4_HEIGHT_PX);
|
||||
this.pageBreaks.set(
|
||||
Array.from({ length: Math.max(0, pages - 1) }, (_, i) => (i + 1) * A4_HEIGHT_PX),
|
||||
);
|
||||
});
|
||||
effect((onCleanup) => {
|
||||
const el = this.page()?.nativeElement;
|
||||
if (!el) return;
|
||||
observer.observe(el);
|
||||
onCleanup(() => observer.unobserve(el));
|
||||
});
|
||||
inject(DestroyRef).onDestroy(() => observer.disconnect());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { Brief, LibraryPassage, allDiagnostics } from '@brief/domain/brief';
|
||||
import { OrgTemplate } from '@brief/domain/org-template';
|
||||
import { LetterCanvasComponent } from './letter-canvas.component';
|
||||
|
||||
const orgTemplate: OrgTemplate = {
|
||||
subOrgId: 'cibg-registers',
|
||||
orgName: 'CIBG — Registers',
|
||||
returnAddress: 'BIG-register\nPostbus 00000\n2500 AA Den Haag',
|
||||
footerContact: 'www.bigregister.nl\ninfo@voorbeeld.example\n070 000 00 00',
|
||||
footerLegal: 'Ons kenmerk vermelden bij correspondentie.',
|
||||
signatureName: 'A. de Vries',
|
||||
signatureRole: 'Hoofd Registratie',
|
||||
signatureClosing: 'Met vriendelijke groet,',
|
||||
margins: { topMm: 25, rightMm: 25, bottomMm: 25, leftMm: 25 },
|
||||
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',
|
||||
templateId: 't1',
|
||||
drafterId: 'demo-drafter',
|
||||
status: { tag: 'draft' },
|
||||
placeholders: [
|
||||
{ key: 'naam_zorgverlener', label: 'Naam zorgverlener', autoResolvable: true },
|
||||
{ key: 'reden_besluit', label: 'Reden besluit', autoResolvable: false },
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
sectionKey: 'aanhef',
|
||||
title: 'Aanhef',
|
||||
required: true,
|
||||
locked: true,
|
||||
blocks: [
|
||||
{
|
||||
type: 'passage',
|
||||
blockId: 'local-1',
|
||||
sourcePassageId: 'p1',
|
||||
sourceVersion: 1,
|
||||
edited: false,
|
||||
content: {
|
||||
paragraphs: [
|
||||
{
|
||||
nodes: [
|
||||
{ type: 'text', text: 'Geachte heer/mevrouw ' },
|
||||
{ type: 'placeholder', key: 'naam_zorgverlener' },
|
||||
{ type: 'text', text: ',' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
sectionKey: 'kern',
|
||||
title: 'Kern van het besluit',
|
||||
required: true,
|
||||
locked: false,
|
||||
blocks: [
|
||||
{
|
||||
type: 'freeText',
|
||||
blockId: 'local-2',
|
||||
content: {
|
||||
paragraphs: [
|
||||
{
|
||||
nodes: [
|
||||
{ type: 'text', text: 'Wij hebben besloten om reden ' },
|
||||
{ type: 'placeholder', key: 'reden_besluit' },
|
||||
{ type: 'text', text: '.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/** Enough repeated body text to push the surface past one A4 page. */
|
||||
const longBrief: Brief = {
|
||||
...brief,
|
||||
sections: brief.sections.map((s) =>
|
||||
s.sectionKey === 'kern'
|
||||
? {
|
||||
...s,
|
||||
blocks: [
|
||||
{
|
||||
type: 'freeText',
|
||||
blockId: 'local-long',
|
||||
content: {
|
||||
paragraphs: Array.from({ length: 40 }, (_, i) => ({
|
||||
nodes: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Alinea ${i + 1}: de beoordeling van uw aanvraag is uitgevoerd volgens de geldende regels voor herregistratie in het BIG-register.`,
|
||||
},
|
||||
],
|
||||
})),
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
: s,
|
||||
),
|
||||
};
|
||||
|
||||
const meta: Meta<LetterCanvasComponent> = {
|
||||
title: 'Domein/Brief/Letter Canvas',
|
||||
component: 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). */
|
||||
export const ReadOnly: Story = { args: { editableRegions: 'none' } };
|
||||
|
||||
export const ReadOnlyZonderBevindingen: Story = {
|
||||
args: { editableRegions: 'none', diagnostics: [] },
|
||||
};
|
||||
|
||||
/** Admin editor focus (consumer arrives in WP-26): body read-only, no "not yours" tint. */
|
||||
export const TemplateMode: Story = { args: { editableRegions: 'template' } };
|
||||
|
||||
export const Zoomed: Story = { args: { editableRegions: 'none', zoom: 0.6 } };
|
||||
|
||||
/** Long letter: the approximate ±page-break marks appear per A4 interval. */
|
||||
export const PageBreak: Story = {
|
||||
args: { editableRegions: 'none', brief: longBrief, diagnostics: [] },
|
||||
};
|
||||
Reference in New Issue
Block a user