feat(fp): WP-24 — letter canvas (edit on the letter)
Some checks failed
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
Some checks failed
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:
@@ -1,3 +1,5 @@
|
|||||||
<!-- CIBG Huisstijl (customized Bootstrap 5.2), served from the vendored public/ staticDir.
|
<!-- CIBG Huisstijl (customized Bootstrap 5.2), served from the vendored public/ staticDir.
|
||||||
Mirrors src/index.html so stories match the app. System font per styles.scss. -->
|
Mirrors src/index.html so stories match the app. System font per styles.scss. -->
|
||||||
<link rel="stylesheet" href="cibg-huisstijl/css/huisstijl.min.css" />
|
<link rel="stylesheet" href="cibg-huisstijl/css/huisstijl.min.css" />
|
||||||
|
<!-- Letter-rendering contract (WP-24), mirrors index.html. -->
|
||||||
|
<link rel="stylesheet" href="letter.css" />
|
||||||
|
|||||||
@@ -67,8 +67,8 @@ for its existing violations, so every WP ends green.
|
|||||||
| [WP-20](WP-20-second-locale.md) | Second locale proof | 5 · productie-volwassenheid | done |
|
| [WP-20](WP-20-second-locale.md) | Second locale proof | 5 · productie-volwassenheid | done |
|
||||||
| [WP-21](WP-21-resilience-seams.md) | Resilience seams (correlation-id, idempotency, retry) | 5 · productie-volwassenheid | done |
|
| [WP-21](WP-21-resilience-seams.md) | Resilience seams (correlation-id, idempotency, retry) | 5 · productie-volwassenheid | done |
|
||||||
| [WP-22](WP-22-durable-persistence.md) | Durable persistence (optional tier) | 5 · productie-volwassenheid | done |
|
| [WP-22](WP-22-durable-persistence.md) | Durable persistence (optional tier) | 5 · productie-volwassenheid | done |
|
||||||
| [WP-23](WP-23-org-template-backend.md) | Org-template backend + admin role | 6 · Brief v2 | in-progress |
|
| [WP-23](WP-23-org-template-backend.md) | Org-template backend + admin role | 6 · Brief v2 | done |
|
||||||
| [WP-24](WP-24-letter-canvas.md) | Letter canvas (edit on the letter) | 6 · Brief v2 | todo |
|
| [WP-24](WP-24-letter-canvas.md) | Letter canvas (edit on the letter) | 6 · Brief v2 | done |
|
||||||
| [WP-25](WP-25-letter-preview-html.md) | Server-rendered letter preview (HTML; PDF deferred) | 6 · Brief v2 | todo |
|
| [WP-25](WP-25-letter-preview-html.md) | Server-rendered letter preview (HTML; PDF deferred) | 6 · Brief v2 | todo |
|
||||||
| [WP-26](WP-26-org-template-editor.md) | Admin org-template editor | 6 · Brief v2 | todo |
|
| [WP-26](WP-26-org-template-editor.md) | Admin org-template editor | 6 · Brief v2 | todo |
|
||||||
| [WP-27](WP-27-brief-ux-layer.md) | Brief UX layer (undo/redo, standaardbrief, diff) | 6 · Brief v2 | todo |
|
| [WP-27](WP-27-brief-ux-layer.md) | Brief UX layer (undo/redo, standaardbrief, diff) | 6 · Brief v2 | todo |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# WP-24 — Letter canvas (edit on the letter)
|
# WP-24 — Letter canvas (edit on the letter)
|
||||||
|
|
||||||
Status: todo
|
Status: done
|
||||||
Phase: 6 — Brief v2 (edit-on-the-letter, org templates, server-rendered preview)
|
Phase: 6 — Brief v2 (edit-on-the-letter, org templates, server-rendered preview)
|
||||||
|
|
||||||
## Why
|
## Why
|
||||||
@@ -63,13 +63,21 @@ domain model, `brief.machine.ts`, and every `BriefMsg` stay byte-identical.
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] Drafter edits blocks in place on the letter surface; passage picker and
|
- [x] Drafter edits blocks in place on the letter surface; passage picker and
|
||||||
diagnostics click-to-locate still work on the canvas.
|
diagnostics click-to-locate still work on the canvas.
|
||||||
- [ ] Approver sees the identical surface read-only with the action bar.
|
- [x] Approver sees the identical surface read-only with the action bar.
|
||||||
- [ ] Letterhead/footer/signature come from `orgTemplate` and are visibly non-editable.
|
- [x] Letterhead/footer/signature come from `orgTemplate` and are visibly non-editable.
|
||||||
- [ ] `git diff` shows zero changes in `brief.machine.ts` / `brief.ts` Msg surface.
|
- [x] `git diff` shows zero changes in `brief.machine.ts` / `brief.ts` Msg surface.
|
||||||
- [ ] `letter-preview` is gone; no story regression (`test-storybook:ci` green).
|
- [x] `letter-preview` is gone; no story regression (`test-storybook:ci` green).
|
||||||
- [ ] Full GREEN + e2e smoke.
|
- [x] Full GREEN + e2e smoke.
|
||||||
|
|
||||||
|
Field notes (landed alongside): the CIBG huisstijl styles bare `<header>`/`<footer>`
|
||||||
|
elements (robijn background), so the canvas regions are `<div>`s — the letter surface
|
||||||
|
must stay letter.css-only. The passage picker's checkboxes now get unique
|
||||||
|
`checkboxId`s (all previously resolved to `id="undefined"`, so labels toggled only
|
||||||
|
the first box — multi-select was broken) and an opaque background; `.letter__body`
|
||||||
|
stacks above the page-break marks so the dashed line never draws through content,
|
||||||
|
while the "±pagina-einde" caption floats above everything for legibility.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
|
|||||||
4164
documentation.json
4164
documentation.json
File diff suppressed because one or more lines are too long
197
public/letter.css
Normal file
197
public/letter.css
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
/* letter.css — the FE⇄BE letter-rendering CONTRACT (WP-24/WP-25).
|
||||||
|
*
|
||||||
|
* One stylesheet, two consumers: the FE letter canvas loads it via <link>
|
||||||
|
* (index.html + Storybook preview-head), the backend HTML renderer (WP-25)
|
||||||
|
* inlines this same file. Its class-parity test is the fence against drift.
|
||||||
|
*
|
||||||
|
* Class vocabulary: .letter, .letter__letterhead, .letter__body,
|
||||||
|
* .letter__signature, .letter__footer, .letter__page-break.
|
||||||
|
* Margins arrive as --letter-margin-* custom props (mm, from the OrgTemplate);
|
||||||
|
* the defaults below match the seed template.
|
||||||
|
*
|
||||||
|
* Deliberately self-contained: no --rhc- or --bs- tokens — the backend renderer
|
||||||
|
* has no token bridge. Geometry follows the sample voorbeeldbrief-inschrijving
|
||||||
|
* (A4, return address above the envelope window, reference block, footer rule).
|
||||||
|
*/
|
||||||
|
|
||||||
|
.letter {
|
||||||
|
--letter-margin-top: 25mm;
|
||||||
|
--letter-margin-right: 25mm;
|
||||||
|
--letter-margin-bottom: 25mm;
|
||||||
|
--letter-margin-left: 25mm;
|
||||||
|
|
||||||
|
position: relative;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 210mm; /* A4 */
|
||||||
|
max-width: 100%;
|
||||||
|
min-height: 297mm;
|
||||||
|
margin-inline: auto;
|
||||||
|
padding: var(--letter-margin-top) var(--letter-margin-right) var(--letter-margin-bottom)
|
||||||
|
var(--letter-margin-left);
|
||||||
|
background: #fff;
|
||||||
|
color: #1a1a1a;
|
||||||
|
/* Letter typography is the letter's, not the portal UI's. Licensed RO/Rijks
|
||||||
|
fonts are not shipped; system stack mirrors the app-wide decision (ADR-0003). */
|
||||||
|
font-family:
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
'Segoe UI',
|
||||||
|
Roboto,
|
||||||
|
sans-serif;
|
||||||
|
font-size: 10.5pt;
|
||||||
|
line-height: 1.5;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter p {
|
||||||
|
margin: 0 0 0.75em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter ul,
|
||||||
|
.letter ol {
|
||||||
|
margin: 0 0 0.75em;
|
||||||
|
padding-inline-start: 1.4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Letterhead: wordmark, return address above the envelope window, reference block --- */
|
||||||
|
|
||||||
|
.letter__letterhead {
|
||||||
|
margin-block-end: 12mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter__letterhead .org-wordmark {
|
||||||
|
font-size: 13pt;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 10mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter__letterhead .return-address {
|
||||||
|
font-style: normal;
|
||||||
|
font-size: 7.5pt;
|
||||||
|
white-space: pre-line;
|
||||||
|
color: #555;
|
||||||
|
margin-block-end: 2mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Envelope-window position (~C5 venstercouvert): recipient block. */
|
||||||
|
.letter__letterhead .address-window {
|
||||||
|
min-height: 22mm;
|
||||||
|
font-style: normal;
|
||||||
|
white-space: pre-line;
|
||||||
|
margin-block-end: 8mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter__letterhead .reference {
|
||||||
|
display: flex;
|
||||||
|
gap: 10mm;
|
||||||
|
font-size: 8.5pt;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter__letterhead .reference dt {
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter__letterhead .reference dd {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Body: the case-type template's sections --- */
|
||||||
|
|
||||||
|
.letter__body {
|
||||||
|
flex: 1;
|
||||||
|
/* Above the absolutely-positioned page-break marks: the dashed line stays visible
|
||||||
|
in the gaps but never draws THROUGH opaque content (editors, pickers). */
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter__body h3 {
|
||||||
|
font-size: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 0.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter__body section {
|
||||||
|
margin-block-end: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Signature --- */
|
||||||
|
|
||||||
|
.letter__signature {
|
||||||
|
margin-block-start: 10mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter__signature p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter__signature .signature-name {
|
||||||
|
margin-block-start: 3em; /* room for the (not-shipped) handwritten signature */
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Footer: contact + legal, above a rule --- */
|
||||||
|
|
||||||
|
.letter__footer {
|
||||||
|
margin-block-start: 10mm;
|
||||||
|
padding-block-start: 3mm;
|
||||||
|
border-block-start: 0.5pt solid #999;
|
||||||
|
font-size: 7.5pt;
|
||||||
|
color: #555;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter__footer .footer-contact {
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter__footer .footer-legal {
|
||||||
|
text-align: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Approximate page-break indicator (screen only; PRD §2b honesty rule) ---
|
||||||
|
Positioned per A4-interval by the canvas; the print preview is authoritative. */
|
||||||
|
|
||||||
|
.letter__page-break {
|
||||||
|
position: absolute;
|
||||||
|
inset-inline: 0;
|
||||||
|
border-block-start: 1px dashed #b36200;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter__page-break > span {
|
||||||
|
position: absolute;
|
||||||
|
inset-inline-end: 2mm;
|
||||||
|
inset-block-start: 0;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
/* Above .letter__body: the honesty caption stays legible even over content. */
|
||||||
|
z-index: 2;
|
||||||
|
font-size: 7pt;
|
||||||
|
color: #b36200;
|
||||||
|
background: #fff;
|
||||||
|
padding-inline: 1mm;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Print: real pages, no indicator chrome --- */
|
||||||
|
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
.letter {
|
||||||
|
width: auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter__page-break {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { TestBed } from '@angular/core/testing';
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { Result } from '@shared/kernel/fp';
|
import { Result } from '@shared/kernel/fp';
|
||||||
import { Brief, BriefDecisions } from '@brief/domain/brief';
|
import { Brief, BriefDecisions } from '@brief/domain/brief';
|
||||||
|
import { OrgTemplate } from '@brief/domain/org-template';
|
||||||
import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';
|
import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';
|
||||||
import { BriefStore } from './brief.store';
|
import { BriefStore } from './brief.store';
|
||||||
|
|
||||||
@@ -22,7 +23,20 @@ const brief: Brief = {
|
|||||||
drafterId: 'u1',
|
drafterId: 'u1',
|
||||||
};
|
};
|
||||||
|
|
||||||
const view: BriefView = { brief, availablePassages: [], decisions };
|
const orgTemplate: OrgTemplate = {
|
||||||
|
subOrgId: 'cibg-registers',
|
||||||
|
orgName: 'CIBG — Registers',
|
||||||
|
returnAddress: 'Postbus 00000\n2500 AA Den Haag',
|
||||||
|
footerContact: 'info@voorbeeld.example',
|
||||||
|
footerLegal: 'KvK 00000000',
|
||||||
|
signatureName: 'A. de Vries',
|
||||||
|
signatureRole: 'Hoofd Registratie',
|
||||||
|
signatureClosing: 'Met vriendelijke groet,',
|
||||||
|
margins: { topMm: 25, rightMm: 25, bottomMm: 25, leftMm: 25 },
|
||||||
|
version: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const view: BriefView = { brief, availablePassages: [], decisions, orgTemplate };
|
||||||
|
|
||||||
function setup(adapter: Partial<BriefAdapter>): BriefStore {
|
function setup(adapter: Partial<BriefAdapter>): BriefStore {
|
||||||
TestBed.configureTestingModule({ providers: [{ provide: BriefAdapter, useValue: adapter }] });
|
TestBed.configureTestingModule({ providers: [{ provide: BriefAdapter, useValue: adapter }] });
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
unresolvedPlaceholders,
|
unresolvedPlaceholders,
|
||||||
} from '@brief/domain/brief';
|
} from '@brief/domain/brief';
|
||||||
import { BriefMsg, BriefState, initial, reduce } from '@brief/domain/brief.machine';
|
import { BriefMsg, BriefState, initial, reduce } from '@brief/domain/brief.machine';
|
||||||
|
import { OrgTemplate } from '@brief/domain/org-template';
|
||||||
import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';
|
import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';
|
||||||
|
|
||||||
/** Transient action state (submit/approve/reject/send/resetDemo) — one tagged union
|
/** Transient action state (submit/approve/reject/send/resetDemo) — one tagged union
|
||||||
@@ -48,6 +49,11 @@ export class BriefStore {
|
|||||||
/** Surfaced autosave state for the indicator + aria-live region. */
|
/** Surfaced autosave state for the indicator + aria-live region. */
|
||||||
readonly saveState = signal<SaveState>({ tag: 'Idle' });
|
readonly saveState = signal<SaveState>({ tag: 'Idle' });
|
||||||
|
|
||||||
|
/** The org template the letter renders with (WP-24). Server-owned appearance data,
|
||||||
|
not letter state — held beside the machine, never inside it (`brief.machine.ts`
|
||||||
|
stays untouched by design). Set from every server view that carries it. */
|
||||||
|
readonly orgTemplate = signal<OrgTemplate | null>(null);
|
||||||
|
|
||||||
/** The load lifecycle as `RemoteData`, for `<app-async>` — the machine keeps
|
/** The load lifecycle as `RemoteData`, for `<app-async>` — the machine keeps
|
||||||
owning the letter's own domain lifecycle (draft/submitted/approved/…); this is
|
owning the letter's own domain lifecycle (draft/submitted/approved/…); this is
|
||||||
purely a projection of its loading/failed tags onto the shared async seam. */
|
purely a projection of its loading/failed tags onto the shared async seam. */
|
||||||
@@ -87,8 +93,12 @@ export class BriefStore {
|
|||||||
|
|
||||||
async load() {
|
async load() {
|
||||||
const r = await this.adapter.load();
|
const r = await this.adapter.load();
|
||||||
if (r.ok) this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
|
if (r.ok) {
|
||||||
else this.store.dispatch({ tag: 'BriefLoadFailed', reason: r.error });
|
this.orgTemplate.set(r.value.orgTemplate);
|
||||||
|
this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
|
||||||
|
} else {
|
||||||
|
this.store.dispatch({ tag: 'BriefLoadFailed', reason: r.error });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** An edit: apply it optimistically in the pure reducer, then debounce-save. */
|
/** An edit: apply it optimistically in the pure reducer, then debounce-save. */
|
||||||
@@ -125,6 +135,7 @@ export class BriefStore {
|
|||||||
this.saveState.set({ tag: 'Idle' });
|
this.saveState.set({ tag: 'Idle' });
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
this.actionState.set({ tag: 'Idle' });
|
this.actionState.set({ tag: 'Idle' });
|
||||||
|
this.orgTemplate.set(r.value.orgTemplate);
|
||||||
this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
|
this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
|
||||||
} else {
|
} else {
|
||||||
this.actionState.set({ tag: 'Failed', error: r.error });
|
this.actionState.set({ tag: 'Failed', error: r.error });
|
||||||
@@ -152,6 +163,8 @@ export class BriefStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private applyServerStatus(view: BriefView) {
|
private applyServerStatus(view: BriefView) {
|
||||||
|
// `send` pins the org-template version server-side — mirror whatever came back.
|
||||||
|
this.orgTemplate.set(view.orgTemplate);
|
||||||
const { brief, decisions } = view;
|
const { brief, decisions } = view;
|
||||||
const s = brief.status;
|
const s = brief.status;
|
||||||
switch (s.tag) {
|
switch (s.tag) {
|
||||||
|
|||||||
31
src/app/brief/domain/org-template.ts
Normal file
31
src/app/brief/domain/org-template.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* The organization template (Brief v2 PRD §3, WP-23/24): the SECOND template axis —
|
||||||
|
* appearance/identity per sub-organization (letterhead, footer, signature, margins).
|
||||||
|
* Orthogonal to the case-type template (sections + placeholders); the two only meet
|
||||||
|
* at render time, on the letter canvas. Server-owned: the FE renders it verbatim,
|
||||||
|
* never edits it here (the admin editor is WP-26).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Margins {
|
||||||
|
readonly topMm: number;
|
||||||
|
readonly rightMm: number;
|
||||||
|
readonly bottomMm: number;
|
||||||
|
readonly leftMm: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrgTemplate {
|
||||||
|
readonly subOrgId: string;
|
||||||
|
readonly orgName: string;
|
||||||
|
/** Multiline; rendered above the envelope window. */
|
||||||
|
readonly returnAddress: string;
|
||||||
|
readonly logoDocumentId?: string;
|
||||||
|
/** Multiline contact block in the footer. */
|
||||||
|
readonly footerContact: string;
|
||||||
|
readonly footerLegal: string;
|
||||||
|
readonly signatureName: string;
|
||||||
|
readonly signatureRole: string;
|
||||||
|
readonly signatureClosing: string;
|
||||||
|
readonly margins: Margins;
|
||||||
|
/** 0 = draft; n>0 = the published snapshot this letter renders with. */
|
||||||
|
readonly version: number;
|
||||||
|
}
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { BriefViewDto } from '@shared/infrastructure/api-client';
|
import { BriefViewDto } from '@shared/infrastructure/api-client';
|
||||||
import { parseBrief, parseBriefView, parseNode, parseStatus } from './brief.adapter';
|
import {
|
||||||
|
parseBrief,
|
||||||
|
parseBriefView,
|
||||||
|
parseNode,
|
||||||
|
parseOrgTemplate,
|
||||||
|
parseStatus,
|
||||||
|
} from './brief.adapter';
|
||||||
|
|
||||||
const view: BriefViewDto = {
|
const view: BriefViewDto = {
|
||||||
brief: {
|
brief: {
|
||||||
@@ -47,6 +53,18 @@ const view: BriefViewDto = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
decisions: { canEdit: false, canApprove: true, canReject: true, canSend: false },
|
decisions: { canEdit: false, canApprove: true, canReject: true, canSend: false },
|
||||||
|
orgTemplate: {
|
||||||
|
subOrgId: 'cibg-registers',
|
||||||
|
orgName: 'CIBG — Registers',
|
||||||
|
returnAddress: 'BIG-register\nPostbus 00000\n2500 AA Den Haag',
|
||||||
|
footerContact: 'www.bigregister.nl\ninfo@voorbeeld.example',
|
||||||
|
footerLegal: 'KvK 00000000',
|
||||||
|
signatureName: 'A. de Vries',
|
||||||
|
signatureRole: 'Hoofd Registratie',
|
||||||
|
signatureClosing: 'Met vriendelijke groet,',
|
||||||
|
margins: { topMm: 25, rightMm: 25, bottomMm: 25, leftMm: 25 },
|
||||||
|
version: 1,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('brief.adapter parse boundary', () => {
|
describe('brief.adapter parse boundary', () => {
|
||||||
@@ -76,6 +94,26 @@ describe('brief.adapter parse boundary', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('parses the org template and drops a null logoDocumentId', () => {
|
||||||
|
const r = parseOrgTemplate(view.orgTemplate);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (!r.ok) return;
|
||||||
|
expect(r.value.orgName).toBe('CIBG — Registers');
|
||||||
|
expect(r.value.margins).toEqual({ topMm: 25, rightMm: 25, bottomMm: 25, leftMm: 25 });
|
||||||
|
expect('logoDocumentId' in r.value).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a view whose org template is missing or malformed', () => {
|
||||||
|
expect(parseBriefView({ ...view, orgTemplate: undefined }).ok).toBe(false);
|
||||||
|
expect(parseOrgTemplate({ ...view.orgTemplate, signatureName: undefined }).ok).toBe(false);
|
||||||
|
expect(
|
||||||
|
parseOrgTemplate({
|
||||||
|
...view.orgTemplate,
|
||||||
|
margins: { topMm: 25, rightMm: 25, bottomMm: 25 },
|
||||||
|
}).ok,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects a view whose decisions are missing or malformed', () => {
|
it('rejects a view whose decisions are missing or malformed', () => {
|
||||||
expect(parseBriefView({ ...view, decisions: undefined as never }).ok).toBe(false);
|
expect(parseBriefView({ ...view, decisions: undefined as never }).ok).toBe(false);
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
LetterBlockDto,
|
LetterBlockDto,
|
||||||
LetterSectionDto,
|
LetterSectionDto,
|
||||||
LibraryPassageDto,
|
LibraryPassageDto,
|
||||||
|
OrgTemplateDto,
|
||||||
PlaceholderDefDto,
|
PlaceholderDefDto,
|
||||||
RichTextBlockDto,
|
RichTextBlockDto,
|
||||||
RichTextNodeDto,
|
RichTextNodeDto,
|
||||||
@@ -22,6 +23,7 @@ import {
|
|||||||
LetterSection,
|
LetterSection,
|
||||||
LibraryPassage,
|
LibraryPassage,
|
||||||
} from '@brief/domain/brief';
|
} from '@brief/domain/brief';
|
||||||
|
import { OrgTemplate } from '@brief/domain/org-template';
|
||||||
import { PlaceholderDef } from '@brief/domain/placeholders';
|
import { PlaceholderDef } from '@brief/domain/placeholders';
|
||||||
import { Mark, Paragraph, RichTextBlock, RichTextNode } from '@shared/kernel/rich-text';
|
import { Mark, Paragraph, RichTextBlock, RichTextNode } from '@shared/kernel/rich-text';
|
||||||
|
|
||||||
@@ -37,6 +39,7 @@ export interface BriefView {
|
|||||||
readonly brief: Brief;
|
readonly brief: Brief;
|
||||||
readonly availablePassages: LibraryPassage[];
|
readonly availablePassages: LibraryPassage[];
|
||||||
readonly decisions: BriefDecisions;
|
readonly decisions: BriefDecisions;
|
||||||
|
readonly orgTemplate: OrgTemplate;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BRIEF_LOAD_FAILED = $localize`:@@brief.load.failed:De brief kon niet worden geladen.`;
|
export const BRIEF_LOAD_FAILED = $localize`:@@brief.load.failed:De brief kon niet worden geladen.`;
|
||||||
@@ -301,19 +304,64 @@ function parseDecisions(dto: BriefDecisionsDto | undefined): Result<string, Brie
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseOrgTemplate(dto: OrgTemplateDto | undefined): Result<string, OrgTemplate> {
|
||||||
|
if (
|
||||||
|
typeof dto?.subOrgId !== 'string' ||
|
||||||
|
typeof dto.orgName !== 'string' ||
|
||||||
|
typeof dto.returnAddress !== 'string' ||
|
||||||
|
typeof dto.footerContact !== 'string' ||
|
||||||
|
typeof dto.footerLegal !== 'string' ||
|
||||||
|
typeof dto.signatureName !== 'string' ||
|
||||||
|
typeof dto.signatureRole !== 'string' ||
|
||||||
|
typeof dto.signatureClosing !== 'string' ||
|
||||||
|
typeof dto.version !== 'number'
|
||||||
|
) {
|
||||||
|
return err('org-template: bad shape');
|
||||||
|
}
|
||||||
|
const m = dto.margins;
|
||||||
|
if (
|
||||||
|
typeof m?.topMm !== 'number' ||
|
||||||
|
typeof m.rightMm !== 'number' ||
|
||||||
|
typeof m.bottomMm !== 'number' ||
|
||||||
|
typeof m.leftMm !== 'number'
|
||||||
|
) {
|
||||||
|
return err('org-template: bad margins');
|
||||||
|
}
|
||||||
|
return ok({
|
||||||
|
subOrgId: dto.subOrgId,
|
||||||
|
orgName: dto.orgName,
|
||||||
|
returnAddress: dto.returnAddress,
|
||||||
|
...(dto.logoDocumentId != null ? { logoDocumentId: dto.logoDocumentId } : {}),
|
||||||
|
footerContact: dto.footerContact,
|
||||||
|
footerLegal: dto.footerLegal,
|
||||||
|
signatureName: dto.signatureName,
|
||||||
|
signatureRole: dto.signatureRole,
|
||||||
|
signatureClosing: dto.signatureClosing,
|
||||||
|
margins: { topMm: m.topMm, rightMm: m.rightMm, bottomMm: m.bottomMm, leftMm: m.leftMm },
|
||||||
|
version: dto.version,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function parseBriefView(dto: BriefViewDto): Result<string, BriefView> {
|
export function parseBriefView(dto: BriefViewDto): Result<string, BriefView> {
|
||||||
if (!dto.brief) return err('brief-view: missing brief');
|
if (!dto.brief) return err('brief-view: missing brief');
|
||||||
const brief = parseBrief(dto.brief);
|
const brief = parseBrief(dto.brief);
|
||||||
if (!brief.ok) return brief;
|
if (!brief.ok) return brief;
|
||||||
const decisions = parseDecisions(dto.decisions);
|
const decisions = parseDecisions(dto.decisions);
|
||||||
if (!decisions.ok) return decisions;
|
if (!decisions.ok) return decisions;
|
||||||
|
const orgTemplate = parseOrgTemplate(dto.orgTemplate);
|
||||||
|
if (!orgTemplate.ok) return orgTemplate;
|
||||||
const availablePassages: LibraryPassage[] = [];
|
const availablePassages: LibraryPassage[] = [];
|
||||||
for (const p of dto.availablePassages ?? []) {
|
for (const p of dto.availablePassages ?? []) {
|
||||||
const parsed = parsePassage(p);
|
const parsed = parsePassage(p);
|
||||||
if (!parsed.ok) return parsed;
|
if (!parsed.ok) return parsed;
|
||||||
availablePassages.push(parsed.value);
|
availablePassages.push(parsed.value);
|
||||||
}
|
}
|
||||||
return ok({ brief: brief.value, availablePassages, decisions: decisions.value });
|
return ok({
|
||||||
|
brief: brief.value,
|
||||||
|
availablePassages,
|
||||||
|
decisions: decisions.value,
|
||||||
|
orgTemplate: orgTemplate.value,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- toDto: domain → wire, for save (collapses the union to the flat shape) ---
|
// --- toDto: domain → wire, for save (collapses the union to the flat shape) ---
|
||||||
|
|||||||
@@ -40,28 +40,31 @@ import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-compos
|
|||||||
</ng-template>
|
</ng-template>
|
||||||
<ng-template appAsyncLoaded>
|
<ng-template appAsyncLoaded>
|
||||||
@if (loaded(); as s) {
|
@if (loaded(); as s) {
|
||||||
<div class="brief-toolbar">
|
@if (store.orgTemplate(); as orgTemplate) {
|
||||||
<span class="save" role="status" aria-live="polite">{{ saveText() }}</span>
|
<div class="brief-toolbar">
|
||||||
<app-button variant="subtle" [disabled]="store.busy()" (click)="resetDemo()">{{
|
<span class="save" role="status" aria-live="polite">{{ saveText() }}</span>
|
||||||
resetLabel
|
<app-button variant="subtle" [disabled]="store.busy()" (click)="resetDemo()">{{
|
||||||
}}</app-button>
|
resetLabel
|
||||||
</div>
|
}}</app-button>
|
||||||
<app-letter-composer
|
</div>
|
||||||
[brief]="s.brief"
|
<app-letter-composer
|
||||||
[availablePassages]="s.availablePassages"
|
[brief]="s.brief"
|
||||||
[diagnostics]="store.diagnostics()"
|
[orgTemplate]="orgTemplate"
|
||||||
[canEdit]="store.canEdit()"
|
[availablePassages]="s.availablePassages"
|
||||||
[canApprove]="store.canApprove()"
|
[diagnostics]="store.diagnostics()"
|
||||||
[canReject]="store.canReject()"
|
[canEdit]="store.canEdit()"
|
||||||
[canSend]="store.canSend()"
|
[canApprove]="store.canApprove()"
|
||||||
[canSubmit]="store.canSubmit()"
|
[canReject]="store.canReject()"
|
||||||
[busy]="store.busy()"
|
[canSend]="store.canSend()"
|
||||||
(edit)="store.edit($event)"
|
[canSubmit]="store.canSubmit()"
|
||||||
(submit)="store.submit()"
|
[busy]="store.busy()"
|
||||||
(approve)="store.approve()"
|
(edit)="store.edit($event)"
|
||||||
(reject)="store.reject($event)"
|
(submit)="store.submit()"
|
||||||
(send)="store.send()"
|
(approve)="store.approve()"
|
||||||
/>
|
(reject)="store.reject($event)"
|
||||||
|
(send)="store.send()"
|
||||||
|
/>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</app-async>
|
</app-async>
|
||||||
|
|||||||
317
src/app/brief/ui/letter-canvas/letter-canvas.component.ts
Normal file
317
src/app/brief/ui/letter-canvas/letter-canvas.component.ts
Normal file
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
154
src/app/brief/ui/letter-canvas/letter-canvas.stories.ts
Normal file
154
src/app/brief/ui/letter-canvas/letter-canvas.stories.ts
Normal file
@@ -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: [] },
|
||||||
|
};
|
||||||
@@ -5,18 +5,18 @@ import { ButtonComponent } from '@shared/ui/button/button.component';
|
|||||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||||
import { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component';
|
import { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component';
|
||||||
import { Brief, LibraryPassage } from '@brief/domain/brief';
|
import { Brief, LibraryPassage } from '@brief/domain/brief';
|
||||||
|
import { OrgTemplate } from '@brief/domain/org-template';
|
||||||
import { Diagnostic } from '@brief/domain/placeholders';
|
import { Diagnostic } from '@brief/domain/placeholders';
|
||||||
import { BriefMsg } from '@brief/domain/brief.machine';
|
import { BriefMsg } from '@brief/domain/brief.machine';
|
||||||
import { LetterSectionComponent } from '@brief/ui/letter-section/letter-section.component';
|
import { LetterCanvasComponent } from '@brief/ui/letter-canvas/letter-canvas.component';
|
||||||
import { LetterPreviewComponent } from '@brief/ui/letter-preview/letter-preview.component';
|
|
||||||
import { DiagnosticsPanelComponent } from '@brief/ui/diagnostics-panel/diagnostics-panel.component';
|
import { DiagnosticsPanelComponent } from '@brief/ui/diagnostics-panel/diagnostics-panel.component';
|
||||||
import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejection-comments.component';
|
import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejection-comments.component';
|
||||||
|
|
||||||
/** Organism: the whole letter — status badge, the editable sections (drafter) or a
|
/** Organism: the whole letter flow — status badge, the letter canvas (editable in
|
||||||
read-only preview (approver / locked), the diagnostics panel, and the action bar
|
place for the drafter, read-only for approver/locked), the diagnostics panel, and
|
||||||
appropriate to status × permission. Presentational: emits edit + transition
|
the action bar appropriate to status × permission. Presentational: emits edit +
|
||||||
intents; the canEdit/canApprove/canReject/canSend inputs are server-computed
|
transition intents; the canEdit/canApprove/canReject/canSend inputs are
|
||||||
decision flags (PRD-0002 phase P1) — this component never derives them. */
|
server-computed decision flags (PRD-0002 phase P1) — this component never derives them. */
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-letter-composer',
|
selector: 'app-letter-composer',
|
||||||
imports: [
|
imports: [
|
||||||
@@ -24,8 +24,7 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
|
|||||||
StatusBadgeComponent,
|
StatusBadgeComponent,
|
||||||
ButtonComponent,
|
ButtonComponent,
|
||||||
AlertComponent,
|
AlertComponent,
|
||||||
LetterSectionComponent,
|
LetterCanvasComponent,
|
||||||
LetterPreviewComponent,
|
|
||||||
DiagnosticsPanelComponent,
|
DiagnosticsPanelComponent,
|
||||||
RejectionCommentsComponent,
|
RejectionCommentsComponent,
|
||||||
],
|
],
|
||||||
@@ -42,10 +41,6 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
margin-block-end: var(--rhc-space-max-lg);
|
margin-block-end: var(--rhc-space-max-lg);
|
||||||
}
|
}
|
||||||
.sections {
|
|
||||||
display: grid;
|
|
||||||
gap: var(--rhc-space-max-2xl);
|
|
||||||
}
|
|
||||||
.panel {
|
.panel {
|
||||||
margin-block: var(--rhc-space-max-xl);
|
margin-block: var(--rhc-space-max-xl);
|
||||||
}
|
}
|
||||||
@@ -68,21 +63,15 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
|
|||||||
<app-rejection-comments mode="show" [comments]="rejectComments()" />
|
<app-rejection-comments mode="show" [comments]="rejectComments()" />
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (canEdit()) {
|
<app-letter-canvas
|
||||||
<div class="sections">
|
[brief]="brief()"
|
||||||
@for (section of brief().sections; track section.sectionKey) {
|
[orgTemplate]="orgTemplate()"
|
||||||
<app-letter-section
|
[editableRegions]="canEdit() ? 'content' : 'none'"
|
||||||
[section]="section"
|
[availablePassages]="availablePassages()"
|
||||||
[availablePassages]="availablePassages()"
|
[placeholders]="menu()"
|
||||||
[placeholders]="menu()"
|
[diagnostics]="diagnostics()"
|
||||||
[editable]="!section.locked"
|
(edit)="edit.emit($event)"
|
||||||
(edit)="edit.emit($event)"
|
/>
|
||||||
/>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<app-letter-preview [brief]="brief()" [diagnostics]="diagnostics()" />
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<app-diagnostics-panel [diagnostics]="diagnostics()" (locate)="locate.emit($event)" />
|
<app-diagnostics-panel [diagnostics]="diagnostics()" (locate)="locate.emit($event)" />
|
||||||
@@ -139,6 +128,7 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
|
|||||||
})
|
})
|
||||||
export class LetterComposerComponent {
|
export class LetterComposerComponent {
|
||||||
brief = input.required<Brief>();
|
brief = input.required<Brief>();
|
||||||
|
orgTemplate = input.required<OrgTemplate>();
|
||||||
availablePassages = input<readonly LibraryPassage[]>([]);
|
availablePassages = input<readonly LibraryPassage[]>([]);
|
||||||
diagnostics = input<readonly Diagnostic[]>([]);
|
diagnostics = input<readonly Diagnostic[]>([]);
|
||||||
canEdit = input(false);
|
canEdit = input(false);
|
||||||
|
|||||||
@@ -2,6 +2,20 @@ import type { Meta, StoryObj } from '@storybook/angular';
|
|||||||
import { LetterComposerComponent } from './letter-composer.component';
|
import { LetterComposerComponent } from './letter-composer.component';
|
||||||
import { Brief, BriefDecisions, BriefStatus, LibraryPassage } from '@brief/domain/brief';
|
import { Brief, BriefDecisions, BriefStatus, LibraryPassage } from '@brief/domain/brief';
|
||||||
import { allDiagnostics } from '@brief/domain/brief';
|
import { allDiagnostics } from '@brief/domain/brief';
|
||||||
|
import { OrgTemplate } from '@brief/domain/org-template';
|
||||||
|
|
||||||
|
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',
|
||||||
|
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[] = [
|
const passages: LibraryPassage[] = [
|
||||||
{
|
{
|
||||||
@@ -106,15 +120,16 @@ function brief(status: BriefStatus): Brief {
|
|||||||
const render = (b: Brief, decisions: BriefDecisions) => ({
|
const render = (b: Brief, decisions: BriefDecisions) => ({
|
||||||
props: {
|
props: {
|
||||||
brief: b,
|
brief: b,
|
||||||
|
orgTemplate,
|
||||||
availablePassages: passages,
|
availablePassages: passages,
|
||||||
diagnostics: allDiagnostics(b),
|
diagnostics: allDiagnostics(b),
|
||||||
...decisions,
|
...decisions,
|
||||||
canSubmit: true,
|
canSubmit: true,
|
||||||
busy: false,
|
busy: false,
|
||||||
},
|
},
|
||||||
template: `<app-letter-composer [brief]="brief" [availablePassages]="availablePassages" [diagnostics]="diagnostics"
|
template: `<app-letter-composer [brief]="brief" [orgTemplate]="orgTemplate" [availablePassages]="availablePassages"
|
||||||
[canEdit]="canEdit" [canApprove]="canApprove" [canReject]="canReject" [canSend]="canSend"
|
[diagnostics]="diagnostics" [canEdit]="canEdit" [canApprove]="canApprove" [canReject]="canReject"
|
||||||
[canSubmit]="canSubmit" [busy]="busy"></app-letter-composer>`,
|
[canSend]="canSend" [canSubmit]="canSubmit" [busy]="busy"></app-letter-composer>`,
|
||||||
});
|
});
|
||||||
|
|
||||||
const meta: Meta<LetterComposerComponent> = {
|
const meta: Meta<LetterComposerComponent> = {
|
||||||
|
|||||||
@@ -1,175 +0,0 @@
|
|||||||
import { Component, computed, input, signal } from '@angular/core';
|
|
||||||
import { NgTemplateOutlet } from '@angular/common';
|
|
||||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
|
||||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
|
||||||
import { PlaceholderChipComponent } from '@shared/ui/placeholder-chip/placeholder-chip.component';
|
|
||||||
import { formatDatumNl } from '@shared/kernel/datum';
|
|
||||||
import { Paragraph } from '@shared/kernel/rich-text';
|
|
||||||
import { Brief, LetterBlock } from '@brief/domain/brief';
|
|
||||||
import { Diagnostic } from '@brief/domain/placeholders';
|
|
||||||
|
|
||||||
/** 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',
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Organism: read-only rendering of the letter as the approver/recipient sees it.
|
|
||||||
Placeholders show as labelled chips (values are resolved server-side only at send);
|
|
||||||
a chip flagged by the linter shows its error/warning state. The "Voorbeeld" toggle
|
|
||||||
fills auto-resolvable placeholders with sample values to preview the sent result. */
|
|
||||||
@Component({
|
|
||||||
selector: 'app-letter-preview',
|
|
||||||
imports: [NgTemplateOutlet, HeadingComponent, ButtonComponent, PlaceholderChipComponent],
|
|
||||||
styles: [
|
|
||||||
`
|
|
||||||
:host {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
.toolbar {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
margin-block-end: var(--rhc-space-max-sm);
|
|
||||||
}
|
|
||||||
.letter {
|
|
||||||
background: var(--rhc-color-wit);
|
|
||||||
border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);
|
|
||||||
border-radius: var(--rhc-border-radius-md);
|
|
||||||
padding: var(--rhc-space-max-2xl);
|
|
||||||
}
|
|
||||||
section {
|
|
||||||
margin-block-end: var(--rhc-space-max-xl);
|
|
||||||
}
|
|
||||||
p {
|
|
||||||
margin: 0 0 var(--rhc-space-max-sm);
|
|
||||||
}
|
|
||||||
ul,
|
|
||||||
ol {
|
|
||||||
margin: 0 0 var(--rhc-space-max-sm);
|
|
||||||
padding-inline-start: 1.4em;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
],
|
|
||||||
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>
|
|
||||||
|
|
||||||
<div class="toolbar">
|
|
||||||
<app-button
|
|
||||||
variant="subtle"
|
|
||||||
(click)="showSample.set(!showSample())"
|
|
||||||
[attr.aria-pressed]="showSample()"
|
|
||||||
>
|
|
||||||
{{ showSample() ? hideSampleLabel() : showSampleLabel() }}
|
|
||||||
</app-button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="letter">
|
|
||||||
@for (section of brief().sections; track section.sectionKey) {
|
|
||||||
<section>
|
|
||||||
<app-heading [level]="3">{{ section.title }}</app-heading>
|
|
||||||
@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>
|
|
||||||
`,
|
|
||||||
})
|
|
||||||
export class LetterPreviewComponent {
|
|
||||||
brief = input.required<Brief>();
|
|
||||||
diagnostics = input<readonly Diagnostic[]>([]);
|
|
||||||
|
|
||||||
showSampleLabel = input($localize`:@@brief.preview.showSample:Voorbeeld met testwaarden`);
|
|
||||||
hideSampleLabel = input($localize`:@@brief.preview.hideSample:Testwaarden verbergen`);
|
|
||||||
|
|
||||||
protected showSample = signal(false);
|
|
||||||
private today = formatDatumNl(new Date());
|
|
||||||
|
|
||||||
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.today : this.labelFor(key));
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/angular';
|
|
||||||
import { Brief } from '@brief/domain/brief';
|
|
||||||
import { Diagnostic } from '@brief/domain/placeholders';
|
|
||||||
import { LetterPreviewComponent } from './letter-preview.component';
|
|
||||||
|
|
||||||
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: '.' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const diagnostics: Diagnostic[] = [
|
|
||||||
{
|
|
||||||
severity: 'warning',
|
|
||||||
code: 'unresolved-at-send',
|
|
||||||
placeholderKey: 'reden_besluit',
|
|
||||||
location: { blockId: 'local-2', paragraphIndex: 0, nodeIndex: 1 },
|
|
||||||
message: '"Reden besluit" is nog niet ingevuld.',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const meta: Meta<LetterPreviewComponent> = {
|
|
||||||
title: 'Domein/Brief/Letter Preview',
|
|
||||||
component: LetterPreviewComponent,
|
|
||||||
args: { brief, diagnostics },
|
|
||||||
};
|
|
||||||
export default meta;
|
|
||||||
type Story = StoryObj<LetterPreviewComponent>;
|
|
||||||
|
|
||||||
export const Default: Story = {};
|
|
||||||
export const ZonderBevindingen: Story = { args: { diagnostics: [] } };
|
|
||||||
@@ -14,6 +14,7 @@ import { LibraryPassage } from '@brief/domain/brief';
|
|||||||
`
|
`
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
|
background: var(--rhc-color-wit, #fff);
|
||||||
border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);
|
border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);
|
||||||
border-radius: var(--rhc-border-radius-md);
|
border-radius: var(--rhc-border-radius-md);
|
||||||
padding: var(--rhc-space-max-md);
|
padding: var(--rhc-space-max-md);
|
||||||
@@ -35,6 +36,7 @@ import { LibraryPassage } from '@brief/domain/brief';
|
|||||||
@for (p of passages(); track p.passageId) {
|
@for (p of passages(); track p.passageId) {
|
||||||
<li>
|
<li>
|
||||||
<app-checkbox
|
<app-checkbox
|
||||||
|
[checkboxId]="'passage-' + p.passageId"
|
||||||
[label]="p.label"
|
[label]="p.label"
|
||||||
[ngModel]="!!checked()[p.passageId]"
|
[ngModel]="!!checked()[p.passageId]"
|
||||||
(ngModelChange)="set(p.passageId, $event)"
|
(ngModelChange)="set(p.passageId, $event)"
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
url(../fonts|icons|images) refs resolve against the vendored folder at runtime.
|
url(../fonts|icons|images) refs resolve against the vendored folder at runtime.
|
||||||
Licensed Rijksoverheid fonts are not used — styles.scss overrides the stack to system-ui. -->
|
Licensed Rijksoverheid fonts are not used — styles.scss overrides the stack to system-ui. -->
|
||||||
<link rel="stylesheet" href="cibg-huisstijl/css/huisstijl.min.css" />
|
<link rel="stylesheet" href="cibg-huisstijl/css/huisstijl.min.css" />
|
||||||
|
<!-- The letter-rendering contract (WP-24): shared verbatim with the backend's
|
||||||
|
HTML preview renderer (WP-25 inlines this same file) — keep it self-contained. -->
|
||||||
|
<link rel="stylesheet" href="letter.css" />
|
||||||
</head>
|
</head>
|
||||||
<!-- brand--cibg activates CIBG's official palette: robijn layout chrome + lintblauw accents
|
<!-- brand--cibg activates CIBG's official palette: robijn layout chrome + lintblauw accents
|
||||||
(without it, --ro-layout falls back to the blue default). -->
|
(without it, --ro-layout falls back to the blue default). -->
|
||||||
|
|||||||
Reference in New Issue
Block a user