diff --git a/docs/backlog/README.md b/docs/backlog/README.md index 120d490..6fa9cb9 100644 --- a/docs/backlog/README.md +++ b/docs/backlog/README.md @@ -70,7 +70,7 @@ for its existing violations, so every WP ends green. | [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 | done | | [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 | done | | [WP-27](WP-27-brief-ux-layer.md) | Brief UX layer (undo/redo, standaardbrief, diff) | 6 · Brief v2 | todo | | [WP-28](WP-28-brief-v2-demo-polish.md) | Brief v2 demo polish (scenarios, e2e, docs) | 6 · Brief v2 | todo | diff --git a/docs/backlog/WP-26-org-template-editor.md b/docs/backlog/WP-26-org-template-editor.md index 5458f8b..9342e01 100644 --- a/docs/backlog/WP-26-org-template-editor.md +++ b/docs/backlog/WP-26-org-template-editor.md @@ -1,6 +1,6 @@ # WP-26 — Admin org-template editor -Status: todo +Status: done Phase: 6 — Brief v2 (edit-on-the-letter, org templates, server-rendered preview) ## Why @@ -58,17 +58,45 @@ content a read-only sample). PRD Brief v2 §5, §7h. ## Acceptance criteria -- [ ] `?role=admin` can switch sub-orgs, edit all template fields in place on the +- [x] `?role=admin` can switch sub-orgs, edit all template fields in place on the canvas, and see the canvas update live. -- [ ] Draft saves are debounced; publish asks for confirmation showing the impact +- [x] Draft saves are debounced; publish asks for confirmation showing the impact count; after publish the drafter's canvas (WP-24) reflects it on reload. -- [ ] Version history lists published versions (who is faked, when is real); +- [x] Version history lists published versions (who is faked, when is real); rollback copies an old version into the draft. -- [ ] Non-admin on `/brief/huisstijl` sees the denial alert; API would 403 anyway. -- [ ] Logo upload validates type/size client-side (existing `rejectReason`) and +- [x] Non-admin on `/brief/huisstijl` sees the denial alert; API would 403 anyway. +- [x] Logo upload validates type/size client-side (existing `rejectReason`) and renders on the canvas after upload. -- [ ] Machine spec covers field edits, dirty tracking, publish/rollback outcomes. -- [ ] Full GREEN. +- [x] Machine spec covers field edits, dirty tracking, publish/rollback outcomes. +- [x] Full GREEN. + +## Deviations / notes (as built) + +- **`check:tokens` was already red on `main`** (WP-24's canvas landed `var(--rhc-*, + #hex)` fallbacks + an `rgb()` paper shadow, and WP-25 committed over it). Fixed here + to end GREEN: dropped the redundant hex fallbacks (the token bridge defines every + one) and marked the paper drop-shadow `token-ok`; also fixed a pre-existing + `passage-picker` `var(--rhc-color-wit, #fff)` hit. +- **Canvas edit-in-place**: `editableRegions='template'` now renders the seven + org-identity text fields as inline ``/`\n } @else {\n

{{ orgTemplate().orgName }}

\n
{{ orgTemplate().returnAddress }}
\n }\n
{{ recipientText() }}
\n
\n
\n
{{ referenceLabel() }}
\n
{{ brief().briefId }}
\n
\n
\n
{{ dateLabel() }}
\n
{{ letterDate }}
\n
\n
\n \n\n
\n @if (editableRegions() === 'content') {\n @for (section of brief().sections; track section.sectionKey) {\n
\n \n
\n }\n } @else {\n @for (section of brief().sections; track section.sectionKey) {\n
\n

{{ section.title }}

\n @for (block of section.blocks; track block.blockId) {\n @for (seg of segmentsOf(block); track $index) {\n @if (seg.list === 'bullet') {\n \n } @else if (seg.list === 'number') {\n
    \n @for (para of seg.items; track $index) {\n
  1. \n \n
  2. \n }\n
\n } @else {\n

\n \n

\n }\n }\n }\n
\n }\n }\n
\n\n
\n @if (editing()) {\n \n \n \n } @else {\n

{{ orgTemplate().signatureClosing }}

\n

{{ orgTemplate().signatureName }}

\n

{{ orgTemplate().signatureRole }}

\n }\n
\n\n
\n @if (editing()) {\n \n \n } @else {\n
{{ orgTemplate().footerContact }}
\n
{{ orgTemplate().footerLegal }}
\n }\n
\n\n @for (top of pageBreaks(); track $index) {\n
\n {{ pageBreakCaption() }}\n
\n }\n \n\n", "templateUrl": [], "viewProviders": [], "hostDirectives": [], @@ -18196,7 +19485,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 237, + "line": 315, "required": false }, { @@ -18207,7 +19496,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 232, + "line": 310, "required": true }, { @@ -18218,7 +19507,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 255, + "line": 337, "required": false }, { @@ -18230,7 +19519,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 239, + "line": 317, "required": false }, { @@ -18242,10 +19531,32 @@ "indexKey": "", "optional": false, "description": "

Who edits what on the surface: drafter ('content'), read-only ('none'),\nadmin editor ('template', consumer arrives in WP-26).

\n", - "line": 236, + "line": 314, "rawdescription": "\nWho edits what on the surface: drafter ('content'), read-only ('none'),\nadmin editor ('template', consumer arrives in WP-26).", "required": false }, + { + "name": "footerContactLabel", + "defaultValue": "$localize`:@@brief.canvas.footerContact:Contactgegevens (voettekst)`", + "deprecated": false, + "deprecationMessage": "", + "indexKey": "", + "optional": false, + "description": "", + "line": 344, + "required": false + }, + { + "name": "footerLegalLabel", + "defaultValue": "$localize`:@@brief.canvas.footerLegal:Juridische voettekst`", + "deprecated": false, + "deprecationMessage": "", + "indexKey": "", + "optional": false, + "description": "", + "line": 345, + "required": false + }, { "name": "fromTemplateCaption", "defaultValue": " $localize`:@@brief.canvas.fromTemplate:Komt uit de huisstijl van de organisatie — niet bewerkbaar in de brief.`, ", @@ -18254,7 +19565,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 245, + "line": 327, "required": false }, { @@ -18265,7 +19576,42 @@ "indexKey": "", "optional": false, "description": "", - "line": 244, + "line": 326, + "required": false + }, + { + "name": "logoAlt", + "defaultValue": "$localize`:@@brief.canvas.logoAlt:Logo van de organisatie`", + "deprecated": false, + "deprecationMessage": "", + "indexKey": "", + "optional": false, + "description": "", + "line": 338, + "required": false + }, + { + "name": "logoUrl", + "defaultValue": "null", + "deprecated": false, + "deprecationMessage": "", + "type": "string | null", + "indexKey": "", + "optional": false, + "description": "

The org logo's content URL (letterhead), or null when none is set.

\n", + "line": 320, + "rawdescription": "\nThe org logo's content URL (letterhead), or null when none is set.", + "required": false + }, + { + "name": "orgNameLabel", + "defaultValue": "$localize`:@@brief.canvas.orgName:Organisatienaam`", + "deprecated": false, + "deprecationMessage": "", + "indexKey": "", + "optional": false, + "description": "", + "line": 339, "required": false }, { @@ -18276,7 +19622,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 233, + "line": 311, "required": true }, { @@ -18287,7 +19633,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 248, + "line": 330, "required": false }, { @@ -18299,7 +19645,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 238, + "line": 316, "required": false }, { @@ -18310,7 +19656,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 251, + "line": 333, "required": false }, { @@ -18321,7 +19667,18 @@ "indexKey": "", "optional": false, "description": "", - "line": 254, + "line": 336, + "required": false + }, + { + "name": "returnAddressLabel", + "defaultValue": "$localize`:@@brief.canvas.returnAddress:Retouradres`", + "deprecated": false, + "deprecationMessage": "", + "indexKey": "", + "optional": false, + "description": "", + "line": 340, "required": false }, { @@ -18332,7 +19689,40 @@ "indexKey": "", "optional": false, "description": "", - "line": 243, + "line": 325, + "required": false + }, + { + "name": "signatureClosingLabel", + "defaultValue": "$localize`:@@brief.canvas.signatureClosing:Afsluiting`", + "deprecated": false, + "deprecationMessage": "", + "indexKey": "", + "optional": false, + "description": "", + "line": 341, + "required": false + }, + { + "name": "signatureNameLabel", + "defaultValue": "$localize`:@@brief.canvas.signatureName:Naam ondertekenaar`", + "deprecated": false, + "deprecationMessage": "", + "indexKey": "", + "optional": false, + "description": "", + "line": 342, + "required": false + }, + { + "name": "signatureRoleLabel", + "defaultValue": "$localize`:@@brief.canvas.signatureRole:Functie ondertekenaar`", + "deprecated": false, + "deprecationMessage": "", + "indexKey": "", + "optional": false, + "description": "", + "line": 343, "required": false }, { @@ -18343,7 +19733,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 240, + "line": 318, "required": false } ], @@ -18356,7 +19746,19 @@ "indexKey": "", "optional": false, "description": "", - "line": 241, + "line": 321, + "required": false + }, + { + "name": "templateEdit", + "deprecated": false, + "deprecationMessage": "", + "type": "{ field: OrgTemplateTextField; value: string }", + "indexKey": "", + "optional": false, + "description": "

An in-place edit to an org-identity field (only in editableRegions='template').

\n", + "line": 323, + "rawdescription": "\nAn in-place edit to an org-identity field (only in `editableRegions='template'`).", "required": false } ], @@ -18370,7 +19772,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 289, + "line": 385, "modifierKind": [ 124 ] @@ -18384,11 +19786,26 @@ "indexKey": "", "optional": false, "description": "", - "line": 276, + "line": 372, "modifierKind": [ 123 ] }, + { + "name": "editing", + "defaultValue": "computed(() => this.editableRegions() === 'template')", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "

Admin edit-in-place: the org-identity regions render as controls.

\n", + "line": 354, + "rawdescription": "\nAdmin edit-in-place: the org-identity regions render as controls.", + "modifierKind": [ + 124 + ] + }, { "name": "labelFor", "defaultValue": "() => {...}", @@ -18398,7 +19815,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 288, + "line": 384, "modifierKind": [ 124 ] @@ -18412,7 +19829,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 258, + "line": 348, "modifierKind": [ 124 ] @@ -18426,7 +19843,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 264, + "line": 360, "modifierKind": [ 124 ] @@ -18440,7 +19857,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 296, + "line": 392, "modifierKind": [ 123 ] @@ -18454,7 +19871,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 297, + "line": 393, "modifierKind": [ 124 ] @@ -18468,7 +19885,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 291, + "line": 387, "modifierKind": [ 124 ] @@ -18482,7 +19899,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 287, + "line": 383, "modifierKind": [ 124 ] @@ -18496,7 +19913,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 257, + "line": 347, "modifierKind": [ 124 ] @@ -18510,7 +19927,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 290, + "line": 386, "modifierKind": [ 124 ] @@ -18524,7 +19941,7 @@ "indexKey": "", "optional": false, "description": "

The letterhead/signature/footer are tinted "not yours" only while composing —\nin 'none' the whole surface is read-only, in 'template' they ARE the editable focus.

\n", - "line": 262, + "line": 352, "rawdescription": "\nThe letterhead/signature/footer are tinted \"not yours\" only while composing —\nin 'none' the whole surface is read-only, in 'template' they ARE the editable focus.", "modifierKind": [ 124 @@ -18539,13 +19956,68 @@ "indexKey": "", "optional": false, "description": "", - "line": 277, + "line": 373, "modifierKind": [ 123 ] } ], - "methodsClass": [], + "methodsClass": [ + { + "name": "emitEdit", + "args": [ + { + "name": "field", + "type": "OrgTemplateTextField", + "optional": false, + "dotDotDotToken": false, + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "event", + "type": "Event", + "optional": false, + "dotDotDotToken": false, + "deprecated": false, + "deprecationMessage": "" + } + ], + "optional": false, + "returnType": "void", + "typeParameters": [], + "line": 356, + "deprecated": false, + "deprecationMessage": "", + "modifierKind": [ + 124 + ], + "jsdoctags": [ + { + "name": "field", + "type": "OrgTemplateTextField", + "optional": false, + "dotDotDotToken": false, + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "event", + "type": "Event", + "optional": false, + "dotDotDotToken": false, + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + } + ], "deprecated": false, "deprecationMessage": "", "hostBindings": [], @@ -18571,23 +20043,23 @@ "description": "

Organism: the letter as one surface — the org template's letterhead, signature and\nfooter around the case-type template's sections. editableRegions picks who edits\nwhat: 'content' hosts the editable letter-sections in place (drafter), 'none'\nrenders everything read-only (approver/locked, absorbs the old letter-preview),\n'template' reserves the org-identity regions for the admin editor (WP-26).\nLetter typography/geometry come from the shared public/letter.css contract —\nthe same file the backend preview renderer inlines (WP-25).

\n", "rawdescription": "\nOrganism: the letter as one surface — the org template's letterhead, signature and\nfooter around the case-type template's sections. `editableRegions` picks who edits\nwhat: `'content'` hosts the editable letter-sections in place (drafter), `'none'`\nrenders everything read-only (approver/locked, absorbs the old letter-preview),\n`'template'` reserves the org-identity regions for the admin editor (WP-26).\nLetter typography/geometry come from the shared `public/letter.css` contract —\nthe same file the backend preview renderer inlines (WP-25).", "type": "component", - "sourceCode": "import {\n Component,\n DestroyRef,\n ElementRef,\n computed,\n effect,\n inject,\n input,\n output,\n signal,\n viewChild,\n} from '@angular/core';\nimport { NgTemplateOutlet } from '@angular/common';\nimport { ButtonComponent } from '@shared/ui/button/button.component';\nimport { PlaceholderChipComponent } from '@shared/ui/placeholder-chip/placeholder-chip.component';\nimport { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component';\nimport { formatDatumNl } from '@shared/kernel/datum';\nimport { Paragraph } from '@shared/kernel/rich-text';\nimport { Brief, LetterBlock, LibraryPassage } from '@brief/domain/brief';\nimport { OrgTemplate } from '@brief/domain/org-template';\nimport { Diagnostic } from '@brief/domain/placeholders';\nimport { BriefMsg } from '@brief/domain/brief.machine';\nimport { LetterSectionComponent } from '@brief/ui/letter-section/letter-section.component';\n\n/** A run of consecutive lines to render together: a list (bullet/number) or a single plain line. */\ntype PreviewSegment = {\n readonly list: 'bullet' | 'number' | null;\n readonly items: readonly Paragraph[];\n};\n\nfunction groupParagraphs(paras: readonly Paragraph[]): PreviewSegment[] {\n const out: { list: 'bullet' | 'number' | null; items: Paragraph[] }[] = [];\n for (const para of paras) {\n const kind = para.list ?? null;\n const last = out[out.length - 1];\n if (kind && last && last.list === kind) last.items.push(para);\n else out.push({ list: kind, items: [para] });\n }\n return out;\n}\n\n// Illustrative values for the \"Voorbeeld\" toggle — what send resolves server-side.\nconst SAMPLE_VALUES: Record = {\n naam_zorgverlener: 'J. Jansen',\n big_nummer: '12345678901',\n};\n\n/** A4 height in CSS px (1in = 96px = 25.4mm) — for the approximate page-break marks. */\nconst A4_HEIGHT_PX = (297 * 96) / 25.4;\n\n/** Organism: the letter as one surface — the org template's letterhead, signature and\n footer around the case-type template's sections. `editableRegions` picks who edits\n what: `'content'` hosts the editable letter-sections in place (drafter), `'none'`\n renders everything read-only (approver/locked, absorbs the old letter-preview),\n `'template'` reserves the org-identity regions for the admin editor (WP-26).\n Letter typography/geometry come from the shared `public/letter.css` contract —\n the same file the backend preview renderer inlines (WP-25). */\n@Component({\n selector: 'app-letter-canvas',\n imports: [NgTemplateOutlet, ButtonComponent, PlaceholderChipComponent, LetterSectionComponent],\n styles: [\n `\n :host {\n display: block;\n }\n .toolbar {\n display: flex;\n justify-content: flex-end;\n margin-block-end: var(--rhc-space-max-sm);\n }\n /* Portal-side chrome around the letter surface (not part of the contract file). */\n .surface {\n background: var(--rhc-color-grijs-100, #f0f0f0);\n border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);\n border-radius: var(--rhc-border-radius-md);\n padding: var(--rhc-space-max-lg);\n overflow-x: auto;\n }\n .surface .letter {\n box-shadow: 0 1px 4px rgb(0 0 0 / 0.15);\n }\n /* Org-identity regions: visibly not the drafter's to edit. */\n .from-template {\n background: var(--rhc-color-grijs-100, #f5f5f5);\n outline: 2mm solid var(--rhc-color-grijs-100, #f5f5f5);\n }\n .from-template-caption {\n font-size: 7.5pt;\n /* grijs-700, not foreground-subtle: subtle misses AA contrast on the tint. */\n color: var(--rhc-color-grijs-700, #334155);\n margin: 0 0 2mm;\n }\n `,\n ],\n template: `\n \n @for (node of nodes; track $index) {\n @switch (node.type) {\n @case ('text') {\n {{ node.text }}\n }\n @case ('lineBreak') {\n
\n }\n @case ('placeholder') {\n @if (showSample() && autoFor(node.key)) {\n {{ sampleFor(node.key) }}\n } @else {\n \n }\n }\n }\n }\n
\n\n @if (editableRegions() === 'none') {\n
\n \n {{ showSample() ? hideSampleLabel() : showSampleLabel() }}\n \n
\n }\n\n
\n
\n \n
\n @if (tintTemplate()) {\n

{{ fromTemplateCaption() }}

\n }\n

{{ orgTemplate().orgName }}

\n
{{ orgTemplate().returnAddress }}
\n
{{ recipientText() }}
\n
\n
\n
{{ referenceLabel() }}
\n
{{ brief().briefId }}
\n
\n
\n
{{ dateLabel() }}
\n
{{ letterDate }}
\n
\n
\n
\n\n
\n @if (editableRegions() === 'content') {\n @for (section of brief().sections; track section.sectionKey) {\n
\n \n
\n }\n } @else {\n @for (section of brief().sections; track section.sectionKey) {\n
\n

{{ section.title }}

\n @for (block of section.blocks; track block.blockId) {\n @for (seg of segmentsOf(block); track $index) {\n @if (seg.list === 'bullet') {\n
    \n @for (para of seg.items; track $index) {\n
  • \n \n
  • \n }\n
\n } @else if (seg.list === 'number') {\n
    \n @for (para of seg.items; track $index) {\n
  1. \n \n
  2. \n }\n
\n } @else {\n

\n \n

\n }\n }\n }\n
\n }\n }\n
\n\n
\n

{{ orgTemplate().signatureClosing }}

\n

{{ orgTemplate().signatureName }}

\n

{{ orgTemplate().signatureRole }}

\n
\n\n
\n
{{ orgTemplate().footerContact }}
\n
{{ orgTemplate().footerLegal }}
\n
\n\n @for (top of pageBreaks(); track $index) {\n
\n {{ pageBreakCaption() }}\n
\n }\n
\n
\n `,\n})\nexport class LetterCanvasComponent {\n brief = input.required();\n orgTemplate = input.required();\n /** Who edits what on the surface: drafter ('content'), read-only ('none'),\n admin editor ('template', consumer arrives in WP-26). */\n editableRegions = input<'content' | 'template' | 'none'>('none');\n availablePassages = input([]);\n placeholders = input([]);\n diagnostics = input([]);\n zoom = input(1);\n edit = output();\n\n showSampleLabel = input($localize`:@@brief.preview.showSample:Voorbeeld met testwaarden`);\n hideSampleLabel = input($localize`:@@brief.preview.hideSample:Testwaarden verbergen`);\n fromTemplateCaption = input(\n $localize`:@@brief.canvas.fromTemplate:Komt uit de huisstijl van de organisatie — niet bewerkbaar in de brief.`,\n );\n pageBreakCaption = input(\n $localize`:@@brief.canvas.pageBreak:±pagina-einde — afdrukvoorbeeld is leidend`,\n );\n recipientText = input(\n $localize`:@@brief.canvas.recipient:Adres van de geadresseerde\\n(wordt ingevuld bij verzending)`,\n );\n referenceLabel = input($localize`:@@brief.canvas.reference:Ons kenmerk`);\n dateLabel = input($localize`:@@brief.canvas.date:Datum`);\n\n protected showSample = signal(false);\n protected letterDate = formatDatumNl(new Date());\n\n /** The letterhead/signature/footer are tinted \"not yours\" only while composing —\n in 'none' the whole surface is read-only, in 'template' they ARE the editable focus. */\n protected tintTemplate = computed(() => this.editableRegions() === 'content');\n\n protected marginStyle = computed(() => {\n const m = this.orgTemplate().margins;\n return {\n '--letter-margin-top': `${m.topMm}mm`,\n '--letter-margin-right': `${m.rightMm}mm`,\n '--letter-margin-bottom': `${m.bottomMm}mm`,\n '--letter-margin-left': `${m.leftMm}mm`,\n };\n });\n\n // --- read-only rendering helpers (migrated from the superseded letter-preview) ---\n\n private defs = computed(() => new Map(this.brief().placeholders.map((p) => [p.key, p])));\n private worst = computed(() => {\n const m = new Map();\n for (const d of this.diagnostics()) {\n if (!d.placeholderKey) continue;\n if (d.severity === 'error') m.set(d.placeholderKey, 'error');\n else if (!m.has(d.placeholderKey)) m.set(d.placeholderKey, 'warning');\n }\n return m;\n });\n\n protected segmentsOf = (block: LetterBlock) => groupParagraphs(block.content.paragraphs);\n protected labelFor = (key: string) => this.defs().get(key)?.label ?? key;\n protected autoFor = (key: string) => this.defs().get(key)?.autoResolvable ?? false;\n protected stateFor = (key: string): 'ok' | 'warning' | 'error' => this.worst().get(key) ?? 'ok';\n protected sampleFor = (key: string) =>\n SAMPLE_VALUES[key] ?? (key === 'datum' ? this.letterDate : this.labelFor(key));\n\n // --- approximate page-break marks (PRD §2b: honest \"±\", print preview is leading) ---\n\n private page = viewChild>('page');\n protected pageBreaks = signal([]);\n\n constructor() {\n // ponytail: whole-surface height / A4-interval — ignores that a break never truly\n // falls mid-line; the caption says \"±\" and WP-25's server preview is authoritative.\n const observer = new ResizeObserver(([entry]) => {\n // ~1cm tolerance so a letter ending on a page boundary gets no edge-hugging mark.\n const pages = Math.ceil((entry.target.scrollHeight - 40) / A4_HEIGHT_PX);\n this.pageBreaks.set(\n Array.from({ length: Math.max(0, pages - 1) }, (_, i) => (i + 1) * A4_HEIGHT_PX),\n );\n });\n effect((onCleanup) => {\n const el = this.page()?.nativeElement;\n if (!el) return;\n observer.observe(el);\n onCleanup(() => observer.unobserve(el));\n });\n inject(DestroyRef).onDestroy(() => observer.disconnect());\n }\n}\n", + "sourceCode": "import {\n Component,\n DestroyRef,\n ElementRef,\n computed,\n effect,\n inject,\n input,\n output,\n signal,\n viewChild,\n} from '@angular/core';\nimport { NgTemplateOutlet } from '@angular/common';\nimport { ButtonComponent } from '@shared/ui/button/button.component';\nimport { PlaceholderChipComponent } from '@shared/ui/placeholder-chip/placeholder-chip.component';\nimport { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component';\nimport { formatDatumNl } from '@shared/kernel/datum';\nimport { Paragraph } from '@shared/kernel/rich-text';\nimport { Brief, LetterBlock, LibraryPassage } from '@brief/domain/brief';\nimport { OrgTemplate } from '@brief/domain/org-template';\nimport { OrgTemplateTextField } from '@brief/domain/org-template.machine';\nimport { Diagnostic } from '@brief/domain/placeholders';\nimport { BriefMsg } from '@brief/domain/brief.machine';\nimport { LetterSectionComponent } from '@brief/ui/letter-section/letter-section.component';\n\n/** A run of consecutive lines to render together: a list (bullet/number) or a single plain line. */\ntype PreviewSegment = {\n readonly list: 'bullet' | 'number' | null;\n readonly items: readonly Paragraph[];\n};\n\nfunction groupParagraphs(paras: readonly Paragraph[]): PreviewSegment[] {\n const out: { list: 'bullet' | 'number' | null; items: Paragraph[] }[] = [];\n for (const para of paras) {\n const kind = para.list ?? null;\n const last = out[out.length - 1];\n if (kind && last && last.list === kind) last.items.push(para);\n else out.push({ list: kind, items: [para] });\n }\n return out;\n}\n\n// Illustrative values for the \"Voorbeeld\" toggle — what send resolves server-side.\nconst SAMPLE_VALUES: Record = {\n naam_zorgverlener: 'J. Jansen',\n big_nummer: '12345678901',\n};\n\n/** A4 height in CSS px (1in = 96px = 25.4mm) — for the approximate page-break marks. */\nconst A4_HEIGHT_PX = (297 * 96) / 25.4;\n\n/** Organism: the letter as one surface — the org template's letterhead, signature and\n footer around the case-type template's sections. `editableRegions` picks who edits\n what: `'content'` hosts the editable letter-sections in place (drafter), `'none'`\n renders everything read-only (approver/locked, absorbs the old letter-preview),\n `'template'` reserves the org-identity regions for the admin editor (WP-26).\n Letter typography/geometry come from the shared `public/letter.css` contract —\n the same file the backend preview renderer inlines (WP-25). */\n@Component({\n selector: 'app-letter-canvas',\n imports: [NgTemplateOutlet, ButtonComponent, PlaceholderChipComponent, LetterSectionComponent],\n styles: [\n `\n :host {\n display: block;\n }\n .toolbar {\n display: flex;\n justify-content: flex-end;\n margin-block-end: var(--rhc-space-max-sm);\n }\n /* Portal-side chrome around the letter surface (not part of the contract file). */\n .surface {\n background: var(--rhc-color-grijs-100);\n border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);\n border-radius: var(--rhc-border-radius-md);\n padding: var(--rhc-space-max-lg);\n overflow-x: auto;\n }\n .surface .letter {\n box-shadow: 0 1px 4px rgb(0 0 0 / 0.15); /* token-ok: paper drop-shadow, not a palette colour */\n }\n /* Org-identity regions: visibly not the drafter's to edit. */\n .from-template {\n background: var(--rhc-color-grijs-100);\n outline: 2mm solid var(--rhc-color-grijs-100);\n }\n .from-template-caption {\n font-size: 7.5pt;\n /* grijs-700, not foreground-subtle: subtle misses AA contrast on the tint. */\n color: var(--rhc-color-grijs-700);\n margin: 0 0 2mm;\n }\n /* Admin edit-in-place (editableRegions='template'): the org-identity fields\n become controls styled to sit in the letter, with a visible editable affordance. */\n .tmpl-input,\n .tmpl-textarea {\n font: inherit;\n color: inherit;\n width: 100%;\n box-sizing: border-box;\n background: var(--rhc-color-geel-100);\n border: var(--rhc-border-width-sm) dashed var(--rhc-color-border-strong);\n border-radius: var(--rhc-border-radius-sm);\n padding: 0.5mm 1mm;\n }\n .tmpl-textarea {\n resize: vertical;\n }\n .org-logo {\n max-height: 20mm;\n max-width: 60mm;\n margin-block-end: 3mm;\n }\n `,\n ],\n template: `\n \n @for (node of nodes; track $index) {\n @switch (node.type) {\n @case ('text') {\n {{ node.text }}\n }\n @case ('lineBreak') {\n
\n }\n @case ('placeholder') {\n @if (showSample() && autoFor(node.key)) {\n {{ sampleFor(node.key) }}\n } @else {\n \n }\n }\n }\n }\n
\n\n @if (editableRegions() === 'none') {\n
\n \n {{ showSample() ? hideSampleLabel() : showSampleLabel() }}\n \n
\n }\n\n
\n
\n \n
\n @if (tintTemplate()) {\n

{{ fromTemplateCaption() }}

\n }\n @if (logoUrl()) {\n \n }\n @if (editing()) {\n \n \n } @else {\n

{{ orgTemplate().orgName }}

\n
{{ orgTemplate().returnAddress }}
\n }\n
{{ recipientText() }}
\n
\n
\n
{{ referenceLabel() }}
\n
{{ brief().briefId }}
\n
\n
\n
{{ dateLabel() }}
\n
{{ letterDate }}
\n
\n
\n
\n\n
\n @if (editableRegions() === 'content') {\n @for (section of brief().sections; track section.sectionKey) {\n
\n \n
\n }\n } @else {\n @for (section of brief().sections; track section.sectionKey) {\n
\n

{{ section.title }}

\n @for (block of section.blocks; track block.blockId) {\n @for (seg of segmentsOf(block); track $index) {\n @if (seg.list === 'bullet') {\n
    \n @for (para of seg.items; track $index) {\n
  • \n \n
  • \n }\n
\n } @else if (seg.list === 'number') {\n
    \n @for (para of seg.items; track $index) {\n
  1. \n \n
  2. \n }\n
\n } @else {\n

\n \n

\n }\n }\n }\n
\n }\n }\n
\n\n
\n @if (editing()) {\n \n \n \n } @else {\n

{{ orgTemplate().signatureClosing }}

\n

{{ orgTemplate().signatureName }}

\n

{{ orgTemplate().signatureRole }}

\n }\n
\n\n
\n @if (editing()) {\n \n \n } @else {\n
{{ orgTemplate().footerContact }}
\n
{{ orgTemplate().footerLegal }}
\n }\n
\n\n @for (top of pageBreaks(); track $index) {\n
\n {{ pageBreakCaption() }}\n
\n }\n
\n
\n `,\n})\nexport class LetterCanvasComponent {\n brief = input.required();\n orgTemplate = input.required();\n /** Who edits what on the surface: drafter ('content'), read-only ('none'),\n admin editor ('template', consumer arrives in WP-26). */\n editableRegions = input<'content' | 'template' | 'none'>('none');\n availablePassages = input([]);\n placeholders = input([]);\n diagnostics = input([]);\n zoom = input(1);\n /** The org logo's content URL (letterhead), or null when none is set. */\n logoUrl = input(null);\n edit = output();\n /** An in-place edit to an org-identity field (only in `editableRegions='template'`). */\n templateEdit = output<{ field: OrgTemplateTextField; value: string }>();\n\n showSampleLabel = input($localize`:@@brief.preview.showSample:Voorbeeld met testwaarden`);\n hideSampleLabel = input($localize`:@@brief.preview.hideSample:Testwaarden verbergen`);\n fromTemplateCaption = input(\n $localize`:@@brief.canvas.fromTemplate:Komt uit de huisstijl van de organisatie — niet bewerkbaar in de brief.`,\n );\n pageBreakCaption = input(\n $localize`:@@brief.canvas.pageBreak:±pagina-einde — afdrukvoorbeeld is leidend`,\n );\n recipientText = input(\n $localize`:@@brief.canvas.recipient:Adres van de geadresseerde\\n(wordt ingevuld bij verzending)`,\n );\n referenceLabel = input($localize`:@@brief.canvas.reference:Ons kenmerk`);\n dateLabel = input($localize`:@@brief.canvas.date:Datum`);\n logoAlt = input($localize`:@@brief.canvas.logoAlt:Logo van de organisatie`);\n orgNameLabel = input($localize`:@@brief.canvas.orgName:Organisatienaam`);\n returnAddressLabel = input($localize`:@@brief.canvas.returnAddress:Retouradres`);\n signatureClosingLabel = input($localize`:@@brief.canvas.signatureClosing:Afsluiting`);\n signatureNameLabel = input($localize`:@@brief.canvas.signatureName:Naam ondertekenaar`);\n signatureRoleLabel = input($localize`:@@brief.canvas.signatureRole:Functie ondertekenaar`);\n footerContactLabel = input($localize`:@@brief.canvas.footerContact:Contactgegevens (voettekst)`);\n footerLegalLabel = input($localize`:@@brief.canvas.footerLegal:Juridische voettekst`);\n\n protected showSample = signal(false);\n protected letterDate = formatDatumNl(new Date());\n\n /** The letterhead/signature/footer are tinted \"not yours\" only while composing —\n in 'none' the whole surface is read-only, in 'template' they ARE the editable focus. */\n protected tintTemplate = computed(() => this.editableRegions() === 'content');\n /** Admin edit-in-place: the org-identity regions render as controls. */\n protected editing = computed(() => this.editableRegions() === 'template');\n\n protected emitEdit(field: OrgTemplateTextField, event: Event) {\n this.templateEdit.emit({ field, value: (event.target as HTMLInputElement | HTMLTextAreaElement).value });\n }\n\n protected marginStyle = computed(() => {\n const m = this.orgTemplate().margins;\n return {\n '--letter-margin-top': `${m.topMm}mm`,\n '--letter-margin-right': `${m.rightMm}mm`,\n '--letter-margin-bottom': `${m.bottomMm}mm`,\n '--letter-margin-left': `${m.leftMm}mm`,\n };\n });\n\n // --- read-only rendering helpers (migrated from the superseded letter-preview) ---\n\n private defs = computed(() => new Map(this.brief().placeholders.map((p) => [p.key, p])));\n private worst = computed(() => {\n const m = new Map();\n for (const d of this.diagnostics()) {\n if (!d.placeholderKey) continue;\n if (d.severity === 'error') m.set(d.placeholderKey, 'error');\n else if (!m.has(d.placeholderKey)) m.set(d.placeholderKey, 'warning');\n }\n return m;\n });\n\n protected segmentsOf = (block: LetterBlock) => groupParagraphs(block.content.paragraphs);\n protected labelFor = (key: string) => this.defs().get(key)?.label ?? key;\n protected autoFor = (key: string) => this.defs().get(key)?.autoResolvable ?? false;\n protected stateFor = (key: string): 'ok' | 'warning' | 'error' => this.worst().get(key) ?? 'ok';\n protected sampleFor = (key: string) =>\n SAMPLE_VALUES[key] ?? (key === 'datum' ? this.letterDate : this.labelFor(key));\n\n // --- approximate page-break marks (PRD §2b: honest \"±\", print preview is leading) ---\n\n private page = viewChild>('page');\n protected pageBreaks = signal([]);\n\n constructor() {\n // ponytail: whole-surface height / A4-interval — ignores that a break never truly\n // falls mid-line; the caption says \"±\" and WP-25's server preview is authoritative.\n const observer = new ResizeObserver(([entry]) => {\n // ~1cm tolerance so a letter ending on a page boundary gets no edge-hugging mark.\n const pages = Math.ceil((entry.target.scrollHeight - 40) / A4_HEIGHT_PX);\n this.pageBreaks.set(\n Array.from({ length: Math.max(0, pages - 1) }, (_, i) => (i + 1) * A4_HEIGHT_PX),\n );\n });\n effect((onCleanup) => {\n const el = this.page()?.nativeElement;\n if (!el) return;\n observer.observe(el);\n onCleanup(() => observer.unobserve(el));\n });\n inject(DestroyRef).onDestroy(() => observer.disconnect());\n }\n}\n", "assetsDirs": [], "styleUrlsData": "", - "stylesData": "\n :host {\n display: block;\n }\n .toolbar {\n display: flex;\n justify-content: flex-end;\n margin-block-end: var(--rhc-space-max-sm);\n }\n /* Portal-side chrome around the letter surface (not part of the contract file). */\n .surface {\n background: var(--rhc-color-grijs-100, #f0f0f0);\n border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);\n border-radius: var(--rhc-border-radius-md);\n padding: var(--rhc-space-max-lg);\n overflow-x: auto;\n }\n .surface .letter {\n box-shadow: 0 1px 4px rgb(0 0 0 / 0.15);\n }\n /* Org-identity regions: visibly not the drafter's to edit. */\n .from-template {\n background: var(--rhc-color-grijs-100, #f5f5f5);\n outline: 2mm solid var(--rhc-color-grijs-100, #f5f5f5);\n }\n .from-template-caption {\n font-size: 7.5pt;\n /* grijs-700, not foreground-subtle: subtle misses AA contrast on the tint. */\n color: var(--rhc-color-grijs-700, #334155);\n margin: 0 0 2mm;\n }\n \n", + "stylesData": "\n :host {\n display: block;\n }\n .toolbar {\n display: flex;\n justify-content: flex-end;\n margin-block-end: var(--rhc-space-max-sm);\n }\n /* Portal-side chrome around the letter surface (not part of the contract file). */\n .surface {\n background: var(--rhc-color-grijs-100);\n border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);\n border-radius: var(--rhc-border-radius-md);\n padding: var(--rhc-space-max-lg);\n overflow-x: auto;\n }\n .surface .letter {\n box-shadow: 0 1px 4px rgb(0 0 0 / 0.15); /* token-ok: paper drop-shadow, not a palette colour */\n }\n /* Org-identity regions: visibly not the drafter's to edit. */\n .from-template {\n background: var(--rhc-color-grijs-100);\n outline: 2mm solid var(--rhc-color-grijs-100);\n }\n .from-template-caption {\n font-size: 7.5pt;\n /* grijs-700, not foreground-subtle: subtle misses AA contrast on the tint. */\n color: var(--rhc-color-grijs-700);\n margin: 0 0 2mm;\n }\n /* Admin edit-in-place (editableRegions='template'): the org-identity fields\n become controls styled to sit in the letter, with a visible editable affordance. */\n .tmpl-input,\n .tmpl-textarea {\n font: inherit;\n color: inherit;\n width: 100%;\n box-sizing: border-box;\n background: var(--rhc-color-geel-100);\n border: var(--rhc-border-width-sm) dashed var(--rhc-color-border-strong);\n border-radius: var(--rhc-border-radius-sm);\n padding: 0.5mm 1mm;\n }\n .tmpl-textarea {\n resize: vertical;\n }\n .org-logo {\n max-height: 20mm;\n max-width: 60mm;\n margin-block-end: 3mm;\n }\n \n", "constructorObj": { "name": "constructor", "description": "", "deprecated": false, "deprecationMessage": "", "args": [], - "line": 297 + "line": 393 }, "extends": [] }, { "name": "LetterComposerComponent", - "id": "component-LetterComposerComponent-77edd5fe085467d0e3885c0fc211e474792ed97568ba59ca9bc88e4c35b4f10b52b54a4e6ea973db3d12630ab4ed00129f1cf1c552677a18b83d56a0e2dbfef8", + "id": "component-LetterComposerComponent-1abe8a9e22b5bd5bc0f45b555ac2aef78219c7cf64f79e63caa35e0542273f817779c6fb69a3c178219f4fd4559cc918ff742953c92fc329b2c5b125b0562dda", "file": "src/app/brief/ui/letter-composer/letter-composer.component.ts", "encapsulation": [], "entryComponents": [], @@ -18597,9 +20069,9 @@ "selector": "app-letter-composer", "styleUrls": [], "styles": [ - "\n :host {\n display: block;\n }\n .head {\n display: flex;\n justify-content: space-between;\n align-items: center;\n gap: var(--rhc-space-max-md);\n flex-wrap: wrap;\n margin-block-end: var(--rhc-space-max-lg);\n }\n .panel {\n margin-block: var(--rhc-space-max-xl);\n }\n .bar {\n display: flex;\n flex-wrap: wrap;\n gap: var(--rhc-space-max-md);\n align-items: center;\n margin-block-start: var(--rhc-space-max-xl);\n }\n " + "\n :host {\n display: block;\n }\n .head {\n display: flex;\n justify-content: space-between;\n align-items: center;\n gap: var(--rhc-space-max-md);\n flex-wrap: wrap;\n margin-block-end: var(--rhc-space-max-lg);\n }\n .head-end {\n display: flex;\n align-items: center;\n gap: var(--rhc-space-max-md);\n }\n .panel {\n margin-block: var(--rhc-space-max-xl);\n }\n .bar {\n display: flex;\n flex-wrap: wrap;\n gap: var(--rhc-space-max-md);\n align-items: center;\n margin-block-start: var(--rhc-space-max-xl);\n }\n " ], - "template": "
\n {{ title() }}\n \n
\n\n@if (status() === 'rejected') {\n \n}\n\n\n\n
\n \n
\n\n
\n @switch (status()) {\n @case ('draft') {\n @if (canEdit()) {\n {{ submitLabel() }}\n @if (!canSubmit()) {\n {{ submitHint() }}\n }\n }\n }\n @case ('rejected') {\n @if (canEdit()) {\n {{ resubmitLabel() }}\n }\n }\n @case ('submitted') {\n @if (canApprove() || canReject()) {\n {{\n approveLabel()\n }}\n \n } @else {\n {{ awaitingText() }}\n }\n }\n @case ('approved') {\n @if (canSend()) {\n {{\n sendLabel()\n }}\n }\n }\n @case ('sent') {\n {{ sentText() }}\n }\n }\n
\n", + "template": "
\n {{ title() }}\n
\n {{\n previewLabel()\n }}\n \n
\n
\n\n@if (status() === 'rejected') {\n \n}\n\n\n\n
\n \n
\n\n
\n @switch (status()) {\n @case ('draft') {\n @if (canEdit()) {\n {{ submitLabel() }}\n @if (!canSubmit()) {\n {{ submitHint() }}\n }\n }\n }\n @case ('rejected') {\n @if (canEdit()) {\n {{ resubmitLabel() }}\n }\n }\n @case ('submitted') {\n @if (canApprove() || canReject()) {\n {{\n approveLabel()\n }}\n \n } @else {\n {{ awaitingText() }}\n }\n }\n @case ('approved') {\n @if (canSend()) {\n {{\n sendLabel()\n }}\n }\n }\n @case ('sent') {\n {{ sentText() }}\n }\n }\n
\n", "templateUrl": [], "viewProviders": [], "hostDirectives": [], @@ -18612,7 +20084,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 154, + "line": 168, "required": false }, { @@ -18624,7 +20096,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 132, + "line": 144, "required": false }, { @@ -18635,7 +20107,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 156, + "line": 170, "required": false }, { @@ -18646,7 +20118,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 130, + "line": 141, "required": true }, { @@ -18657,7 +20129,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 139, + "line": 151, "required": false }, { @@ -18668,7 +20140,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 135, + "line": 147, "required": false }, { @@ -18679,7 +20151,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 134, + "line": 146, "required": false }, { @@ -18690,7 +20162,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 136, + "line": 148, "required": false }, { @@ -18701,7 +20173,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 137, + "line": 149, "required": false }, { @@ -18712,7 +20184,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 138, + "line": 150, "required": false }, { @@ -18724,7 +20196,19 @@ "indexKey": "", "optional": false, "description": "", - "line": 133, + "line": 145, + "required": false + }, + { + "name": "logoUrl", + "defaultValue": "null", + "deprecated": false, + "deprecationMessage": "", + "type": "string | null", + "indexKey": "", + "optional": false, + "description": "", + "line": 143, "required": false }, { @@ -18735,9 +20219,20 @@ "indexKey": "", "optional": false, "description": "", - "line": 131, + "line": 142, "required": true }, + { + "name": "previewLabel", + "defaultValue": "$localize`:@@brief.preview.open:Voorbeeld`", + "deprecated": false, + "deprecationMessage": "", + "indexKey": "", + "optional": false, + "description": "", + "line": 162, + "required": false + }, { "name": "resubmitLabel", "defaultValue": "$localize`:@@brief.resubmit:Opnieuw indienen`", @@ -18746,7 +20241,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 150, + "line": 164, "required": false }, { @@ -18757,7 +20252,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 155, + "line": 169, "required": false }, { @@ -18768,7 +20263,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 159, + "line": 173, "required": false }, { @@ -18779,7 +20274,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 151, + "line": 165, "required": false }, { @@ -18790,7 +20285,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 149, + "line": 163, "required": false }, { @@ -18801,7 +20296,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 148, + "line": 161, "required": false } ], @@ -18814,7 +20309,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 143, + "line": 155, "required": false }, { @@ -18825,7 +20320,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 141, + "line": 153, "required": false }, { @@ -18836,7 +20331,18 @@ "indexKey": "", "optional": false, "description": "", - "line": 146, + "line": 159, + "required": false + }, + { + "name": "preview", + "deprecated": false, + "deprecationMessage": "", + "type": "void", + "indexKey": "", + "optional": false, + "description": "", + "line": 158, "required": false }, { @@ -18847,7 +20353,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 144, + "line": 156, "required": false }, { @@ -18858,7 +20364,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 145, + "line": 157, "required": false }, { @@ -18869,7 +20375,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 142, + "line": 154, "required": false } ], @@ -18883,7 +20389,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 169, + "line": 183, "modifierKind": [ 124 ] @@ -18897,7 +20403,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 162, + "line": 176, "modifierKind": [ 124 ] @@ -18911,7 +20417,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 161, + "line": 175, "modifierKind": [ 124 ] @@ -18925,7 +20431,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 190, + "line": 204, "modifierKind": [ 124 ] @@ -18939,7 +20445,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 175, + "line": 189, "modifierKind": [ 124 ] @@ -18984,10 +20490,10 @@ "description": "

Organism: the whole letter flow — status badge, the letter canvas (editable in\nplace for the drafter, read-only for approver/locked), the diagnostics panel, and\nthe action bar appropriate to status × permission. Presentational: emits edit +\ntransition intents; the canEdit/canApprove/canReject/canSend inputs are\nserver-computed decision flags (PRD-0002 phase P1) — this component never derives them.

\n", "rawdescription": "\nOrganism: the whole letter flow — status badge, the letter canvas (editable in\nplace for the drafter, read-only for approver/locked), the diagnostics panel, and\nthe action bar appropriate to status × permission. Presentational: emits edit +\ntransition intents; the canEdit/canApprove/canReject/canSend inputs are\nserver-computed decision flags (PRD-0002 phase P1) — this component never derives them.", "type": "component", - "sourceCode": "import { Component, computed, input, output } from '@angular/core';\nimport { HeadingComponent } from '@shared/ui/heading/heading.component';\nimport { StatusBadgeComponent } from '@shared/ui/status-badge/status-badge.component';\nimport { ButtonComponent } from '@shared/ui/button/button.component';\nimport { AlertComponent } from '@shared/ui/alert/alert.component';\nimport { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component';\nimport { Brief, LibraryPassage } from '@brief/domain/brief';\nimport { OrgTemplate } from '@brief/domain/org-template';\nimport { Diagnostic } from '@brief/domain/placeholders';\nimport { BriefMsg } from '@brief/domain/brief.machine';\nimport { LetterCanvasComponent } from '@brief/ui/letter-canvas/letter-canvas.component';\nimport { DiagnosticsPanelComponent } from '@brief/ui/diagnostics-panel/diagnostics-panel.component';\nimport { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejection-comments.component';\n\n/** Organism: the whole letter flow — status badge, the letter canvas (editable in\n place for the drafter, read-only for approver/locked), the diagnostics panel, and\n the action bar appropriate to status × permission. Presentational: emits edit +\n transition intents; the canEdit/canApprove/canReject/canSend inputs are\n server-computed decision flags (PRD-0002 phase P1) — this component never derives them. */\n@Component({\n selector: 'app-letter-composer',\n imports: [\n HeadingComponent,\n StatusBadgeComponent,\n ButtonComponent,\n AlertComponent,\n LetterCanvasComponent,\n DiagnosticsPanelComponent,\n RejectionCommentsComponent,\n ],\n styles: [\n `\n :host {\n display: block;\n }\n .head {\n display: flex;\n justify-content: space-between;\n align-items: center;\n gap: var(--rhc-space-max-md);\n flex-wrap: wrap;\n margin-block-end: var(--rhc-space-max-lg);\n }\n .panel {\n margin-block: var(--rhc-space-max-xl);\n }\n .bar {\n display: flex;\n flex-wrap: wrap;\n gap: var(--rhc-space-max-md);\n align-items: center;\n margin-block-start: var(--rhc-space-max-xl);\n }\n `,\n ],\n template: `\n
\n {{ title() }}\n \n
\n\n @if (status() === 'rejected') {\n \n }\n\n \n\n
\n \n
\n\n
\n @switch (status()) {\n @case ('draft') {\n @if (canEdit()) {\n {{ submitLabel() }}\n @if (!canSubmit()) {\n {{ submitHint() }}\n }\n }\n }\n @case ('rejected') {\n @if (canEdit()) {\n {{ resubmitLabel() }}\n }\n }\n @case ('submitted') {\n @if (canApprove() || canReject()) {\n {{\n approveLabel()\n }}\n \n } @else {\n {{ awaitingText() }}\n }\n }\n @case ('approved') {\n @if (canSend()) {\n {{\n sendLabel()\n }}\n }\n }\n @case ('sent') {\n {{ sentText() }}\n }\n }\n
\n `,\n})\nexport class LetterComposerComponent {\n brief = input.required();\n orgTemplate = input.required();\n availablePassages = input([]);\n diagnostics = input([]);\n canEdit = input(false);\n canApprove = input(false);\n canReject = input(false);\n canSend = input(false);\n canSubmit = input(false);\n busy = input(false);\n\n edit = output();\n submit = output();\n approve = output();\n reject = output();\n send = output();\n locate = output();\n\n title = input($localize`:@@brief.title:Brief aan de zorgverlener`);\n submitLabel = input($localize`:@@brief.submit:Indienen ter beoordeling`);\n resubmitLabel = input($localize`:@@brief.resubmit:Opnieuw indienen`);\n submitHint = input(\n $localize`:@@brief.submitHint:Vul eerst alle verplichte secties en los fouten op.`,\n );\n approveLabel = input($localize`:@@brief.approve:Goedkeuren`);\n sendLabel = input($localize`:@@brief.send:Versturen`);\n awaitingText = input(\n $localize`:@@brief.awaiting:De brief wacht op beoordeling door een collega.`,\n );\n sentText = input($localize`:@@brief.sent:De brief is verzonden.`);\n\n protected status = computed(() => this.brief().status.tag);\n protected rejectComments = computed(() => {\n const s = this.brief().status;\n return s.tag === 'rejected' ? s.comments : '';\n });\n\n // The insert menu offers only valid, fillable, non-deprecated fields — inserting an\n // unknown/retired key is structurally impossible.\n protected menu = computed(() =>\n this.brief()\n .placeholders.filter((p) => p.fillable !== false && !p.deprecated)\n .map((p) => ({ key: p.key, label: p.label, autoResolvable: p.autoResolvable })),\n );\n\n protected statusLabel = computed(() => {\n switch (this.status()) {\n case 'draft':\n return $localize`:@@brief.status.draft:Concept`;\n case 'submitted':\n return $localize`:@@brief.status.submitted:Ter beoordeling`;\n case 'approved':\n return $localize`:@@brief.status.approved:Goedgekeurd`;\n case 'rejected':\n return $localize`:@@brief.status.rejected:Afgewezen`;\n case 'sent':\n return $localize`:@@brief.status.sent:Verzonden`;\n }\n });\n\n protected statusColor = computed(() => {\n switch (this.status()) {\n case 'draft':\n return 'var(--rhc-color-border-strong)';\n case 'submitted':\n return 'var(--rhc-color-oranje-500)';\n case 'approved':\n case 'sent':\n return 'var(--rhc-color-groen-500)';\n case 'rejected':\n return 'var(--rhc-color-rood-500)';\n }\n });\n}\n", + "sourceCode": "import { Component, computed, input, output } from '@angular/core';\nimport { HeadingComponent } from '@shared/ui/heading/heading.component';\nimport { StatusBadgeComponent } from '@shared/ui/status-badge/status-badge.component';\nimport { ButtonComponent } from '@shared/ui/button/button.component';\nimport { AlertComponent } from '@shared/ui/alert/alert.component';\nimport { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component';\nimport { Brief, LibraryPassage } from '@brief/domain/brief';\nimport { OrgTemplate } from '@brief/domain/org-template';\nimport { Diagnostic } from '@brief/domain/placeholders';\nimport { BriefMsg } from '@brief/domain/brief.machine';\nimport { LetterCanvasComponent } from '@brief/ui/letter-canvas/letter-canvas.component';\nimport { DiagnosticsPanelComponent } from '@brief/ui/diagnostics-panel/diagnostics-panel.component';\nimport { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejection-comments.component';\n\n/** Organism: the whole letter flow — status badge, the letter canvas (editable in\n place for the drafter, read-only for approver/locked), the diagnostics panel, and\n the action bar appropriate to status × permission. Presentational: emits edit +\n transition intents; the canEdit/canApprove/canReject/canSend inputs are\n server-computed decision flags (PRD-0002 phase P1) — this component never derives them. */\n@Component({\n selector: 'app-letter-composer',\n imports: [\n HeadingComponent,\n StatusBadgeComponent,\n ButtonComponent,\n AlertComponent,\n LetterCanvasComponent,\n DiagnosticsPanelComponent,\n RejectionCommentsComponent,\n ],\n styles: [\n `\n :host {\n display: block;\n }\n .head {\n display: flex;\n justify-content: space-between;\n align-items: center;\n gap: var(--rhc-space-max-md);\n flex-wrap: wrap;\n margin-block-end: var(--rhc-space-max-lg);\n }\n .head-end {\n display: flex;\n align-items: center;\n gap: var(--rhc-space-max-md);\n }\n .panel {\n margin-block: var(--rhc-space-max-xl);\n }\n .bar {\n display: flex;\n flex-wrap: wrap;\n gap: var(--rhc-space-max-md);\n align-items: center;\n margin-block-start: var(--rhc-space-max-xl);\n }\n `,\n ],\n template: `\n
\n {{ title() }}\n
\n {{\n previewLabel()\n }}\n \n
\n
\n\n @if (status() === 'rejected') {\n \n }\n\n \n\n
\n \n
\n\n
\n @switch (status()) {\n @case ('draft') {\n @if (canEdit()) {\n {{ submitLabel() }}\n @if (!canSubmit()) {\n {{ submitHint() }}\n }\n }\n }\n @case ('rejected') {\n @if (canEdit()) {\n {{ resubmitLabel() }}\n }\n }\n @case ('submitted') {\n @if (canApprove() || canReject()) {\n {{\n approveLabel()\n }}\n \n } @else {\n {{ awaitingText() }}\n }\n }\n @case ('approved') {\n @if (canSend()) {\n {{\n sendLabel()\n }}\n }\n }\n @case ('sent') {\n {{ sentText() }}\n }\n }\n
\n `,\n})\nexport class LetterComposerComponent {\n brief = input.required();\n orgTemplate = input.required();\n logoUrl = input(null);\n availablePassages = input([]);\n diagnostics = input([]);\n canEdit = input(false);\n canApprove = input(false);\n canReject = input(false);\n canSend = input(false);\n canSubmit = input(false);\n busy = input(false);\n\n edit = output();\n submit = output();\n approve = output();\n reject = output();\n send = output();\n preview = output();\n locate = output();\n\n title = input($localize`:@@brief.title:Brief aan de zorgverlener`);\n previewLabel = input($localize`:@@brief.preview.open:Voorbeeld`);\n submitLabel = input($localize`:@@brief.submit:Indienen ter beoordeling`);\n resubmitLabel = input($localize`:@@brief.resubmit:Opnieuw indienen`);\n submitHint = input(\n $localize`:@@brief.submitHint:Vul eerst alle verplichte secties en los fouten op.`,\n );\n approveLabel = input($localize`:@@brief.approve:Goedkeuren`);\n sendLabel = input($localize`:@@brief.send:Versturen`);\n awaitingText = input(\n $localize`:@@brief.awaiting:De brief wacht op beoordeling door een collega.`,\n );\n sentText = input($localize`:@@brief.sent:De brief is verzonden.`);\n\n protected status = computed(() => this.brief().status.tag);\n protected rejectComments = computed(() => {\n const s = this.brief().status;\n return s.tag === 'rejected' ? s.comments : '';\n });\n\n // The insert menu offers only valid, fillable, non-deprecated fields — inserting an\n // unknown/retired key is structurally impossible.\n protected menu = computed(() =>\n this.brief()\n .placeholders.filter((p) => p.fillable !== false && !p.deprecated)\n .map((p) => ({ key: p.key, label: p.label, autoResolvable: p.autoResolvable })),\n );\n\n protected statusLabel = computed(() => {\n switch (this.status()) {\n case 'draft':\n return $localize`:@@brief.status.draft:Concept`;\n case 'submitted':\n return $localize`:@@brief.status.submitted:Ter beoordeling`;\n case 'approved':\n return $localize`:@@brief.status.approved:Goedgekeurd`;\n case 'rejected':\n return $localize`:@@brief.status.rejected:Afgewezen`;\n case 'sent':\n return $localize`:@@brief.status.sent:Verzonden`;\n }\n });\n\n protected statusColor = computed(() => {\n switch (this.status()) {\n case 'draft':\n return 'var(--rhc-color-border-strong)';\n case 'submitted':\n return 'var(--rhc-color-oranje-500)';\n case 'approved':\n case 'sent':\n return 'var(--rhc-color-groen-500)';\n case 'rejected':\n return 'var(--rhc-color-rood-500)';\n }\n });\n}\n", "assetsDirs": [], "styleUrlsData": "", - "stylesData": "\n :host {\n display: block;\n }\n .head {\n display: flex;\n justify-content: space-between;\n align-items: center;\n gap: var(--rhc-space-max-md);\n flex-wrap: wrap;\n margin-block-end: var(--rhc-space-max-lg);\n }\n .panel {\n margin-block: var(--rhc-space-max-xl);\n }\n .bar {\n display: flex;\n flex-wrap: wrap;\n gap: var(--rhc-space-max-md);\n align-items: center;\n margin-block-start: var(--rhc-space-max-xl);\n }\n \n", + "stylesData": "\n :host {\n display: block;\n }\n .head {\n display: flex;\n justify-content: space-between;\n align-items: center;\n gap: var(--rhc-space-max-md);\n flex-wrap: wrap;\n margin-block-end: var(--rhc-space-max-lg);\n }\n .head-end {\n display: flex;\n align-items: center;\n gap: var(--rhc-space-max-md);\n }\n .panel {\n margin-block: var(--rhc-space-max-xl);\n }\n .bar {\n display: flex;\n flex-wrap: wrap;\n gap: var(--rhc-space-max-md);\n align-items: center;\n margin-block-start: var(--rhc-space-max-xl);\n }\n \n", "extends": [] }, { @@ -19582,6 +21088,1039 @@ "stylesData": "", "extends": [] }, + { + "name": "OrgTemplateEditorComponent", + "id": "component-OrgTemplateEditorComponent-c423dbb352723fa0a2f181cd90c63d42d6924426a8775720579afbc6c5a07cb65e64085d62001592b053e7b2b5592de2736a2a3396724a384eee1454e2c66498", + "file": "src/app/brief/ui/org-template-editor/org-template-editor.component.ts", + "encapsulation": [], + "entryComponents": [], + "inputs": [], + "outputs": [], + "providers": [], + "selector": "app-org-template-editor", + "styleUrls": [], + "styles": [ + "\n :host {\n display: block;\n }\n .toolbar {\n display: flex;\n justify-content: space-between;\n align-items: end;\n gap: var(--rhc-space-max-md);\n flex-wrap: wrap;\n margin-block-end: var(--rhc-space-max-lg);\n }\n .field {\n display: flex;\n flex-direction: column;\n gap: var(--rhc-space-max-2xs);\n }\n .save {\n color: var(--rhc-color-foreground-subtle);\n font-size: 0.9em;\n }\n .section {\n margin-block-start: var(--rhc-space-max-xl);\n }\n .margins {\n display: flex;\n gap: var(--rhc-space-max-md);\n flex-wrap: wrap;\n border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);\n border-radius: var(--rhc-border-radius-md);\n padding: var(--rhc-space-max-md);\n }\n .margins input {\n width: 6rem;\n }\n .history-list {\n list-style: none;\n margin: 0;\n padding: 0;\n display: flex;\n flex-direction: column;\n gap: var(--rhc-space-max-sm);\n }\n .history-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: var(--rhc-space-max-md);\n border-block-end: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);\n padding-block-end: var(--rhc-space-max-sm);\n }\n .bar {\n display: flex;\n flex-wrap: wrap;\n gap: var(--rhc-space-max-md);\n align-items: center;\n margin-block-start: var(--rhc-space-max-xl);\n }\n .published {\n color: var(--rhc-color-foreground-subtle);\n }\n " + ], + "template": "
\n \n {{ saveText() }}\n
\n\n\n\n
\n {{ marginsLegend() }}\n @for (edge of edges; track edge) {\n \n }\n
\n\n
\n {{ logoHeading() }}\n @if (logoCategory()) {\n \n }\n @if (logoRejection()) {\n {{ logoRejection() }}\n }\n @if (logoUploads().length) {\n
    \n @for (u of logoUploads(); track u.localId) {\n \n }\n
\n }\n
\n\n
\n {{ historyHeading() }}\n @if (history().length === 0) {\n

{{ noHistory() }}

\n } @else {\n
    \n @for (v of history(); track v.version) {\n
  • \n {{ versionLabel() }} {{ v.version }} · {{ v.publishedAt | date: 'longDate' }}\n \n {{ rollbackLabel() }}\n \n
  • \n }\n
\n }\n
\n\n
\n {{ publishedLabel() }} {{ publishedVersion() }}\n @if (pendingPublish()) {\n {{ impactText() }}\n \n {{ confirmLabel() }}\n \n \n {{ cancelLabel() }}\n \n } @else {\n \n {{ publishLabel() }}\n \n @if (!draftValid()) {\n {{ invalidHint() }}\n }\n }\n \n {{ proefbriefLabel() }}\n \n
\n", + "templateUrl": [], + "viewProviders": [], + "hostDirectives": [], + "inputsClass": [ + { + "name": "busy", + "defaultValue": "false", + "deprecated": false, + "deprecationMessage": "", + "indexKey": "", + "optional": false, + "description": "", + "line": 278, + "required": false + }, + { + "name": "cancelLabel", + "defaultValue": "$localize`:@@orgTemplate.publish.cancel:Annuleren`", + "deprecated": false, + "deprecationMessage": "", + "indexKey": "", + "optional": false, + "description": "", + "line": 343, + "modifierKind": [ + 124 + ], + "required": false + }, + { + "name": "confirmLabel", + "defaultValue": "$localize`:@@orgTemplate.publish.confirm:Bevestigen`", + "deprecated": false, + "deprecationMessage": "", + "indexKey": "", + "optional": false, + "description": "", + "line": 342, + "modifierKind": [ + 124 + ], + "required": false + }, + { + "name": "draft", + "deprecated": false, + "deprecationMessage": "", + "type": "OrgTemplate", + "indexKey": "", + "optional": false, + "description": "", + "line": 269, + "required": true + }, + { + "name": "draftValid", + "defaultValue": "false", + "deprecated": false, + "deprecationMessage": "", + "indexKey": "", + "optional": false, + "description": "", + "line": 277, + "required": false + }, + { + "name": "history", + "defaultValue": "[]", + "deprecated": false, + "deprecationMessage": "", + "type": "readonly OrgTemplateVersion[]", + "indexKey": "", + "optional": false, + "description": "", + "line": 274, + "required": false + }, + { + "name": "historyHeading", + "defaultValue": "$localize`:@@orgTemplate.history:Versiegeschiedenis`", + "deprecated": false, + "deprecationMessage": "", + "indexKey": "", + "optional": false, + "description": "", + "line": 336, + "modifierKind": [ + 124 + ], + "required": false + }, + { + "name": "invalidHint", + "defaultValue": " $localize`:@@orgTemplate.invalid:Vul organisatienaam en ondertekenaar in; marges tussen ${MARGIN_MIN_MM}:min: en ${MARGIN_MAX_MM}:max: mm.`, ", + "deprecated": false, + "deprecationMessage": "", + "indexKey": "", + "optional": false, + "description": "", + "line": 345, + "modifierKind": [ + 124 + ], + "required": false + }, + { + "name": "logoHeading", + "defaultValue": "$localize`:@@orgTemplate.logo:Logo`", + "deprecated": false, + "deprecationMessage": "", + "indexKey": "", + "optional": false, + "description": "", + "line": 335, + "modifierKind": [ + 124 + ], + "required": false + }, + { + "name": "logoUrl", + "defaultValue": "null", + "deprecated": false, + "deprecationMessage": "", + "type": "string | null", + "indexKey": "", + "optional": false, + "description": "", + "line": 270, + "required": false + }, + { + "name": "marginsLegend", + "defaultValue": "$localize`:@@orgTemplate.margins:Marges (mm, tussen ${MARGIN_MIN_MM}:min: en ${MARGIN_MAX_MM}:max:)`", + "deprecated": false, + "deprecationMessage": "", + "indexKey": "", + "optional": false, + "description": "", + "line": 334, + "modifierKind": [ + 124 + ], + "required": false + }, + { + "name": "noHistory", + "defaultValue": "$localize`:@@orgTemplate.history.none:Nog niets gepubliceerd.`", + "deprecated": false, + "deprecationMessage": "", + "indexKey": "", + "optional": false, + "description": "", + "line": 337, + "modifierKind": [ + 124 + ], + "required": false + }, + { + "name": "pendingPublish", + "defaultValue": "false", + "deprecated": false, + "deprecationMessage": "", + "indexKey": "", + "optional": false, + "description": "", + "line": 279, + "required": false + }, + { + "name": "previewUrlFor", + "deprecated": false, + "deprecationMessage": "", + "type": "(documentId: string) => string | undefined", + "indexKey": "", + "optional": false, + "description": "", + "line": 282, + "required": false + }, + { + "name": "proefbriefLabel", + "defaultValue": "$localize`:@@orgTemplate.proefbrief:Proefbrief`", + "deprecated": false, + "deprecationMessage": "", + "indexKey": "", + "optional": false, + "description": "", + "line": 344, + "modifierKind": [ + 124 + ], + "required": false + }, + { + "name": "publishedLabel", + "defaultValue": "$localize`:@@orgTemplate.published:Gepubliceerde versie:`", + "deprecated": false, + "deprecationMessage": "", + "indexKey": "", + "optional": false, + "description": "", + "line": 340, + "modifierKind": [ + 124 + ], + "required": false + }, + { + "name": "publishedVersion", + "defaultValue": "0", + "deprecated": false, + "deprecationMessage": "", + "indexKey": "", + "optional": false, + "description": "", + "line": 275, + "required": false + }, + { + "name": "publishLabel", + "defaultValue": "$localize`:@@orgTemplate.publish:Publiceren`", + "deprecated": false, + "deprecationMessage": "", + "indexKey": "", + "optional": false, + "description": "", + "line": 341, + "modifierKind": [ + 124 + ], + "required": false + }, + { + "name": "rollbackLabel", + "defaultValue": "$localize`:@@orgTemplate.rollback:Terugzetten in concept`", + "deprecated": false, + "deprecationMessage": "", + "indexKey": "", + "optional": false, + "description": "", + "line": 339, + "modifierKind": [ + 124 + ], + "required": false + }, + { + "name": "sampleBrief", + "defaultValue": "SAMPLE_LETTER_BRIEF", + "deprecated": false, + "deprecationMessage": "", + "type": "Brief", + "indexKey": "", + "optional": false, + "description": "", + "line": 281, + "required": false + }, + { + "name": "saveText", + "defaultValue": "''", + "deprecated": false, + "deprecationMessage": "", + "indexKey": "", + "optional": false, + "description": "", + "line": 280, + "required": false + }, + { + "name": "selectedSubOrgId", + "defaultValue": "null", + "deprecated": false, + "deprecationMessage": "", + "type": "string | null", + "indexKey": "", + "optional": false, + "description": "", + "line": 273, + "required": false + }, + { + "name": "subOrgLabel", + "defaultValue": "$localize`:@@orgTemplate.subOrg:Organisatieonderdeel`", + "deprecated": false, + "deprecationMessage": "", + "indexKey": "", + "optional": false, + "description": "", + "line": 333, + "modifierKind": [ + 124 + ], + "required": false + }, + { + "name": "subOrgs", + "defaultValue": "[]", + "deprecated": false, + "deprecationMessage": "", + "type": "readonly SubOrgSummary[]", + "indexKey": "", + "optional": false, + "description": "", + "line": 272, + "required": false + }, + { + "name": "unsentBriefs", + "defaultValue": "0", + "deprecated": false, + "deprecationMessage": "", + "indexKey": "", + "optional": false, + "description": "", + "line": 276, + "required": false + }, + { + "name": "uploadState", + "deprecated": false, + "deprecationMessage": "", + "type": "UploadState", + "indexKey": "", + "optional": false, + "description": "", + "line": 271, + "required": true + }, + { + "name": "versionLabel", + "defaultValue": "$localize`:@@orgTemplate.version:Versie`", + "deprecated": false, + "deprecationMessage": "", + "indexKey": "", + "optional": false, + "description": "", + "line": 338, + "modifierKind": [ + 124 + ], + "required": false + } + ], + "outputsClass": [ + { + "name": "cancelPublish", + "deprecated": false, + "deprecationMessage": "", + "type": "void", + "indexKey": "", + "optional": false, + "description": "", + "line": 292, + "required": false + }, + { + "name": "confirmPublish", + "deprecated": false, + "deprecationMessage": "", + "type": "void", + "indexKey": "", + "optional": false, + "description": "", + "line": 291, + "required": false + }, + { + "name": "logoRemoved", + "deprecated": false, + "deprecationMessage": "", + "type": "string", + "indexKey": "", + "optional": false, + "description": "", + "line": 288, + "required": false + }, + { + "name": "logoRetry", + "deprecated": false, + "deprecationMessage": "", + "type": "string", + "indexKey": "", + "optional": false, + "description": "", + "line": 289, + "required": false + }, + { + "name": "logoSelected", + "deprecated": false, + "deprecationMessage": "", + "type": "File[]", + "indexKey": "", + "optional": false, + "description": "", + "line": 287, + "required": false + }, + { + "name": "marginEdit", + "deprecated": false, + "deprecationMessage": "", + "type": "{ edge: keyof Margins; value: number }", + "indexKey": "", + "optional": false, + "description": "", + "line": 286, + "required": false + }, + { + "name": "proefbrief", + "deprecated": false, + "deprecationMessage": "", + "type": "void", + "indexKey": "", + "optional": false, + "description": "", + "line": 294, + "required": false + }, + { + "name": "requestPublish", + "deprecated": false, + "deprecationMessage": "", + "type": "void", + "indexKey": "", + "optional": false, + "description": "", + "line": 290, + "required": false + }, + { + "name": "rollback", + "deprecated": false, + "deprecationMessage": "", + "type": "number", + "indexKey": "", + "optional": false, + "description": "", + "line": 293, + "required": false + }, + { + "name": "selectSubOrg", + "deprecated": false, + "deprecationMessage": "", + "type": "string", + "indexKey": "", + "optional": false, + "description": "", + "line": 284, + "required": false + }, + { + "name": "templateEdit", + "deprecated": false, + "deprecationMessage": "", + "type": "{ field: OrgTemplateTextField; value: string }", + "indexKey": "", + "optional": false, + "description": "", + "line": 285, + "required": false + } + ], + "propertiesClass": [ + { + "name": "edges", + "defaultValue": "EDGES", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 296, + "modifierKind": [ + 124, + 148 + ] + }, + { + "name": "impactText", + "defaultValue": "computed(() =>\n $localize`:@@orgTemplate.publish.impact:Dit raakt ${this.unsentBriefs()}:count: nog niet verzonden brieven. Publiceren?`,\n )", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 329, + "modifierKind": [ + 124 + ] + }, + { + "name": "logoCategory", + "defaultValue": "computed(() =>\n this.uploadState().categories.find((c) => c.categoryId === LOGO_CATEGORY),\n )", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 300, + "modifierKind": [ + 124 + ] + }, + { + "name": "logoRejection", + "defaultValue": "computed(() => this.uploadState().rejections[LOGO_CATEGORY])", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 306, + "modifierKind": [ + 124 + ] + }, + { + "name": "logoUploads", + "defaultValue": "computed(() =>\n this.uploadState().uploads.filter((u) => u.categoryId === LOGO_CATEGORY),\n )", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 303, + "modifierKind": [ + 124 + ] + }, + { + "name": "MAX", + "defaultValue": "MARGIN_MAX_MM", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 298, + "modifierKind": [ + 124, + 148 + ] + }, + { + "name": "MIN", + "defaultValue": "MARGIN_MIN_MM", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 297, + "modifierKind": [ + 124, + 148 + ] + } + ], + "methodsClass": [ + { + "name": "edgeLabel", + "args": [ + { + "name": "edge", + "type": "unknown", + "optional": false, + "dotDotDotToken": false, + "deprecated": false, + "deprecationMessage": "" + } + ], + "optional": false, + "returnType": "string", + "typeParameters": [], + "line": 316, + "deprecated": false, + "deprecationMessage": "", + "modifierKind": [ + 124 + ], + "jsdoctags": [ + { + "name": "edge", + "type": "unknown", + "optional": false, + "dotDotDotToken": false, + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, + { + "name": "onMargin", + "args": [ + { + "name": "edge", + "type": "unknown", + "optional": false, + "dotDotDotToken": false, + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "event", + "type": "Event", + "optional": false, + "dotDotDotToken": false, + "deprecated": false, + "deprecationMessage": "" + } + ], + "optional": false, + "returnType": "void", + "typeParameters": [], + "line": 311, + "deprecated": false, + "deprecationMessage": "", + "modifierKind": [ + 124 + ], + "jsdoctags": [ + { + "name": "edge", + "type": "unknown", + "optional": false, + "dotDotDotToken": false, + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "event", + "type": "Event", + "optional": false, + "dotDotDotToken": false, + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, + { + "name": "onSelectSubOrg", + "args": [ + { + "name": "event", + "type": "Event", + "optional": false, + "dotDotDotToken": false, + "deprecated": false, + "deprecationMessage": "" + } + ], + "optional": false, + "returnType": "void", + "typeParameters": [], + "line": 308, + "deprecated": false, + "deprecationMessage": "", + "modifierKind": [ + 124 + ], + "jsdoctags": [ + { + "name": "event", + "type": "Event", + "optional": false, + "dotDotDotToken": false, + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + } + ], + "deprecated": false, + "deprecationMessage": "", + "hostBindings": [], + "hostListeners": [], + "standalone": false, + "imports": [ + { + "name": "DatePipe", + "type": "pipe" + }, + { + "name": "HeadingComponent", + "type": "component" + }, + { + "name": "ButtonComponent", + "type": "component" + }, + { + "name": "AlertComponent", + "type": "component" + }, + { + "name": "FileInputComponent", + "type": "component" + }, + { + "name": "SingleUploadComponent", + "type": "component" + }, + { + "name": "LetterCanvasComponent", + "type": "component" + } + ], + "description": "

Organism (WP-26): the admin org-template editor. The mirror of the drafter's\ncomposer — the letter canvas runs in editableRegions='template' so the\nletterhead/signature/footer are edited in place, while the content is a read-only\nsample. Margins, logo upload, version history and the publish bar sit around it.\nPresentational: every mutation is an output the store turns into a command.

\n", + "rawdescription": "\n\nOrganism (WP-26): the admin org-template editor. The mirror of the drafter's\ncomposer — the letter canvas runs in `editableRegions='template'` so the\nletterhead/signature/footer are edited in place, while the content is a read-only\nsample. Margins, logo upload, version history and the publish bar sit around it.\nPresentational: every mutation is an output the store turns into a command.\n", + "type": "component", + "sourceCode": "import { Component, computed, input, output } from '@angular/core';\nimport { DatePipe } from '@angular/common';\nimport { HeadingComponent } from '@shared/ui/heading/heading.component';\nimport { ButtonComponent } from '@shared/ui/button/button.component';\nimport { AlertComponent } from '@shared/ui/alert/alert.component';\nimport { FileInputComponent } from '@shared/ui/upload/file-input/file-input.component';\nimport { SingleUploadComponent } from '@shared/ui/upload/single-upload/single-upload.component';\nimport { UploadState } from '@shared/upload/upload.machine';\nimport { Brief } from '@brief/domain/brief';\nimport {\n MARGIN_MAX_MM,\n MARGIN_MIN_MM,\n Margins,\n OrgTemplate,\n OrgTemplateVersion,\n SubOrgSummary,\n} from '@brief/domain/org-template';\nimport { OrgTemplateTextField } from '@brief/domain/org-template.machine';\nimport { LetterCanvasComponent } from '@brief/ui/letter-canvas/letter-canvas.component';\n\nconst LOGO_CATEGORY = 'org-logo';\nconst EDGES: readonly (keyof Margins)[] = ['topMm', 'rightMm', 'bottomMm', 'leftMm'];\n\n/** A minimal read-only sample letter, so the admin sees the org identity in context\n while editing (content itself is not the admin's to change). */\nexport const SAMPLE_LETTER_BRIEF: Brief = {\n briefId: 'VOORBEELD-0001',\n beroep: 'arts',\n templateId: 'sample',\n drafterId: 'sample',\n status: { tag: 'draft' },\n placeholders: [\n { key: 'naam_zorgverlener', label: 'Naam zorgverlener', autoResolvable: true },\n { key: 'datum', label: 'Datum', autoResolvable: true },\n ],\n sections: [\n {\n sectionKey: 'body',\n title: 'Voorbeeldinhoud',\n required: true,\n locked: true,\n blocks: [\n {\n type: 'freeText',\n blockId: 'sample-1',\n content: {\n paragraphs: [\n {\n nodes: [\n { type: 'text', text: 'Geachte ' },\n { type: 'placeholder', key: 'naam_zorgverlener' },\n { type: 'text', text: ',' },\n ],\n },\n {\n nodes: [\n {\n type: 'text',\n text: 'Dit is voorbeeldinhoud. Alleen de huisstijl-onderdelen (logo, afzender, ondertekening en voettekst) zijn hier bewerkbaar.',\n },\n ],\n },\n ],\n },\n },\n ],\n },\n ],\n};\n\n/**\n * Organism (WP-26): the admin org-template editor. The mirror of the drafter's\n * composer — the letter canvas runs in `editableRegions='template'` so the\n * letterhead/signature/footer are edited in place, while the content is a read-only\n * sample. Margins, logo upload, version history and the publish bar sit around it.\n * Presentational: every mutation is an output the store turns into a command.\n */\n@Component({\n selector: 'app-org-template-editor',\n imports: [\n DatePipe,\n HeadingComponent,\n ButtonComponent,\n AlertComponent,\n FileInputComponent,\n SingleUploadComponent,\n LetterCanvasComponent,\n ],\n styles: [\n `\n :host {\n display: block;\n }\n .toolbar {\n display: flex;\n justify-content: space-between;\n align-items: end;\n gap: var(--rhc-space-max-md);\n flex-wrap: wrap;\n margin-block-end: var(--rhc-space-max-lg);\n }\n .field {\n display: flex;\n flex-direction: column;\n gap: var(--rhc-space-max-2xs);\n }\n .save {\n color: var(--rhc-color-foreground-subtle);\n font-size: 0.9em;\n }\n .section {\n margin-block-start: var(--rhc-space-max-xl);\n }\n .margins {\n display: flex;\n gap: var(--rhc-space-max-md);\n flex-wrap: wrap;\n border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);\n border-radius: var(--rhc-border-radius-md);\n padding: var(--rhc-space-max-md);\n }\n .margins input {\n width: 6rem;\n }\n .history-list {\n list-style: none;\n margin: 0;\n padding: 0;\n display: flex;\n flex-direction: column;\n gap: var(--rhc-space-max-sm);\n }\n .history-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: var(--rhc-space-max-md);\n border-block-end: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);\n padding-block-end: var(--rhc-space-max-sm);\n }\n .bar {\n display: flex;\n flex-wrap: wrap;\n gap: var(--rhc-space-max-md);\n align-items: center;\n margin-block-start: var(--rhc-space-max-xl);\n }\n .published {\n color: var(--rhc-color-foreground-subtle);\n }\n `,\n ],\n template: `\n
\n \n {{ saveText() }}\n
\n\n \n\n
\n {{ marginsLegend() }}\n @for (edge of edges; track edge) {\n \n }\n
\n\n
\n {{ logoHeading() }}\n @if (logoCategory()) {\n \n }\n @if (logoRejection()) {\n {{ logoRejection() }}\n }\n @if (logoUploads().length) {\n
    \n @for (u of logoUploads(); track u.localId) {\n \n }\n
\n }\n
\n\n
\n {{ historyHeading() }}\n @if (history().length === 0) {\n

{{ noHistory() }}

\n } @else {\n
    \n @for (v of history(); track v.version) {\n
  • \n {{ versionLabel() }} {{ v.version }} · {{ v.publishedAt | date: 'longDate' }}\n \n {{ rollbackLabel() }}\n \n
  • \n }\n
\n }\n
\n\n
\n {{ publishedLabel() }} {{ publishedVersion() }}\n @if (pendingPublish()) {\n {{ impactText() }}\n \n {{ confirmLabel() }}\n \n \n {{ cancelLabel() }}\n \n } @else {\n \n {{ publishLabel() }}\n \n @if (!draftValid()) {\n {{ invalidHint() }}\n }\n }\n \n {{ proefbriefLabel() }}\n \n
\n `,\n})\nexport class OrgTemplateEditorComponent {\n draft = input.required();\n logoUrl = input(null);\n uploadState = input.required();\n subOrgs = input([]);\n selectedSubOrgId = input(null);\n history = input([]);\n publishedVersion = input(0);\n unsentBriefs = input(0);\n draftValid = input(false);\n busy = input(false);\n pendingPublish = input(false);\n saveText = input('');\n sampleBrief = input(SAMPLE_LETTER_BRIEF);\n previewUrlFor = input<(documentId: string) => string | undefined>();\n\n selectSubOrg = output();\n templateEdit = output<{ field: OrgTemplateTextField; value: string }>();\n marginEdit = output<{ edge: keyof Margins; value: number }>();\n logoSelected = output();\n logoRemoved = output();\n logoRetry = output();\n requestPublish = output();\n confirmPublish = output();\n cancelPublish = output();\n rollback = output();\n proefbrief = output();\n\n protected readonly edges = EDGES;\n protected readonly MIN = MARGIN_MIN_MM;\n protected readonly MAX = MARGIN_MAX_MM;\n\n protected logoCategory = computed(() =>\n this.uploadState().categories.find((c) => c.categoryId === LOGO_CATEGORY),\n );\n protected logoUploads = computed(() =>\n this.uploadState().uploads.filter((u) => u.categoryId === LOGO_CATEGORY),\n );\n protected logoRejection = computed(() => this.uploadState().rejections[LOGO_CATEGORY]);\n\n protected onSelectSubOrg(event: Event) {\n this.selectSubOrg.emit((event.target as HTMLSelectElement).value);\n }\n protected onMargin(edge: keyof Margins, event: Event) {\n const value = (event.target as HTMLInputElement).valueAsNumber;\n if (Number.isFinite(value)) this.marginEdit.emit({ edge, value });\n }\n\n protected edgeLabel(edge: keyof Margins): string {\n switch (edge) {\n case 'topMm':\n return $localize`:@@orgTemplate.margin.top:Boven (mm)`;\n case 'rightMm':\n return $localize`:@@orgTemplate.margin.right:Rechts (mm)`;\n case 'bottomMm':\n return $localize`:@@orgTemplate.margin.bottom:Onder (mm)`;\n case 'leftMm':\n return $localize`:@@orgTemplate.margin.left:Links (mm)`;\n }\n }\n\n protected impactText = computed(() =>\n $localize`:@@orgTemplate.publish.impact:Dit raakt ${this.unsentBriefs()}:count: nog niet verzonden brieven. Publiceren?`,\n );\n\n protected subOrgLabel = input($localize`:@@orgTemplate.subOrg:Organisatieonderdeel`);\n protected marginsLegend = input($localize`:@@orgTemplate.margins:Marges (mm, tussen ${MARGIN_MIN_MM}:min: en ${MARGIN_MAX_MM}:max:)`);\n protected logoHeading = input($localize`:@@orgTemplate.logo:Logo`);\n protected historyHeading = input($localize`:@@orgTemplate.history:Versiegeschiedenis`);\n protected noHistory = input($localize`:@@orgTemplate.history.none:Nog niets gepubliceerd.`);\n protected versionLabel = input($localize`:@@orgTemplate.version:Versie`);\n protected rollbackLabel = input($localize`:@@orgTemplate.rollback:Terugzetten in concept`);\n protected publishedLabel = input($localize`:@@orgTemplate.published:Gepubliceerde versie:`);\n protected publishLabel = input($localize`:@@orgTemplate.publish:Publiceren`);\n protected confirmLabel = input($localize`:@@orgTemplate.publish.confirm:Bevestigen`);\n protected cancelLabel = input($localize`:@@orgTemplate.publish.cancel:Annuleren`);\n protected proefbriefLabel = input($localize`:@@orgTemplate.proefbrief:Proefbrief`);\n protected invalidHint = input(\n $localize`:@@orgTemplate.invalid:Vul organisatienaam en ondertekenaar in; marges tussen ${MARGIN_MIN_MM}:min: en ${MARGIN_MAX_MM}:max: mm.`,\n );\n}\n", + "assetsDirs": [], + "styleUrlsData": "", + "stylesData": "\n :host {\n display: block;\n }\n .toolbar {\n display: flex;\n justify-content: space-between;\n align-items: end;\n gap: var(--rhc-space-max-md);\n flex-wrap: wrap;\n margin-block-end: var(--rhc-space-max-lg);\n }\n .field {\n display: flex;\n flex-direction: column;\n gap: var(--rhc-space-max-2xs);\n }\n .save {\n color: var(--rhc-color-foreground-subtle);\n font-size: 0.9em;\n }\n .section {\n margin-block-start: var(--rhc-space-max-xl);\n }\n .margins {\n display: flex;\n gap: var(--rhc-space-max-md);\n flex-wrap: wrap;\n border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);\n border-radius: var(--rhc-border-radius-md);\n padding: var(--rhc-space-max-md);\n }\n .margins input {\n width: 6rem;\n }\n .history-list {\n list-style: none;\n margin: 0;\n padding: 0;\n display: flex;\n flex-direction: column;\n gap: var(--rhc-space-max-sm);\n }\n .history-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: var(--rhc-space-max-md);\n border-block-end: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);\n padding-block-end: var(--rhc-space-max-sm);\n }\n .bar {\n display: flex;\n flex-wrap: wrap;\n gap: var(--rhc-space-max-md);\n align-items: center;\n margin-block-start: var(--rhc-space-max-xl);\n }\n .published {\n color: var(--rhc-color-foreground-subtle);\n }\n \n", + "extends": [] + }, + { + "name": "OrgTemplatePage", + "id": "component-OrgTemplatePage-087415290c78676d315589b9b83e39384e750ad0069991c5a88714ca2f62ad4cf0030dfe1598332c7b6d682a2aea52aa441bfda06f300bc995e0d9cfbc5a0a90", + "file": "src/app/brief/ui/org-template.page.ts", + "encapsulation": [], + "entryComponents": [], + "inputs": [], + "outputs": [], + "providers": [], + "selector": "app-org-template-page", + "styleUrls": [], + "styles": [ + "\n .save {\n color: var(--rhc-color-foreground-subtle);\n font-size: 0.9em;\n }\n " + ], + "template": "\n @if (store.lastError(); as err) {\n {{ err }}\n }\n\n @if (!access.ready()) {\n \n } @else if (!canEdit()) {\n {{ deniedText }}\n } @else {\n \n \n {{ failedText }}\n {{ retryText }}\n \n \n @if (store.draft(); as draft) {\n \n }\n \n \n }\n\n", + "templateUrl": [], + "viewProviders": [], + "hostDirectives": [], + "inputsClass": [], + "outputsClass": [], + "propertiesClass": [ + { + "name": "access", + "defaultValue": "inject(AccessStore)", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 84, + "modifierKind": [ + 124 + ] + }, + { + "name": "canEdit", + "defaultValue": "computed(() => this.access.can('orgtemplate:edit'))", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 87, + "modifierKind": [ + 124 + ] + }, + { + "name": "deniedText", + "defaultValue": "$localize`:@@orgTemplate.page.denied:U hebt geen rechten om organisatiesjablonen te beheren.`", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 92, + "modifierKind": [ + 124 + ] + }, + { + "name": "failedText", + "defaultValue": "$localize`:@@orgTemplate.page.failed:Het sjabloon kon niet worden geladen.`", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 93, + "modifierKind": [ + 124 + ] + }, + { + "name": "heading", + "defaultValue": "$localize`:@@orgTemplate.page.heading:Huisstijl beheren`", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 90, + "modifierKind": [ + 124 + ] + }, + { + "name": "intro", + "defaultValue": "$localize`:@@orgTemplate.page.intro:Beheer per organisatieonderdeel het uiterlijk van de brief: logo, afzender, ondertekening, voettekst en marges.`", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 91, + "modifierKind": [ + 124 + ] + }, + { + "name": "previewUrlFor", + "defaultValue": "() => {...}", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 88, + "modifierKind": [ + 124 + ] + }, + { + "name": "retryText", + "defaultValue": "$localize`:@@orgTemplate.page.retry:Opnieuw proberen`", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 94, + "modifierKind": [ + 124 + ] + }, + { + "name": "savedText", + "defaultValue": "$localize`:@@orgTemplate.page.saved:Concept opgeslagen`", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 97, + "modifierKind": [ + 123 + ] + }, + { + "name": "saveErrorText", + "defaultValue": "$localize`:@@orgTemplate.page.saveError:Opslaan mislukt`", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 98, + "modifierKind": [ + 123 + ] + }, + { + "name": "saveText", + "defaultValue": "computed(() => {\n switch (this.store.saveState().tag) {\n case 'Saving':\n return this.savingText;\n case 'Saved':\n return this.savedText;\n case 'Error':\n return this.saveErrorText;\n default:\n return '';\n }\n })", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 99, + "modifierKind": [ + 124 + ] + }, + { + "name": "savingText", + "defaultValue": "$localize`:@@orgTemplate.page.saving:Concept opslaan…`", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 96, + "modifierKind": [ + 123 + ] + }, + { + "name": "store", + "defaultValue": "inject(OrgTemplateStore)", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 83, + "modifierKind": [ + 124 + ] + }, + { + "name": "uploadAdapter", + "defaultValue": "inject(UploadAdapter)", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 85, + "modifierKind": [ + 123 + ] + } + ], + "methodsClass": [ + { + "name": "reload", + "args": [], + "optional": false, + "returnType": "void", + "typeParameters": [], + "line": 120, + "deprecated": false, + "deprecationMessage": "", + "modifierKind": [ + 124 + ] + } + ], + "deprecated": false, + "deprecationMessage": "", + "hostBindings": [], + "hostListeners": [], + "standalone": false, + "imports": [ + { + "name": "PageShellComponent", + "type": "component" + }, + { + "name": "AlertComponent", + "type": "component" + }, + { + "name": "ButtonComponent", + "type": "component" + }, + { + "name": "ASYNC" + }, + { + "name": "OrgTemplateEditorComponent", + "type": "component" + } + ], + "description": "

Page: thin container for the admin org-template editor (WP-26). Deny-by-default\ncapability gate (orgtemplate:edit) — a denial alert for non-admins, the editor\nfor admins. Loads once the capability resolves; wires store commands to the organism.

\n", + "rawdescription": "\nPage: thin container for the admin org-template editor (WP-26). Deny-by-default\ncapability gate (`orgtemplate:edit`) — a denial alert for non-admins, the editor\nfor admins. Loads once the capability resolves; wires store commands to the organism.", + "type": "component", + "sourceCode": "import { Component, computed, effect, inject } from '@angular/core';\nimport { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';\nimport { AlertComponent } from '@shared/ui/alert/alert.component';\nimport { ButtonComponent } from '@shared/ui/button/button.component';\nimport { ASYNC } from '@shared/ui/async/async.component';\nimport { AccessStore } from '@shared/application/access.store';\nimport { UploadAdapter } from '@shared/upload/upload.adapter';\nimport { OrgTemplateStore } from '@brief/application/org-template.store';\nimport { OrgTemplateEditorComponent } from '@brief/ui/org-template-editor/org-template-editor.component';\n\n/** Page: thin container for the admin org-template editor (WP-26). Deny-by-default\n capability gate (`orgtemplate:edit`) — a denial alert for non-admins, the editor\n for admins. Loads once the capability resolves; wires store commands to the organism. */\n@Component({\n selector: 'app-org-template-page',\n imports: [\n PageShellComponent,\n AlertComponent,\n ButtonComponent,\n ...ASYNC,\n OrgTemplateEditorComponent,\n ],\n styles: [\n `\n .save {\n color: var(--rhc-color-foreground-subtle);\n font-size: 0.9em;\n }\n `,\n ],\n template: `\n \n @if (store.lastError(); as err) {\n {{ err }}\n }\n\n @if (!access.ready()) {\n \n } @else if (!canEdit()) {\n {{ deniedText }}\n } @else {\n \n \n {{ failedText }}\n {{ retryText }}\n \n \n @if (store.draft(); as draft) {\n \n }\n \n \n }\n \n `,\n})\nexport class OrgTemplatePage {\n protected store = inject(OrgTemplateStore);\n protected access = inject(AccessStore);\n private uploadAdapter = inject(UploadAdapter);\n\n protected canEdit = computed(() => this.access.can('orgtemplate:edit'));\n protected previewUrlFor = (documentId: string) => this.uploadAdapter.contentUrl(documentId);\n\n protected heading = $localize`:@@orgTemplate.page.heading:Huisstijl beheren`;\n protected intro = $localize`:@@orgTemplate.page.intro:Beheer per organisatieonderdeel het uiterlijk van de brief: logo, afzender, ondertekening, voettekst en marges.`;\n protected deniedText = $localize`:@@orgTemplate.page.denied:U hebt geen rechten om organisatiesjablonen te beheren.`;\n protected failedText = $localize`:@@orgTemplate.page.failed:Het sjabloon kon niet worden geladen.`;\n protected retryText = $localize`:@@orgTemplate.page.retry:Opnieuw proberen`;\n\n private savingText = $localize`:@@orgTemplate.page.saving:Concept opslaan…`;\n private savedText = $localize`:@@orgTemplate.page.saved:Concept opgeslagen`;\n private saveErrorText = $localize`:@@orgTemplate.page.saveError:Opslaan mislukt`;\n protected saveText = computed(() => {\n switch (this.store.saveState().tag) {\n case 'Saving':\n return this.savingText;\n case 'Saved':\n return this.savedText;\n case 'Error':\n return this.saveErrorText;\n default:\n return '';\n }\n });\n\n constructor() {\n // Load once the capability resolves to `allowed` (a 403 GET would be wasted otherwise).\n effect(() => {\n if (this.canEdit() && this.store.model().tag === 'loading' && this.store.selectedSubOrgId() === null)\n void this.store.load();\n });\n }\n\n protected reload() {\n void this.store.load();\n }\n}\n", + "assetsDirs": [], + "styleUrlsData": "", + "stylesData": "\n .save {\n color: var(--rhc-color-foreground-subtle);\n font-size: 0.9em;\n }\n \n", + "constructorObj": { + "name": "constructor", + "description": "", + "deprecated": false, + "deprecationMessage": "", + "args": [], + "line": 110 + }, + "extends": [] + }, { "name": "PageShellComponent", "id": "component-PageShellComponent-63dbf836ace3b433da1eedc736a2dbabe3e44a3951249edc0d236e953856002d7e263e35a44a47a20d6375a2be3fc58cb3e976470e704399a7e5f50f83c4e8f7", @@ -19687,7 +22226,7 @@ }, { "name": "PassagePickerComponent", - "id": "component-PassagePickerComponent-61e92246ba5329367274c46fd9bbf6201d22bfb6762d6c1f81b88ad0a1f1f1c4489556c629501a39b08f8cb2a31bef7077a8cea8aa42d18be93e7e3a77efcfa5", + "id": "component-PassagePickerComponent-e24a0db8b6f18c473803765abdf0623d21d6b39e90fc557667b2d1d1bfa8bf5f5be4119ee46166fc5e1c728d09d77920640a39211c6c3c687491fb1f5bb2f4a7", "file": "src/app/brief/ui/passage-picker/passage-picker.component.ts", "encapsulation": [], "entryComponents": [], @@ -19697,7 +22236,7 @@ "selector": "app-passage-picker", "styleUrls": [], "styles": [ - "\n :host {\n display: block;\n background: var(--rhc-color-wit, #fff);\n border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);\n border-radius: var(--rhc-border-radius-md);\n padding: var(--rhc-space-max-md);\n }\n ul {\n list-style: none;\n margin: 0 0 var(--rhc-space-max-md);\n padding: 0;\n display: grid;\n gap: var(--rhc-space-max-sm);\n }\n .scope {\n color: var(--rhc-color-foreground-subtle);\n }\n " + "\n :host {\n display: block;\n background: var(--rhc-color-wit);\n border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);\n border-radius: var(--rhc-border-radius-md);\n padding: var(--rhc-space-max-md);\n }\n ul {\n list-style: none;\n margin: 0 0 var(--rhc-space-max-md);\n padding: 0;\n display: grid;\n gap: var(--rhc-space-max-sm);\n }\n .scope {\n color: var(--rhc-color-foreground-subtle);\n }\n " ], "template": "
    \n @for (p of passages(); track p.passageId) {\n
  • \n \n · {{ p.scope === 'beroep' ? beroepLabel() : globalLabel() }}\n
  • \n }\n
\n{{ addLabel() }} ({{ count() }})\n", "templateUrl": [], @@ -19883,10 +22422,10 @@ "description": "

Molecule: multi-select list of the section's library passages. One "Voeg toe"\ninserts ALL checked passages at once (a single message upstream) — there is no\nsingle-insert path. Presentational: emits the chosen passages in list order.

\n", "rawdescription": "\nMolecule: multi-select list of the section's library passages. One \"Voeg toe\"\ninserts ALL checked passages at once (a single message upstream) — there is no\nsingle-insert path. Presentational: emits the chosen passages in list order.", "type": "component", - "sourceCode": "import { Component, input, output, signal } from '@angular/core';\nimport { FormsModule } from '@angular/forms';\nimport { CheckboxComponent } from '@shared/ui/checkbox/checkbox.component';\nimport { ButtonComponent } from '@shared/ui/button/button.component';\nimport { LibraryPassage } from '@brief/domain/brief';\n\n/** Molecule: multi-select list of the section's library passages. One \"Voeg toe\"\n inserts ALL checked passages at once (a single message upstream) — there is no\n single-insert path. Presentational: emits the chosen passages in list order. */\n@Component({\n selector: 'app-passage-picker',\n imports: [FormsModule, CheckboxComponent, ButtonComponent],\n styles: [\n `\n :host {\n display: block;\n background: var(--rhc-color-wit, #fff);\n border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);\n border-radius: var(--rhc-border-radius-md);\n padding: var(--rhc-space-max-md);\n }\n ul {\n list-style: none;\n margin: 0 0 var(--rhc-space-max-md);\n padding: 0;\n display: grid;\n gap: var(--rhc-space-max-sm);\n }\n .scope {\n color: var(--rhc-color-foreground-subtle);\n }\n `,\n ],\n template: `\n
    \n @for (p of passages(); track p.passageId) {\n
  • \n \n · {{ p.scope === 'beroep' ? beroepLabel() : globalLabel() }}\n
  • \n }\n
\n {{ addLabel() }} ({{ count() }})\n `,\n})\nexport class PassagePickerComponent {\n passages = input.required();\n insert = output();\n\n addLabel = input($localize`:@@brief.picker.add:Voeg toe`);\n globalLabel = input($localize`:@@brief.picker.global:algemeen`);\n beroepLabel = input($localize`:@@brief.picker.beroep:beroepsspecifiek`);\n\n protected checked = signal>({});\n protected count = () => Object.values(this.checked()).filter(Boolean).length;\n\n protected set(id: string, on: boolean) {\n this.checked.update((c) => ({ ...c, [id]: on }));\n }\n\n protected add() {\n const chosen = this.passages().filter((p) => this.checked()[p.passageId]);\n if (chosen.length) {\n this.insert.emit(chosen);\n this.checked.set({});\n }\n }\n}\n", + "sourceCode": "import { Component, input, output, signal } from '@angular/core';\nimport { FormsModule } from '@angular/forms';\nimport { CheckboxComponent } from '@shared/ui/checkbox/checkbox.component';\nimport { ButtonComponent } from '@shared/ui/button/button.component';\nimport { LibraryPassage } from '@brief/domain/brief';\n\n/** Molecule: multi-select list of the section's library passages. One \"Voeg toe\"\n inserts ALL checked passages at once (a single message upstream) — there is no\n single-insert path. Presentational: emits the chosen passages in list order. */\n@Component({\n selector: 'app-passage-picker',\n imports: [FormsModule, CheckboxComponent, ButtonComponent],\n styles: [\n `\n :host {\n display: block;\n background: var(--rhc-color-wit);\n border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);\n border-radius: var(--rhc-border-radius-md);\n padding: var(--rhc-space-max-md);\n }\n ul {\n list-style: none;\n margin: 0 0 var(--rhc-space-max-md);\n padding: 0;\n display: grid;\n gap: var(--rhc-space-max-sm);\n }\n .scope {\n color: var(--rhc-color-foreground-subtle);\n }\n `,\n ],\n template: `\n
    \n @for (p of passages(); track p.passageId) {\n
  • \n \n · {{ p.scope === 'beroep' ? beroepLabel() : globalLabel() }}\n
  • \n }\n
\n {{ addLabel() }} ({{ count() }})\n `,\n})\nexport class PassagePickerComponent {\n passages = input.required();\n insert = output();\n\n addLabel = input($localize`:@@brief.picker.add:Voeg toe`);\n globalLabel = input($localize`:@@brief.picker.global:algemeen`);\n beroepLabel = input($localize`:@@brief.picker.beroep:beroepsspecifiek`);\n\n protected checked = signal>({});\n protected count = () => Object.values(this.checked()).filter(Boolean).length;\n\n protected set(id: string, on: boolean) {\n this.checked.update((c) => ({ ...c, [id]: on }));\n }\n\n protected add() {\n const chosen = this.passages().filter((p) => this.checked()[p.passageId]);\n if (chosen.length) {\n this.insert.emit(chosen);\n this.checked.set({});\n }\n }\n}\n", "assetsDirs": [], "styleUrlsData": "", - "stylesData": "\n :host {\n display: block;\n background: var(--rhc-color-wit, #fff);\n border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);\n border-radius: var(--rhc-border-radius-md);\n padding: var(--rhc-space-max-md);\n }\n ul {\n list-style: none;\n margin: 0 0 var(--rhc-space-max-md);\n padding: 0;\n display: grid;\n gap: var(--rhc-space-max-sm);\n }\n .scope {\n color: var(--rhc-color-foreground-subtle);\n }\n \n", + "stylesData": "\n :host {\n display: block;\n background: var(--rhc-color-wit);\n border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);\n border-radius: var(--rhc-border-radius-md);\n padding: var(--rhc-space-max-md);\n }\n ul {\n list-style: none;\n margin: 0 0 var(--rhc-space-max-md);\n padding: 0;\n display: grid;\n gap: var(--rhc-space-max-sm);\n }\n .scope {\n color: var(--rhc-color-foreground-subtle);\n }\n \n", "extends": [] }, { @@ -24192,6 +26731,16 @@ "type": "number", "defaultValue": "600" }, + { + "name": "EDGES", + "ctype": "miscellaneous", + "subtype": "variable", + "file": "src/app/brief/ui/org-template-editor/org-template-editor.component.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "(unknown)[]", + "defaultValue": "['topMm', 'rightMm', 'bottomMm', 'leftMm']" + }, { "name": "emptyDraft", "ctype": "miscellaneous", @@ -24236,6 +26785,18 @@ "type": "unknown", "defaultValue": "(error: E): Result => ({ ok: false, error })" }, + { + "name": "FAILED", + "ctype": "miscellaneous", + "subtype": "variable", + "file": "src/app/brief/infrastructure/org-template.adapter.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "defaultValue": "$localize`:@@orgTemplate.action.failed:De actie is niet gelukt. Probeer het later opnieuw.`", + "rawdescription": "The only place admin org-template HTTP lives (ADR-0001 boundary). CRUD/publish/\nrollback go through the generated client (X-Role added by `roleInterceptor`);\n`parse*` narrows the untrusted wire shape. The proefbrief is `text/html` and\n`ExcludeFromDescription`'d — a hand-written fetch, same seam as `letter-preview.adapter`.", + "description": "

The only place admin org-template HTTP lives (ADR-0001 boundary). CRUD/publish/\nrollback go through the generated client (X-Role added by roleInterceptor);\nparse* narrows the untrusted wire shape. The proefbrief is text/html and\nExcludeFromDescription'd — a hand-written fetch, same seam as letter-preview.adapter.

\n" + }, { "name": "find", "ctype": "miscellaneous", @@ -24298,6 +26859,16 @@ "type": "BriefState", "defaultValue": "{ tag: 'loading' }" }, + { + "name": "initial", + "ctype": "miscellaneous", + "subtype": "variable", + "file": "src/app/brief/domain/org-template.machine.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "OrgTemplateState", + "defaultValue": "{ tag: 'loading' }" + }, { "name": "initial", "ctype": "miscellaneous", @@ -24380,6 +26951,48 @@ "type": "Capability[]", "defaultValue": "[\n 'brief:approve',\n 'brief:reject',\n 'brief:send',\n 'orgtemplate:edit',\n]" }, + { + "name": "LOGO_CATEGORY", + "ctype": "miscellaneous", + "subtype": "variable", + "file": "src/app/brief/application/org-template.store.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "string", + "defaultValue": "'org-logo'" + }, + { + "name": "LOGO_CATEGORY", + "ctype": "miscellaneous", + "subtype": "variable", + "file": "src/app/brief/ui/org-template-editor/org-template-editor.component.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "string", + "defaultValue": "'org-logo'" + }, + { + "name": "MARGIN_MAX_MM", + "ctype": "miscellaneous", + "subtype": "variable", + "file": "src/app/brief/domain/org-template.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "number", + "defaultValue": "50" + }, + { + "name": "MARGIN_MIN_MM", + "ctype": "miscellaneous", + "subtype": "variable", + "file": "src/app/brief/domain/org-template.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "number", + "defaultValue": "10", + "rawdescription": "Margin bounds (server-owned, `OrgTemplateRules`): the FE mirrors them for instant\nfeedback via ``; the server re-validates and stays the authority.", + "description": "

Margin bounds (server-owned, OrgTemplateRules): the FE mirrors them for instant\nfeedback via <input min max>; the server re-validates and stays the authority.

\n" + }, { "name": "MARK_TAG", "ctype": "miscellaneous", @@ -24454,6 +27067,16 @@ "rawdescription": "The server-owned geldigheidsvraag whose\"ja\" answer requires a Dutch-taalvaardigheid\nupload (proof of the confirmed B2 level). Stable id shared with the backend.", "description": "

The server-owned geldigheidsvraag whose"ja" answer requires a Dutch-taalvaardigheid\nupload (proof of the confirmed B2 level). Stable id shared with the backend.

\n" }, + { + "name": "NO_SUBORGS", + "ctype": "miscellaneous", + "subtype": "variable", + "file": "src/app/brief/application/org-template.store.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "defaultValue": "$localize`:@@orgTemplate.noSubOrgs:Er zijn geen organisatiesjablonen om te beheren.`" + }, { "name": "ok", "ctype": "miscellaneous", @@ -24476,6 +27099,16 @@ "rawdescription": "The quarantined boundary between the imperative `contenteditable` DOM and the\nserialisable `RichTextBlock` value. Pure functions (given a DOM they render /\nread) so they round-trip losslessly and can be unit-tested without Angular. The\nrest of the app only ever sees `RichTextBlock` — this is the one place DOM leaks.\n\nponytail: mark detection covers the tags/styles a browser's execCommand emits\n(strong/b, em/i, u, and inline font-weight/style/decoration); exotic pasted markup\ndegrades to plain text rather than crashing.", "description": "

The quarantined boundary between the imperative contenteditable DOM and the\nserialisable RichTextBlock value. Pure functions (given a DOM they render /\nread) so they round-trip losslessly and can be unit-tested without Angular. The\nrest of the app only ever sees RichTextBlock — this is the one place DOM leaks.

\n

ponytail: mark detection covers the tags/styles a browser's execCommand emits\n(strong/b, em/i, u, and inline font-weight/style/decoration); exotic pasted markup\ndegrades to plain text rather than crashing.

\n" }, + { + "name": "parseAdminView", + "ctype": "miscellaneous", + "subtype": "variable", + "file": "src/app/brief/infrastructure/org-template.adapter.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "defaultValue": "parseOrgTemplateAdminView" + }, { "name": "parseError", "ctype": "miscellaneous", @@ -24497,6 +27130,26 @@ "rawdescription": "A stable Idempotency-Key threaded down from the command layer (one per logical\nsubmit — see `runSubmit`) rather than minted per HTTP attempt, so a retried\nsubmit dedupes on the backend instead of double-submitting. The NSwag-generated\n`ApiClient` has no per-call header hook, so `withIdempotencyKey` bridges it here:\nevery non-GET call made synchronously inside `fn` picks up the same key.\nponytail: a module-level variable, not a proper async-context primitive — holds\nup because every submit command calls its adapter synchronously (no await\nbefore reaching this file); swap for `AsyncLocal`-equivalent if concurrent\nsubmits ever become possible.", "description": "

A stable Idempotency-Key threaded down from the command layer (one per logical\nsubmit — see runSubmit) rather than minted per HTTP attempt, so a retried\nsubmit dedupes on the backend instead of double-submitting. The NSwag-generated\nApiClient has no per-call header hook, so withIdempotencyKey bridges it here:\nevery non-GET call made synchronously inside fn picks up the same key.\nponytail: a module-level variable, not a proper async-context primitive — holds\nup because every submit command calls its adapter synchronously (no await\nbefore reaching this file); swap for AsyncLocal-equivalent if concurrent\nsubmits ever become possible.

\n" }, + { + "name": "PREVIEW_FAILED", + "ctype": "miscellaneous", + "subtype": "variable", + "file": "src/app/brief/infrastructure/letter-preview.adapter.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "defaultValue": "$localize`:@@brief.preview.failed:De voorvertoning kon niet worden geopend.`" + }, + { + "name": "PROEFBRIEF_FAILED", + "ctype": "miscellaneous", + "subtype": "variable", + "file": "src/app/brief/infrastructure/org-template.adapter.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "defaultValue": "$localize`:@@orgTemplate.proefbrief.failed:De proefbrief kon niet worden geopend.`" + }, { "name": "RAW_BRACES", "ctype": "miscellaneous", @@ -24569,7 +27222,7 @@ "deprecated": false, "deprecationMessage": "", "type": "Routes", - "defaultValue": "[\n {\n path: '',\n component: ShellComponent, // persistent header/footer; only children swap\n children: [\n { path: '', pathMatch: 'full', redirectTo: 'login' },\n {\n path: 'login',\n loadComponent: () => \"import('@auth/ui/login.page').then((m) => m.LoginPage)\",\n },\n {\n path: 'dashboard',\n canActivate: [authGuard],\n loadComponent: () => \"import('@registratie/ui/dashboard.page').then((m) => m.DashboardPage)\",\n },\n {\n path: 'registratie',\n canActivate: [authGuard],\n loadComponent: () =>\n \"import('@registratie/ui/registration-detail.page').then((m) => m.RegistrationDetailPage)\",\n },\n {\n path: 'aanvraag/:id',\n canActivate: [authGuard],\n loadComponent: () =>\n \"import('@registratie/ui/aanvraag-detail.page').then((m) => m.AanvraagDetailPage)\",\n },\n {\n path: 'registreren',\n canActivate: [authGuard],\n loadComponent: () =>\n \"import('@registratie/ui/registratie.page').then((m) => m.RegistratiePage)\",\n },\n {\n path: 'herregistratie',\n canActivate: [authGuard],\n loadComponent: () =>\n \"import('@herregistratie/ui/herregistratie.page').then((m) => m.HerregistratiePage)\",\n },\n {\n path: 'intake',\n canActivate: [authGuard],\n loadComponent: () => \"import('@herregistratie/ui/intake.page').then((m) => m.IntakePage)\",\n },\n {\n path: 'brief',\n canActivate: [authGuard],\n loadComponent: () => \"import('@brief/ui/brief.page').then((m) => m.BriefPage)\",\n },\n {\n path: 'concepts',\n loadComponent: () => \"import('./showcase/concepts.page').then((m) => m.ConceptsPage)\",\n },\n { path: '**', redirectTo: 'login' },\n ],\n },\n]" + "defaultValue": "[\n {\n path: '',\n component: ShellComponent, // persistent header/footer; only children swap\n children: [\n { path: '', pathMatch: 'full', redirectTo: 'login' },\n {\n path: 'login',\n loadComponent: () => \"import('@auth/ui/login.page').then((m) => m.LoginPage)\",\n },\n {\n path: 'dashboard',\n canActivate: [authGuard],\n loadComponent: () => \"import('@registratie/ui/dashboard.page').then((m) => m.DashboardPage)\",\n },\n {\n path: 'registratie',\n canActivate: [authGuard],\n loadComponent: () =>\n \"import('@registratie/ui/registration-detail.page').then((m) => m.RegistrationDetailPage)\",\n },\n {\n path: 'aanvraag/:id',\n canActivate: [authGuard],\n loadComponent: () =>\n \"import('@registratie/ui/aanvraag-detail.page').then((m) => m.AanvraagDetailPage)\",\n },\n {\n path: 'registreren',\n canActivate: [authGuard],\n loadComponent: () =>\n \"import('@registratie/ui/registratie.page').then((m) => m.RegistratiePage)\",\n },\n {\n path: 'herregistratie',\n canActivate: [authGuard],\n loadComponent: () =>\n \"import('@herregistratie/ui/herregistratie.page').then((m) => m.HerregistratiePage)\",\n },\n {\n path: 'intake',\n canActivate: [authGuard],\n loadComponent: () => \"import('@herregistratie/ui/intake.page').then((m) => m.IntakePage)\",\n },\n {\n path: 'brief',\n canActivate: [authGuard],\n loadComponent: () => \"import('@brief/ui/brief.page').then((m) => m.BriefPage)\",\n },\n {\n path: 'brief/huisstijl',\n canActivate: [authGuard],\n loadComponent: () =>\n \"import('@brief/ui/org-template.page').then((m) => m.OrgTemplatePage)\",\n },\n {\n path: 'concepts',\n loadComponent: () => \"import('./showcase/concepts.page').then((m) => m.ConceptsPage)\",\n },\n { path: '**', redirectTo: 'login' },\n ],\n },\n]" }, { "name": "ROUTES", @@ -24581,6 +27234,18 @@ "type": "Record", "defaultValue": "{\n '/dashboard': { label: $localize`:@@crumb.dashboard:Mijn overzicht` },\n '/registratie': { label: $localize`:@@crumb.registratie:Mijn gegevens`, parent: '/dashboard' },\n '/registreren': { label: $localize`:@@crumb.registreren:Inschrijven`, parent: '/dashboard' },\n '/herregistratie': {\n label: $localize`:@@crumb.herregistratie:Herregistratie`,\n parent: '/dashboard',\n },\n '/intake': { label: $localize`:@@crumb.intake:Herregistratie-intake`, parent: '/dashboard' },\n '/concepts': { label: $localize`:@@crumb.concepts:Functionele patronen`, parent: '/dashboard' },\n}" }, + { + "name": "SAMPLE_LETTER_BRIEF", + "ctype": "miscellaneous", + "subtype": "variable", + "file": "src/app/brief/ui/org-template-editor/org-template-editor.component.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "Brief", + "defaultValue": "{\n briefId: 'VOORBEELD-0001',\n beroep: 'arts',\n templateId: 'sample',\n drafterId: 'sample',\n status: { tag: 'draft' },\n placeholders: [\n { key: 'naam_zorgverlener', label: 'Naam zorgverlener', autoResolvable: true },\n { key: 'datum', label: 'Datum', autoResolvable: true },\n ],\n sections: [\n {\n sectionKey: 'body',\n title: 'Voorbeeldinhoud',\n required: true,\n locked: true,\n blocks: [\n {\n type: 'freeText',\n blockId: 'sample-1',\n content: {\n paragraphs: [\n {\n nodes: [\n { type: 'text', text: 'Geachte ' },\n { type: 'placeholder', key: 'naam_zorgverlener' },\n { type: 'text', text: ',' },\n ],\n },\n {\n nodes: [\n {\n type: 'text',\n text: 'Dit is voorbeeldinhoud. Alleen de huisstijl-onderdelen (logo, afzender, ondertekening en voettekst) zijn hier bewerkbaar.',\n },\n ],\n },\n ],\n },\n },\n ],\n },\n ],\n}", + "rawdescription": "A minimal read-only sample letter, so the admin sees the org identity in context\nwhile editing (content itself is not the admin's to change).", + "description": "

A minimal read-only sample letter, so the admin sees the org identity in context\nwhile editing (content itself is not the admin's to change).

\n" + }, { "name": "SAMPLE_VALUES", "ctype": "miscellaneous", @@ -25851,6 +28516,48 @@ } ] }, + { + "name": "editDraft", + "file": "src/app/brief/domain/org-template.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "

Edit the loaded draft; a no-op in any non-loaded state (illegal by construction).

\n", + "args": [ + { + "name": "s", + "type": "OrgTemplateState", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "f", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "OrgTemplateState", + "jsdoctags": [ + { + "name": "s", + "type": "OrgTemplateState", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "f", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, { "name": "emptyBlock", "file": "src/app/shared/kernel/rich-text.ts", @@ -25862,6 +28569,35 @@ "args": [], "returnType": "RichTextBlock" }, + { + "name": "errorMessage", + "file": "src/app/brief/infrastructure/letter-preview.adapter.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "args": [ + { + "name": "res", + "type": "Response", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "Promise", + "jsdoctags": [ + { + "name": "res", + "type": "Response", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, { "name": "fakeResource", "file": "src/app/showcase/concepts.page.ts", @@ -28159,6 +30895,35 @@ } ] }, + { + "name": "parseOrgTemplateAdminView", + "file": "src/app/brief/infrastructure/org-template.adapter.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "args": [ + { + "name": "dto", + "type": "OrgTemplateAdminViewDto", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "Result", + "jsdoctags": [ + { + "name": "dto", + "type": "OrgTemplateAdminViewDto", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, { "name": "parsePassage", "file": "src/app/brief/infrastructure/brief.adapter.ts", @@ -28246,6 +31011,35 @@ } ] }, + { + "name": "parsePublish", + "file": "src/app/brief/infrastructure/org-template.adapter.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "args": [ + { + "name": "dto", + "type": "PublishOrgTemplateResponse", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "Result", + "jsdoctags": [ + { + "name": "dto", + "type": "PublishOrgTemplateResponse", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, { "name": "parseQuestions", "file": "src/app/registratie/infrastructure/duo.adapter.ts", @@ -28329,6 +31123,35 @@ } ] }, + { + "name": "parseSubOrg", + "file": "src/app/brief/infrastructure/org-template.adapter.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "args": [ + { + "name": "dto", + "type": "SubOrgSummaryDto", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "Result", + "jsdoctags": [ + { + "name": "dto", + "type": "SubOrgSummaryDto", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, { "name": "parseUren", "file": "src/app/registratie/domain/value-objects/uren.ts", @@ -28358,6 +31181,35 @@ } ] }, + { + "name": "parseVersion", + "file": "src/app/brief/infrastructure/org-template.adapter.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "args": [ + { + "name": "dto", + "type": "OrgTemplateVersionDto", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "Result", + "jsdoctags": [ + { + "name": "dto", + "type": "OrgTemplateVersionDto", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, { "name": "placeholderKeysIn", "file": "src/app/shared/kernel/rich-text.ts", @@ -28711,6 +31563,50 @@ } ] }, + { + "name": "reduce", + "file": "src/app/brief/domain/org-template.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "args": [ + { + "name": "s", + "type": "OrgTemplateState", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "m", + "type": "OrgTemplateMsg", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "OrgTemplateState", + "jsdoctags": [ + { + "name": "s", + "type": "OrgTemplateState", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "m", + "type": "OrgTemplateMsg", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, { "name": "reduce", "file": "src/app/herregistratie/domain/herregistratie.machine.ts", @@ -30187,6 +33083,35 @@ } ] }, + { + "name": "toDto", + "file": "src/app/brief/infrastructure/org-template.adapter.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "args": [ + { + "name": "t", + "type": "OrgTemplate", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "OrgTemplateDto", + "jsdoctags": [ + { + "name": "t", + "type": "OrgTemplate", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, { "name": "toStatusItem", "file": "src/app/shared/upload/upload.adapter.ts", @@ -30447,6 +33372,35 @@ } ] }, + { + "name": "uploadContentUrl", + "file": "src/app/shared/upload/upload.adapter.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "

Direct URL to a stored document's bytes. Pure (no injection) so a store can build\na letterhead-logo src without pulling ApiClient into its dependency graph.

\n", + "args": [ + { + "name": "documentId", + "type": "string", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "string", + "jsdoctags": [ + { + "name": "documentId", + "type": "string", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, { "name": "validate", "file": "src/app/herregistratie/domain/herregistratie.machine.ts", @@ -30900,6 +33854,17 @@ "description": "

Transient action state (submit/approve/reject/send/resetDemo) — one tagged union\ninstead of a busy boolean + a nullable error sitting side by side.

\n", "kind": 193 }, + { + "name": "ActionState", + "ctype": "miscellaneous", + "subtype": "typealias", + "rawtype": "literal type | literal type | literal type", + "file": "src/app/brief/application/org-template.store.ts", + "deprecated": false, + "deprecationMessage": "", + "description": "

Transient action state for publish/rollback/proefbrief — the BriefStore idiom.

\n", + "kind": 193 + }, { "name": "AdresErrors", "ctype": "miscellaneous", @@ -31241,6 +34206,17 @@ "description": "", "kind": 184 }, + { + "name": "LoadedState", + "ctype": "miscellaneous", + "subtype": "typealias", + "rawtype": "Extract", + "file": "src/app/brief/application/org-template.store.ts", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "kind": 184 + }, { "name": "Mark", "ctype": "miscellaneous", @@ -31252,6 +34228,39 @@ "description": "

Rich text as a serialisable value, not opaque HTML.

\n

A block is a node tree. Because a placeholder is a first-class NODE (not a\n{{token}} substring hidden inside a string), highlighting it, inserting it,\nand linting it are all pure functions over data — no regex over markup. This\nis the whole reason the letter feature stays in the "impossible states" style:\nthe value the app holds is always well-shaped, and the imperative editor is\nquarantined behind one component that converts to/from this tree.

\n", "kind": 193 }, + { + "name": "OrgTemplateMsg", + "ctype": "miscellaneous", + "subtype": "typealias", + "rawtype": "literal type | literal type | literal type | literal type | literal type | literal type | literal type", + "file": "src/app/brief/domain/org-template.machine.ts", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "kind": 193 + }, + { + "name": "OrgTemplateState", + "ctype": "miscellaneous", + "subtype": "typealias", + "rawtype": "literal type | literal type | literal type", + "file": "src/app/brief/domain/org-template.machine.ts", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "kind": 193 + }, + { + "name": "OrgTemplateTextField", + "ctype": "miscellaneous", + "subtype": "typealias", + "rawtype": "\"orgName\" | \"returnAddress\" | \"footerContact\" | \"footerLegal\" | \"signatureName\" | \"signatureRole\" | \"signatureClosing\"", + "file": "src/app/brief/domain/org-template.machine.ts", + "deprecated": false, + "deprecationMessage": "", + "description": "

The admin org-template editor as one Elm-style machine (WP-26, PRD Brief v2 §5) —\nthe same idiom as the wizards. The DRAFT org template is form state (edited in\nplace on the canvas); publish/rollback are effects that come back as DraftLoaded.\ndirty tracks unsaved edits (the store debounce-saves them). The logo upload is\nthe composable upload sub-machine folded in, exactly like the wizards fold\nreduceUpload — its UploadComplete/UploadRemoved also mutate draft.logoDocumentId.

\n", + "kind": 193 + }, { "name": "PassageScope", "ctype": "miscellaneous", @@ -31384,6 +34393,17 @@ "description": "

Debounced-autosave indicator, shown in a small status line near the toolbar —\na separate concern from ActionState (a stale autosave error doesn't block\nsubmit/approve/reject), but tag-aligned with it for one consistent idiom.

\n", "kind": 193 }, + { + "name": "SaveState", + "ctype": "miscellaneous", + "subtype": "typealias", + "rawtype": "literal type | literal type | literal type | literal type", + "file": "src/app/brief/application/org-template.store.ts", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "kind": 193 + }, { "name": "Scenario", "ctype": "miscellaneous", @@ -31715,6 +34735,40 @@ "defaultValue": "600" } ], + "src/app/brief/ui/org-template-editor/org-template-editor.component.ts": [ + { + "name": "EDGES", + "ctype": "miscellaneous", + "subtype": "variable", + "file": "src/app/brief/ui/org-template-editor/org-template-editor.component.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "(unknown)[]", + "defaultValue": "['topMm', 'rightMm', 'bottomMm', 'leftMm']" + }, + { + "name": "LOGO_CATEGORY", + "ctype": "miscellaneous", + "subtype": "variable", + "file": "src/app/brief/ui/org-template-editor/org-template-editor.component.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "string", + "defaultValue": "'org-logo'" + }, + { + "name": "SAMPLE_LETTER_BRIEF", + "ctype": "miscellaneous", + "subtype": "variable", + "file": "src/app/brief/ui/org-template-editor/org-template-editor.component.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "Brief", + "defaultValue": "{\n briefId: 'VOORBEELD-0001',\n beroep: 'arts',\n templateId: 'sample',\n drafterId: 'sample',\n status: { tag: 'draft' },\n placeholders: [\n { key: 'naam_zorgverlener', label: 'Naam zorgverlener', autoResolvable: true },\n { key: 'datum', label: 'Datum', autoResolvable: true },\n ],\n sections: [\n {\n sectionKey: 'body',\n title: 'Voorbeeldinhoud',\n required: true,\n locked: true,\n blocks: [\n {\n type: 'freeText',\n blockId: 'sample-1',\n content: {\n paragraphs: [\n {\n nodes: [\n { type: 'text', text: 'Geachte ' },\n { type: 'placeholder', key: 'naam_zorgverlener' },\n { type: 'text', text: ',' },\n ],\n },\n {\n nodes: [\n {\n type: 'text',\n text: 'Dit is voorbeeldinhoud. Alleen de huisstijl-onderdelen (logo, afzender, ondertekening en voettekst) zijn hier bewerkbaar.',\n },\n ],\n },\n ],\n },\n },\n ],\n },\n ],\n}", + "rawdescription": "A minimal read-only sample letter, so the admin sees the org identity in context\nwhile editing (content itself is not the admin's to change).", + "description": "

A minimal read-only sample letter, so the admin sees the org identity in context\nwhile editing (content itself is not the admin's to change).

\n" + } + ], "src/app/registratie/domain/registratie-wizard.machine.ts": [ { "name": "emptyDraft", @@ -31799,6 +34853,40 @@ "defaultValue": "(value: T): Result => ({ ok: true, value })" } ], + "src/app/brief/infrastructure/org-template.adapter.ts": [ + { + "name": "FAILED", + "ctype": "miscellaneous", + "subtype": "variable", + "file": "src/app/brief/infrastructure/org-template.adapter.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "defaultValue": "$localize`:@@orgTemplate.action.failed:De actie is niet gelukt. Probeer het later opnieuw.`", + "rawdescription": "The only place admin org-template HTTP lives (ADR-0001 boundary). CRUD/publish/\nrollback go through the generated client (X-Role added by `roleInterceptor`);\n`parse*` narrows the untrusted wire shape. The proefbrief is `text/html` and\n`ExcludeFromDescription`'d — a hand-written fetch, same seam as `letter-preview.adapter`.", + "description": "

The only place admin org-template HTTP lives (ADR-0001 boundary). CRUD/publish/\nrollback go through the generated client (X-Role added by roleInterceptor);\nparse* narrows the untrusted wire shape. The proefbrief is text/html and\nExcludeFromDescription'd — a hand-written fetch, same seam as letter-preview.adapter.

\n" + }, + { + "name": "parseAdminView", + "ctype": "miscellaneous", + "subtype": "variable", + "file": "src/app/brief/infrastructure/org-template.adapter.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "defaultValue": "parseOrgTemplateAdminView" + }, + { + "name": "PROEFBRIEF_FAILED", + "ctype": "miscellaneous", + "subtype": "variable", + "file": "src/app/brief/infrastructure/org-template.adapter.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "defaultValue": "$localize`:@@orgTemplate.proefbrief.failed:De proefbrief kon niet worden geopend.`" + } + ], "src/app/shared/upload/upload.adapter.ts": [ { "name": "genericError", @@ -31901,6 +34989,18 @@ "defaultValue": "{ tag: 'loading' }" } ], + "src/app/brief/domain/org-template.machine.ts": [ + { + "name": "initial", + "ctype": "miscellaneous", + "subtype": "variable", + "file": "src/app/brief/domain/org-template.machine.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "OrgTemplateState", + "defaultValue": "{ tag: 'loading' }" + } + ], "src/app/herregistratie/domain/herregistratie.machine.ts": [ { "name": "initial", @@ -31987,6 +35087,52 @@ "defaultValue": "[\n 'brief:approve',\n 'brief:reject',\n 'brief:send',\n 'orgtemplate:edit',\n]" } ], + "src/app/brief/application/org-template.store.ts": [ + { + "name": "LOGO_CATEGORY", + "ctype": "miscellaneous", + "subtype": "variable", + "file": "src/app/brief/application/org-template.store.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "string", + "defaultValue": "'org-logo'" + }, + { + "name": "NO_SUBORGS", + "ctype": "miscellaneous", + "subtype": "variable", + "file": "src/app/brief/application/org-template.store.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "defaultValue": "$localize`:@@orgTemplate.noSubOrgs:Er zijn geen organisatiesjablonen om te beheren.`" + } + ], + "src/app/brief/domain/org-template.ts": [ + { + "name": "MARGIN_MAX_MM", + "ctype": "miscellaneous", + "subtype": "variable", + "file": "src/app/brief/domain/org-template.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "number", + "defaultValue": "50" + }, + { + "name": "MARGIN_MIN_MM", + "ctype": "miscellaneous", + "subtype": "variable", + "file": "src/app/brief/domain/org-template.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "number", + "defaultValue": "10", + "rawdescription": "Margin bounds (server-owned, `OrgTemplateRules`): the FE mirrors them for instant\nfeedback via ``; the server re-validates and stays the authority.", + "description": "

Margin bounds (server-owned, OrgTemplateRules): the FE mirrors them for instant\nfeedback via <input min max>; the server re-validates and stays the authority.

\n" + } + ], "src/app/shared/ui/rich-text-editor/rich-text-dom.ts": [ { "name": "MARK_TAG", @@ -32098,6 +35244,18 @@ "description": "

Single place every API call passes through: the seam for cross-cutting concerns.

\n" } ], + "src/app/brief/infrastructure/letter-preview.adapter.ts": [ + { + "name": "PREVIEW_FAILED", + "ctype": "miscellaneous", + "subtype": "variable", + "file": "src/app/brief/infrastructure/letter-preview.adapter.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "defaultValue": "$localize`:@@brief.preview.failed:De voorvertoning kon niet worden geopend.`" + } + ], "src/app/brief/domain/placeholders.ts": [ { "name": "RAW_BRACES", @@ -32155,7 +35313,7 @@ "deprecated": false, "deprecationMessage": "", "type": "Routes", - "defaultValue": "[\n {\n path: '',\n component: ShellComponent, // persistent header/footer; only children swap\n children: [\n { path: '', pathMatch: 'full', redirectTo: 'login' },\n {\n path: 'login',\n loadComponent: () => \"import('@auth/ui/login.page').then((m) => m.LoginPage)\",\n },\n {\n path: 'dashboard',\n canActivate: [authGuard],\n loadComponent: () => \"import('@registratie/ui/dashboard.page').then((m) => m.DashboardPage)\",\n },\n {\n path: 'registratie',\n canActivate: [authGuard],\n loadComponent: () =>\n \"import('@registratie/ui/registration-detail.page').then((m) => m.RegistrationDetailPage)\",\n },\n {\n path: 'aanvraag/:id',\n canActivate: [authGuard],\n loadComponent: () =>\n \"import('@registratie/ui/aanvraag-detail.page').then((m) => m.AanvraagDetailPage)\",\n },\n {\n path: 'registreren',\n canActivate: [authGuard],\n loadComponent: () =>\n \"import('@registratie/ui/registratie.page').then((m) => m.RegistratiePage)\",\n },\n {\n path: 'herregistratie',\n canActivate: [authGuard],\n loadComponent: () =>\n \"import('@herregistratie/ui/herregistratie.page').then((m) => m.HerregistratiePage)\",\n },\n {\n path: 'intake',\n canActivate: [authGuard],\n loadComponent: () => \"import('@herregistratie/ui/intake.page').then((m) => m.IntakePage)\",\n },\n {\n path: 'brief',\n canActivate: [authGuard],\n loadComponent: () => \"import('@brief/ui/brief.page').then((m) => m.BriefPage)\",\n },\n {\n path: 'concepts',\n loadComponent: () => \"import('./showcase/concepts.page').then((m) => m.ConceptsPage)\",\n },\n { path: '**', redirectTo: 'login' },\n ],\n },\n]" + "defaultValue": "[\n {\n path: '',\n component: ShellComponent, // persistent header/footer; only children swap\n children: [\n { path: '', pathMatch: 'full', redirectTo: 'login' },\n {\n path: 'login',\n loadComponent: () => \"import('@auth/ui/login.page').then((m) => m.LoginPage)\",\n },\n {\n path: 'dashboard',\n canActivate: [authGuard],\n loadComponent: () => \"import('@registratie/ui/dashboard.page').then((m) => m.DashboardPage)\",\n },\n {\n path: 'registratie',\n canActivate: [authGuard],\n loadComponent: () =>\n \"import('@registratie/ui/registration-detail.page').then((m) => m.RegistrationDetailPage)\",\n },\n {\n path: 'aanvraag/:id',\n canActivate: [authGuard],\n loadComponent: () =>\n \"import('@registratie/ui/aanvraag-detail.page').then((m) => m.AanvraagDetailPage)\",\n },\n {\n path: 'registreren',\n canActivate: [authGuard],\n loadComponent: () =>\n \"import('@registratie/ui/registratie.page').then((m) => m.RegistratiePage)\",\n },\n {\n path: 'herregistratie',\n canActivate: [authGuard],\n loadComponent: () =>\n \"import('@herregistratie/ui/herregistratie.page').then((m) => m.HerregistratiePage)\",\n },\n {\n path: 'intake',\n canActivate: [authGuard],\n loadComponent: () => \"import('@herregistratie/ui/intake.page').then((m) => m.IntakePage)\",\n },\n {\n path: 'brief',\n canActivate: [authGuard],\n loadComponent: () => \"import('@brief/ui/brief.page').then((m) => m.BriefPage)\",\n },\n {\n path: 'brief/huisstijl',\n canActivate: [authGuard],\n loadComponent: () =>\n \"import('@brief/ui/org-template.page').then((m) => m.OrgTemplatePage)\",\n },\n {\n path: 'concepts',\n loadComponent: () => \"import('./showcase/concepts.page').then((m) => m.ConceptsPage)\",\n },\n { path: '**', redirectTo: 'login' },\n ],\n },\n]" } ], "src/app/shared/layout/breadcrumb/breadcrumb-trail.ts": [ @@ -37062,6 +40220,125 @@ ] } ], + "src/app/brief/domain/org-template.machine.ts": [ + { + "name": "editDraft", + "file": "src/app/brief/domain/org-template.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "

Edit the loaded draft; a no-op in any non-loaded state (illegal by construction).

\n", + "args": [ + { + "name": "s", + "type": "OrgTemplateState", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "f", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "OrgTemplateState", + "jsdoctags": [ + { + "name": "s", + "type": "OrgTemplateState", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "f", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, + { + "name": "reduce", + "file": "src/app/brief/domain/org-template.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "args": [ + { + "name": "s", + "type": "OrgTemplateState", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "m", + "type": "OrgTemplateMsg", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "OrgTemplateState", + "jsdoctags": [ + { + "name": "s", + "type": "OrgTemplateState", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "m", + "type": "OrgTemplateMsg", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + } + ], + "src/app/brief/infrastructure/letter-preview.adapter.ts": [ + { + "name": "errorMessage", + "file": "src/app/brief/infrastructure/letter-preview.adapter.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "args": [ + { + "name": "res", + "type": "Response", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "Promise", + "jsdoctags": [ + { + "name": "res", + "type": "Response", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + } + ], "src/app/showcase/concepts.page.ts": [ { "name": "fakeResource", @@ -37930,6 +41207,153 @@ ] } ], + "src/app/brief/infrastructure/org-template.adapter.ts": [ + { + "name": "parseOrgTemplateAdminView", + "file": "src/app/brief/infrastructure/org-template.adapter.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "args": [ + { + "name": "dto", + "type": "OrgTemplateAdminViewDto", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "Result", + "jsdoctags": [ + { + "name": "dto", + "type": "OrgTemplateAdminViewDto", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, + { + "name": "parsePublish", + "file": "src/app/brief/infrastructure/org-template.adapter.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "args": [ + { + "name": "dto", + "type": "PublishOrgTemplateResponse", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "Result", + "jsdoctags": [ + { + "name": "dto", + "type": "PublishOrgTemplateResponse", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, + { + "name": "parseSubOrg", + "file": "src/app/brief/infrastructure/org-template.adapter.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "args": [ + { + "name": "dto", + "type": "SubOrgSummaryDto", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "Result", + "jsdoctags": [ + { + "name": "dto", + "type": "SubOrgSummaryDto", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, + { + "name": "parseVersion", + "file": "src/app/brief/infrastructure/org-template.adapter.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "args": [ + { + "name": "dto", + "type": "OrgTemplateVersionDto", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "Result", + "jsdoctags": [ + { + "name": "dto", + "type": "OrgTemplateVersionDto", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, + { + "name": "toDto", + "file": "src/app/brief/infrastructure/org-template.adapter.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "args": [ + { + "name": "t", + "type": "OrgTemplate", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "OrgTemplateDto", + "jsdoctags": [ + { + "name": "t", + "type": "OrgTemplate", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + } + ], "src/app/registratie/domain/value-objects/postcode.ts": [ { "name": "parsePostcode", @@ -38305,6 +41729,35 @@ } } ] + }, + { + "name": "uploadContentUrl", + "file": "src/app/shared/upload/upload.adapter.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "

Direct URL to a stored document's bytes. Pure (no injection) so a store can build\na letterhead-logo src without pulling ApiClient into its dependency graph.

\n", + "args": [ + { + "name": "documentId", + "type": "string", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "string", + "jsdoctags": [ + { + "name": "documentId", + "type": "string", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] } ], "src/app/registratie/domain/tasks.ts": [ @@ -38572,6 +42025,41 @@ "kind": 193 } ], + "src/app/brief/application/org-template.store.ts": [ + { + "name": "ActionState", + "ctype": "miscellaneous", + "subtype": "typealias", + "rawtype": "literal type | literal type | literal type", + "file": "src/app/brief/application/org-template.store.ts", + "deprecated": false, + "deprecationMessage": "", + "description": "

Transient action state for publish/rollback/proefbrief — the BriefStore idiom.

\n", + "kind": 193 + }, + { + "name": "LoadedState", + "ctype": "miscellaneous", + "subtype": "typealias", + "rawtype": "Extract", + "file": "src/app/brief/application/org-template.store.ts", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "kind": 184 + }, + { + "name": "SaveState", + "ctype": "miscellaneous", + "subtype": "typealias", + "rawtype": "literal type | literal type | literal type | literal type", + "file": "src/app/brief/application/org-template.store.ts", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "kind": 193 + } + ], "src/app/registratie/ui/address-fields/address-fields.component.ts": [ { "name": "AdresErrors", @@ -39052,6 +42540,41 @@ "kind": 193 } ], + "src/app/brief/domain/org-template.machine.ts": [ + { + "name": "OrgTemplateMsg", + "ctype": "miscellaneous", + "subtype": "typealias", + "rawtype": "literal type | literal type | literal type | literal type | literal type | literal type | literal type", + "file": "src/app/brief/domain/org-template.machine.ts", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "kind": 193 + }, + { + "name": "OrgTemplateState", + "ctype": "miscellaneous", + "subtype": "typealias", + "rawtype": "literal type | literal type | literal type", + "file": "src/app/brief/domain/org-template.machine.ts", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "kind": 193 + }, + { + "name": "OrgTemplateTextField", + "ctype": "miscellaneous", + "subtype": "typealias", + "rawtype": "\"orgName\" | \"returnAddress\" | \"footerContact\" | \"footerLegal\" | \"signatureName\" | \"signatureRole\" | \"signatureClosing\"", + "file": "src/app/brief/domain/org-template.machine.ts", + "deprecated": false, + "deprecationMessage": "", + "description": "

The admin org-template editor as one Elm-style machine (WP-26, PRD Brief v2 §5) —\nthe same idiom as the wizards. The DRAFT org template is form state (edited in\nplace on the canvas); publish/rollback are effects that come back as DraftLoaded.\ndirty tracks unsaved edits (the store debounce-saves them). The logo upload is\nthe composable upload sub-machine folded in, exactly like the wizards fold\nreduceUpload — its UploadComplete/UploadRemoved also mutate draft.logoDocumentId.

\n", + "kind": 193 + } + ], "src/app/registratie/domain/value-objects/postcode.ts": [ { "name": "Postcode", @@ -39255,6 +42778,11 @@ "kind": "route-path", "filename": "src/app/app.routes.ts" }, + { + "name": "brief/huisstijl", + "kind": "route-path", + "filename": "src/app/app.routes.ts" + }, { "name": "concepts", "kind": "route-path", @@ -39278,7 +42806,7 @@ ] }, "coverage": { - "count": 37, + "count": 36, "status": "medium", "files": [ { @@ -39410,9 +42938,9 @@ "type": "injectable", "linktype": "injectable", "name": "BriefStore", - "coveragePercent": 22, - "coverageCount": "7/31", - "status": "low" + "coveragePercent": 26, + "coverageCount": "9/34", + "status": "medium" }, { "filePath": "src/app/brief/application/brief.store.ts", @@ -39444,6 +42972,65 @@ "coverageCount": "1/1", "status": "very-good" }, + { + "filePath": "src/app/brief/application/org-template.store.ts", + "type": "injectable", + "linktype": "injectable", + "name": "OrgTemplateStore", + "coveragePercent": 12, + "coverageCount": "5/41", + "status": "low" + }, + { + "filePath": "src/app/brief/application/org-template.store.ts", + "type": "variable", + "linktype": "miscellaneous", + "linksubtype": "variable", + "name": "LOGO_CATEGORY", + "coveragePercent": 0, + "coverageCount": "0/1", + "status": "low" + }, + { + "filePath": "src/app/brief/application/org-template.store.ts", + "type": "variable", + "linktype": "miscellaneous", + "linksubtype": "variable", + "name": "NO_SUBORGS", + "coveragePercent": 0, + "coverageCount": "0/1", + "status": "low" + }, + { + "filePath": "src/app/brief/application/org-template.store.ts", + "type": "type alias", + "linktype": "miscellaneous", + "linksubtype": "typealias", + "name": "ActionState", + "coveragePercent": 100, + "coverageCount": "1/1", + "status": "very-good" + }, + { + "filePath": "src/app/brief/application/org-template.store.ts", + "type": "type alias", + "linktype": "miscellaneous", + "linksubtype": "typealias", + "name": "LoadedState", + "coveragePercent": 0, + "coverageCount": "0/1", + "status": "low" + }, + { + "filePath": "src/app/brief/application/org-template.store.ts", + "type": "type alias", + "linktype": "miscellaneous", + "linksubtype": "typealias", + "name": "SaveState", + "coveragePercent": 0, + "coverageCount": "0/1", + "status": "low" + }, { "filePath": "src/app/brief/domain/brief.machine.ts", "type": "function", @@ -39720,6 +43307,66 @@ "coverageCount": "1/1", "status": "very-good" }, + { + "filePath": "src/app/brief/domain/org-template.machine.ts", + "type": "function", + "linktype": "miscellaneous", + "linksubtype": "function", + "name": "editDraft", + "coveragePercent": 100, + "coverageCount": "1/1", + "status": "very-good" + }, + { + "filePath": "src/app/brief/domain/org-template.machine.ts", + "type": "function", + "linktype": "miscellaneous", + "linksubtype": "function", + "name": "reduce", + "coveragePercent": 0, + "coverageCount": "0/1", + "status": "low" + }, + { + "filePath": "src/app/brief/domain/org-template.machine.ts", + "type": "variable", + "linktype": "miscellaneous", + "linksubtype": "variable", + "name": "initial", + "coveragePercent": 0, + "coverageCount": "0/1", + "status": "low" + }, + { + "filePath": "src/app/brief/domain/org-template.machine.ts", + "type": "type alias", + "linktype": "miscellaneous", + "linksubtype": "typealias", + "name": "OrgTemplateMsg", + "coveragePercent": 0, + "coverageCount": "0/1", + "status": "low" + }, + { + "filePath": "src/app/brief/domain/org-template.machine.ts", + "type": "type alias", + "linktype": "miscellaneous", + "linksubtype": "typealias", + "name": "OrgTemplateState", + "coveragePercent": 0, + "coverageCount": "0/1", + "status": "low" + }, + { + "filePath": "src/app/brief/domain/org-template.machine.ts", + "type": "type alias", + "linktype": "miscellaneous", + "linksubtype": "typealias", + "name": "OrgTemplateTextField", + "coveragePercent": 100, + "coverageCount": "1/1", + "status": "very-good" + }, { "filePath": "src/app/brief/domain/org-template.ts", "type": "interface", @@ -39738,6 +43385,62 @@ "coverageCount": "3/12", "status": "low" }, + { + "filePath": "src/app/brief/domain/org-template.ts", + "type": "interface", + "linktype": "interface", + "name": "OrgTemplateAdminView", + "coveragePercent": 40, + "coverageCount": "2/5", + "status": "medium" + }, + { + "filePath": "src/app/brief/domain/org-template.ts", + "type": "interface", + "linktype": "interface", + "name": "OrgTemplateVersion", + "coveragePercent": 25, + "coverageCount": "1/4", + "status": "low" + }, + { + "filePath": "src/app/brief/domain/org-template.ts", + "type": "interface", + "linktype": "interface", + "name": "PublishResult", + "coveragePercent": 33, + "coverageCount": "1/3", + "status": "medium" + }, + { + "filePath": "src/app/brief/domain/org-template.ts", + "type": "interface", + "linktype": "interface", + "name": "SubOrgSummary", + "coveragePercent": 25, + "coverageCount": "1/4", + "status": "low" + }, + { + "filePath": "src/app/brief/domain/org-template.ts", + "type": "variable", + "linktype": "miscellaneous", + "linksubtype": "variable", + "name": "MARGIN_MAX_MM", + "coveragePercent": 0, + "coverageCount": "0/1", + "status": "low" + }, + { + "filePath": "src/app/brief/domain/org-template.ts", + "type": "variable", + "linktype": "miscellaneous", + "linksubtype": "variable", + "name": "MARGIN_MIN_MM", + "coveragePercent": 100, + "coverageCount": "1/1", + "status": "very-good" + }, { "filePath": "src/app/brief/domain/placeholders.ts", "type": "interface", @@ -40033,6 +43736,124 @@ "coverageCount": "0/1", "status": "low" }, + { + "filePath": "src/app/brief/infrastructure/letter-preview.adapter.ts", + "type": "injectable", + "linktype": "injectable", + "name": "LetterPreviewAdapter", + "coveragePercent": 50, + "coverageCount": "1/2", + "status": "medium" + }, + { + "filePath": "src/app/brief/infrastructure/letter-preview.adapter.ts", + "type": "function", + "linktype": "miscellaneous", + "linksubtype": "function", + "name": "errorMessage", + "coveragePercent": 0, + "coverageCount": "0/1", + "status": "low" + }, + { + "filePath": "src/app/brief/infrastructure/letter-preview.adapter.ts", + "type": "variable", + "linktype": "miscellaneous", + "linksubtype": "variable", + "name": "PREVIEW_FAILED", + "coveragePercent": 0, + "coverageCount": "0/1", + "status": "low" + }, + { + "filePath": "src/app/brief/infrastructure/org-template.adapter.ts", + "type": "injectable", + "linktype": "injectable", + "name": "OrgTemplateAdapter", + "coveragePercent": 12, + "coverageCount": "1/8", + "status": "low" + }, + { + "filePath": "src/app/brief/infrastructure/org-template.adapter.ts", + "type": "function", + "linktype": "miscellaneous", + "linksubtype": "function", + "name": "parseOrgTemplateAdminView", + "coveragePercent": 0, + "coverageCount": "0/1", + "status": "low" + }, + { + "filePath": "src/app/brief/infrastructure/org-template.adapter.ts", + "type": "function", + "linktype": "miscellaneous", + "linksubtype": "function", + "name": "parsePublish", + "coveragePercent": 0, + "coverageCount": "0/1", + "status": "low" + }, + { + "filePath": "src/app/brief/infrastructure/org-template.adapter.ts", + "type": "function", + "linktype": "miscellaneous", + "linksubtype": "function", + "name": "parseSubOrg", + "coveragePercent": 0, + "coverageCount": "0/1", + "status": "low" + }, + { + "filePath": "src/app/brief/infrastructure/org-template.adapter.ts", + "type": "function", + "linktype": "miscellaneous", + "linksubtype": "function", + "name": "parseVersion", + "coveragePercent": 0, + "coverageCount": "0/1", + "status": "low" + }, + { + "filePath": "src/app/brief/infrastructure/org-template.adapter.ts", + "type": "function", + "linktype": "miscellaneous", + "linksubtype": "function", + "name": "toDto", + "coveragePercent": 0, + "coverageCount": "0/1", + "status": "low" + }, + { + "filePath": "src/app/brief/infrastructure/org-template.adapter.ts", + "type": "variable", + "linktype": "miscellaneous", + "linksubtype": "variable", + "name": "FAILED", + "coveragePercent": 100, + "coverageCount": "1/1", + "status": "very-good" + }, + { + "filePath": "src/app/brief/infrastructure/org-template.adapter.ts", + "type": "variable", + "linktype": "miscellaneous", + "linksubtype": "variable", + "name": "parseAdminView", + "coveragePercent": 0, + "coverageCount": "0/1", + "status": "low" + }, + { + "filePath": "src/app/brief/infrastructure/org-template.adapter.ts", + "type": "variable", + "linktype": "miscellaneous", + "linksubtype": "variable", + "name": "PROEFBRIEF_FAILED", + "coveragePercent": 0, + "coverageCount": "0/1", + "status": "low" + }, { "filePath": "src/app/brief/ui/brief.page.ts", "type": "component", @@ -40065,8 +43886,8 @@ "type": "component", "linktype": "component", "name": "LetterCanvasComponent", - "coveragePercent": 10, - "coverageCount": "3/30", + "coveragePercent": 14, + "coverageCount": "6/42", "status": "low" }, { @@ -40115,7 +43936,7 @@ "linktype": "component", "name": "LetterComposerComponent", "coveragePercent": 3, - "coverageCount": "1/30", + "coverageCount": "1/33", "status": "low" }, { @@ -40127,6 +43948,54 @@ "coverageCount": "1/15", "status": "low" }, + { + "filePath": "src/app/brief/ui/org-template-editor/org-template-editor.component.ts", + "type": "component", + "linktype": "component", + "name": "OrgTemplateEditorComponent", + "coveragePercent": 2, + "coverageCount": "1/49", + "status": "low" + }, + { + "filePath": "src/app/brief/ui/org-template-editor/org-template-editor.component.ts", + "type": "variable", + "linktype": "miscellaneous", + "linksubtype": "variable", + "name": "EDGES", + "coveragePercent": 0, + "coverageCount": "0/1", + "status": "low" + }, + { + "filePath": "src/app/brief/ui/org-template-editor/org-template-editor.component.ts", + "type": "variable", + "linktype": "miscellaneous", + "linksubtype": "variable", + "name": "LOGO_CATEGORY", + "coveragePercent": 0, + "coverageCount": "0/1", + "status": "low" + }, + { + "filePath": "src/app/brief/ui/org-template-editor/org-template-editor.component.ts", + "type": "variable", + "linktype": "miscellaneous", + "linksubtype": "variable", + "name": "SAMPLE_LETTER_BRIEF", + "coveragePercent": 100, + "coverageCount": "1/1", + "status": "very-good" + }, + { + "filePath": "src/app/brief/ui/org-template.page.ts", + "type": "component", + "linktype": "component", + "name": "OrgTemplatePage", + "coveragePercent": 5, + "coverageCount": "1/17", + "status": "low" + }, { "filePath": "src/app/brief/ui/passage-picker/passage-picker.component.ts", "type": "component", @@ -41872,9 +45741,9 @@ "type": "injectable", "linktype": "injectable", "name": "AccessStore", - "coveragePercent": 20, - "coverageCount": "1/5", - "status": "low" + "coveragePercent": 33, + "coverageCount": "2/6", + "status": "medium" }, { "filePath": "src/app/shared/application/access.store.ts", @@ -43776,6 +47645,16 @@ "coverageCount": "0/1", "status": "low" }, + { + "filePath": "src/app/shared/upload/upload.adapter.ts", + "type": "function", + "linktype": "miscellaneous", + "linksubtype": "function", + "name": "uploadContentUrl", + "coveragePercent": 100, + "coverageCount": "1/1", + "status": "very-good" + }, { "filePath": "src/app/shared/upload/upload.adapter.ts", "type": "variable", diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 7616548..5f023a5 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -51,6 +51,12 @@ export const routes: Routes = [ canActivate: [authGuard], loadComponent: () => import('@brief/ui/brief.page').then((m) => m.BriefPage), }, + { + path: 'brief/huisstijl', + canActivate: [authGuard], + loadComponent: () => + import('@brief/ui/org-template.page').then((m) => m.OrgTemplatePage), + }, { path: 'concepts', loadComponent: () => import('./showcase/concepts.page').then((m) => m.ConceptsPage), diff --git a/src/app/brief/application/brief.store.ts b/src/app/brief/application/brief.store.ts index 1271d0a..0ef09ae 100644 --- a/src/app/brief/application/brief.store.ts +++ b/src/app/brief/application/brief.store.ts @@ -13,6 +13,7 @@ import { BriefMsg, BriefState, initial, reduce } from '@brief/domain/brief.machi import { OrgTemplate } from '@brief/domain/org-template'; import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter'; import { LetterPreviewAdapter } from '@brief/infrastructure/letter-preview.adapter'; +import { uploadContentUrl } from '@shared/upload/upload.adapter'; /** Transient action state (submit/approve/reject/send/resetDemo) — one tagged union instead of a busy boolean + a nullable error sitting side by side. */ @@ -56,6 +57,12 @@ export class BriefStore { stays untouched by design). Set from every server view that carries it. */ readonly orgTemplate = signal(null); + /** The org logo's content URL for the letterhead, or null when the template has none. */ + readonly logoUrl = computed(() => { + const id = this.orgTemplate()?.logoDocumentId; + return id ? uploadContentUrl(id) : null; + }); + /** The load lifecycle as `RemoteData`, for `` — the machine keeps 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. */ diff --git a/src/app/brief/application/org-template.store.ts b/src/app/brief/application/org-template.store.ts new file mode 100644 index 0000000..9af7c49 --- /dev/null +++ b/src/app/brief/application/org-template.store.ts @@ -0,0 +1,271 @@ +import { Injectable, computed, effect, inject, signal } from '@angular/core'; +import { RemoteData } from '@shared/application/remote-data'; +import { createStore } from '@shared/application/store'; +import { UploadAdapter } from '@shared/upload/upload.adapter'; +import { UploadShellService } from '@shared/upload/upload-shell.service'; +import { UploadMsg, initialUpload, rejectReason } from '@shared/upload/upload.machine'; +import { + MARGIN_MAX_MM, + MARGIN_MIN_MM, + OrgTemplate, + SubOrgSummary, +} from '@brief/domain/org-template'; +import { + OrgTemplateMsg, + OrgTemplateState, + initial, + reduce, +} from '@brief/domain/org-template.machine'; +import { OrgTemplateAdapter } from '@brief/infrastructure/org-template.adapter'; + +/** Transient action state for publish/rollback/proefbrief — the BriefStore idiom. */ +type ActionState = { tag: 'Idle' } | { tag: 'Busy' } | { tag: 'Failed'; error: string }; +type SaveState = { tag: 'Idle' } | { tag: 'Saving' } | { tag: 'Saved' } | { tag: 'Error' }; +type LoadedState = Extract; + +const LOGO_CATEGORY = 'org-logo'; +const NO_SUBORGS = $localize`:@@orgTemplate.noSubOrgs:Er zijn geen organisatiesjablonen om te beheren.`; + +/** + * Root singleton for the admin org-template editor (WP-26). The Elm machine owns the + * editable draft; commands here do the debounced save, publish (impact-confirm), + * rollback and proefbrief, then dispatch the outcome — the reducer stays pure. The + * logo upload reuses the shared upload transport; its completion mutates the draft + * (in the reducer) and triggers a save (here). Mirrors `BriefStore`. + */ +@Injectable({ providedIn: 'root' }) +export class OrgTemplateStore { + private adapter = inject(OrgTemplateAdapter); + private uploadAdapter = inject(UploadAdapter); + private shell = inject(UploadShellService); + private store = createStore(initial, reduce); + + readonly model = this.store.model; + + readonly subOrgs = signal([]); + readonly selectedSubOrgId = signal(null); + + private actionState = signal({ tag: 'Idle' }); + readonly busy = computed(() => this.actionState().tag === 'Busy'); + readonly lastError = computed(() => { + const s = this.actionState(); + return s.tag === 'Failed' ? s.error : null; + }); + readonly saveState = signal({ tag: 'Idle' }); + + /** The publish impact-confirm gate (PRD §7h: show N affected letters before POST). */ + readonly pendingPublish = signal(false); + + readonly remoteData = computed>(() => { + const s = this.model(); + switch (s.tag) { + case 'loading': + return { tag: 'Loading' }; + case 'failed': + return { tag: 'Failure', error: new Error(s.reason) }; + case 'loaded': + return { tag: 'Success', value: s }; + } + }); + + private loaded = computed(() => { + const s = this.model(); + return s.tag === 'loaded' ? s : null; + }); + readonly draft = computed(() => this.loaded()?.draft ?? null); + readonly uploadState = computed(() => this.loaded()?.upload ?? initialUpload); + readonly history = computed(() => this.loaded()?.history ?? []); + readonly publishedVersion = computed(() => this.loaded()?.publishedVersion ?? 0); + readonly unsentBriefs = computed(() => this.loaded()?.unsentBriefs ?? 0); + readonly logoUrl = computed(() => { + const id = this.draft()?.logoDocumentId; + return id ? this.uploadAdapter.contentUrl(id) : null; + }); + + /** Client-side mirror of the server rules (`OrgTemplateRules`) for instant feedback; + the server re-validates and stays the authority — publish is gated on this. */ + readonly draftValid = computed(() => { + const d = this.draft(); + if (!d) return false; + const marginsOk = [d.margins.topMm, d.margins.rightMm, d.margins.bottomMm, d.margins.leftMm].every( + (v) => v >= MARGIN_MIN_MM && v <= MARGIN_MAX_MM, + ); + return d.orgName.trim().length > 0 && d.signatureName.trim().length > 0 && marginsOk; + }); + + // Live File blobs keyed by localId — needed to retry a failed upload (a reducer can't hold these). + private files = new Map(); + private categoriesRes = this.uploadAdapter.categoriesResource('org-template'); + + constructor() { + // Feed the logo category into the machine's upload sub-state once loaded. Tracks + // `model()` so it re-fires after a sub-org switch reseeds an empty upload state; + // the length guard makes it idempotent (no dispatch loop). + effect(() => { + const s = this.model(); + if (s.tag !== 'loaded' || s.upload.categories.length > 0) return; + const status = this.categoriesRes.status(); + if (status === 'resolved' || status === 'local') + this.dispatchUpload({ type: 'CategoriesLoaded', categories: this.categoriesRes.value() ?? [] }); + }); + } + + async load() { + this.store.dispatch({ tag: 'Loading' }); + const list = await this.adapter.list(); + if (!list.ok) { + this.store.dispatch({ tag: 'LoadFailed', reason: list.error }); + return; + } + this.subOrgs.set(list.value); + const first = list.value[0]; + if (!first) { + this.store.dispatch({ tag: 'LoadFailed', reason: NO_SUBORGS }); + return; + } + await this.selectSubOrg(first.subOrgId); + } + + async selectSubOrg(subOrgId: string) { + this.selectedSubOrgId.set(subOrgId); + this.saveState.set({ tag: 'Idle' }); + clearTimeout(this.saveTimer); + this.store.dispatch({ tag: 'Loading' }); + const r = await this.adapter.load(subOrgId); + if (r.ok) this.store.dispatch({ tag: 'DraftLoaded', view: r.value }); + else this.store.dispatch({ tag: 'LoadFailed', reason: r.error }); + } + + /** An in-place canvas or margin edit: apply optimistically, then debounce-save. */ + edit(msg: OrgTemplateMsg) { + this.store.dispatch(msg); + this.scheduleSave(); + } + + private saveTimer?: ReturnType; + private scheduleSave() { + if (this.loaded() === null) return; + clearTimeout(this.saveTimer); + // ponytail: 600ms debounce, same as BriefStore; the server is the store of record. + this.saveTimer = setTimeout(() => void this.flushSave(), 600); + } + private async flushSave() { + const s = this.loaded(); + if (!s || !s.dirty) return; + const { subOrgId, draft } = s; + this.saveState.set({ tag: 'Saving' }); + const r = await this.adapter.save(subOrgId, draft); + if (r.ok) { + this.saveState.set({ tag: 'Saved' }); + this.store.dispatch({ tag: 'DraftSaved', savedDraft: draft }); + } else { + this.saveState.set({ tag: 'Error' }); + this.actionState.set({ tag: 'Failed', error: r.error }); + } + } + + // --- publish (impact-confirm) / rollback / proefbrief --- + + requestPublish() { + this.pendingPublish.set(true); + } + cancelPublish() { + this.pendingPublish.set(false); + } + async confirmPublish() { + const s = this.loaded(); + if (!s) return; + this.pendingPublish.set(false); + this.actionState.set({ tag: 'Busy' }); + clearTimeout(this.saveTimer); + await this.flushSave(); // publish the saved draft — flush any pending edit first + const r = await this.adapter.publish(s.subOrgId); + if (!r.ok) { + this.actionState.set({ tag: 'Failed', error: r.error }); + return; + } + this.actionState.set({ tag: 'Idle' }); + await this.selectSubOrg(s.subOrgId); // reload: new version, history, unsentBriefs = 0 + } + + async rollback(version: number) { + const s = this.loaded(); + if (!s) return; + this.actionState.set({ tag: 'Busy' }); + clearTimeout(this.saveTimer); + const r = await this.adapter.rollback(s.subOrgId, version); + if (!r.ok) { + this.actionState.set({ tag: 'Failed', error: r.error }); + return; + } + this.actionState.set({ tag: 'Idle' }); + this.store.dispatch({ tag: 'DraftLoaded', view: r.value }); // old version copied into draft + } + + async proefbrief() { + const s = this.loaded(); + if (!s) return; + this.actionState.set({ tag: 'Busy' }); + clearTimeout(this.saveTimer); + await this.flushSave(); // the proefbrief renders the server's draft + const r = await this.adapter.proefbrief(s.subOrgId); + if (!r.ok) { + this.actionState.set({ tag: 'Failed', error: r.error }); + return; + } + this.actionState.set({ tag: 'Idle' }); + window.open(URL.createObjectURL(r.value), '_blank'); + } + + // --- logo upload (reuses the shared upload transport; single `org-logo` file) --- + + onLogoSelected(files: File[]) { + const s = this.loaded(); + const cat = s?.upload.categories.find((c) => c.categoryId === LOGO_CATEGORY); + const file = files[0]; + if (!s || !cat || !file) return; + const reason = rejectReason(cat, { type: file.type, sizeMb: file.size / 1e6 }); + if (reason) { + this.dispatchUpload({ type: 'FileRejected', categoryId: cat.categoryId, reason }); + return; + } + const localId = crypto.randomUUID(); + this.files.set(localId, file); + this.dispatchUpload({ + type: 'FileSelected', + categoryId: cat.categoryId, + localId, + fileName: file.name, + fileSizeMb: file.size / 1e6, + }); + this.shell.upload({ localId, categoryId: cat.categoryId, wizardId: 'org-template', file }, (m) => + this.onUploadMsg(m), + ); + } + + onLogoRemoved(localId: string) { + this.shell.cancel([localId]); + this.files.delete(localId); + this.onUploadMsg({ type: 'UploadRemoved', localId }); + } + + onLogoRetry(localId: string) { + const file = this.files.get(localId); + const up = this.loaded()?.upload.uploads.find((u) => u.localId === localId); + if (!file || !up) return; + this.dispatchUpload({ type: 'UploadRetried', localId }); + this.shell.upload({ localId, categoryId: up.categoryId, wizardId: 'org-template', file }, (m) => + this.onUploadMsg(m), + ); + } + + private dispatchUpload(msg: UploadMsg) { + this.store.dispatch({ tag: 'Upload', msg }); + } + /** Upload effects arriving from the transport: a finished/removed logo edits the + draft (in the reducer) and needs persisting. */ + private onUploadMsg(msg: UploadMsg) { + this.dispatchUpload(msg); + if (msg.type === 'UploadComplete' || msg.type === 'UploadRemoved') this.scheduleSave(); + } +} diff --git a/src/app/brief/domain/org-template.machine.spec.ts b/src/app/brief/domain/org-template.machine.spec.ts new file mode 100644 index 0000000..2baacf9 --- /dev/null +++ b/src/app/brief/domain/org-template.machine.spec.ts @@ -0,0 +1,135 @@ +import { describe, it, expect } from 'vitest'; +import { OrgTemplate, OrgTemplateAdminView } from './org-template'; +import { OrgTemplateState, reduce } from './org-template.machine'; +import { DocumentCategory } from '@shared/upload/upload.machine'; + +const template: OrgTemplate = { + subOrgId: 'cibg-registers', + orgName: 'CIBG', + returnAddress: 'Postbus 1\n2500 AA Den Haag', + footerContact: 'info@cibg.nl', + footerLegal: 'CIBG is onderdeel van VWS', + signatureName: 'A. de Vries', + signatureRole: 'Hoofd Registratie', + signatureClosing: 'Met vriendelijke groet,', + margins: { topMm: 25, rightMm: 20, bottomMm: 25, leftMm: 20 }, + version: 3, +}; + +const view = (over: Partial = {}): OrgTemplateAdminView => ({ + draft: template, + publishedVersion: 3, + history: [], + unsentBriefs: 2, + ...over, +}); + +const loaded = (): OrgTemplateState => reduce({ tag: 'loading' }, { tag: 'DraftLoaded', view: view() }); + +const logoCategory: DocumentCategory = { + categoryId: 'org-logo', + label: 'Logo', + description: '', + required: false, + acceptedTypes: ['image/png'], + maxSizeMb: 2, + multiple: false, + allowPostDelivery: false, +}; + +describe('org-template.machine', () => { + it('DraftLoaded moves to loaded with the draft, clean', () => { + const s = loaded(); + expect(s.tag).toBe('loaded'); + if (s.tag !== 'loaded') return; + expect(s.draft.orgName).toBe('CIBG'); + expect(s.subOrgId).toBe('cibg-registers'); + expect(s.unsentBriefs).toBe(2); + expect(s.dirty).toBe(false); + }); + + it('LoadFailed carries the reason', () => { + const s = reduce({ tag: 'loading' }, { tag: 'LoadFailed', reason: 'boom' }); + expect(s).toEqual({ tag: 'failed', reason: 'boom' }); + }); + + it('FieldEdited edits the draft and marks dirty', () => { + const s = reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'CIBG Nieuw' }); + expect(s.tag === 'loaded' && s.draft.orgName).toBe('CIBG Nieuw'); + expect(s.tag === 'loaded' && s.dirty).toBe(true); + }); + + it('MarginEdited edits one edge and marks dirty', () => { + const s = reduce(loaded(), { tag: 'MarginEdited', edge: 'topMm', value: 40 }); + expect(s.tag === 'loaded' && s.draft.margins.topMm).toBe(40); + expect(s.tag === 'loaded' && s.draft.margins.leftMm).toBe(20); + expect(s.tag === 'loaded' && s.dirty).toBe(true); + }); + + it('DraftSaved clears dirty when the saved draft is the current one', () => { + const edited = reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'X' }); + const savedDraft = edited.tag === 'loaded' ? edited.draft : template; + const s = reduce(edited, { tag: 'DraftSaved', savedDraft }); + expect(s.tag === 'loaded' && s.dirty).toBe(false); + expect(s.tag === 'loaded' && s.draft.orgName).toBe('X'); + }); + + it('DraftSaved keeps dirty when an edit landed during the save round-trip', () => { + const editing = reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'X' }); + const savedDraft = editing.tag === 'loaded' ? editing.draft : template; + // a further edit changes the draft reference before the save resolves + const raced = reduce(editing, { tag: 'FieldEdited', field: 'orgName', value: 'Y' }); + const s = reduce(raced, { tag: 'DraftSaved', savedDraft }); + expect(s.tag === 'loaded' && s.dirty).toBe(true); + }); + + it('edits are no-ops in non-loaded states', () => { + expect(reduce({ tag: 'loading' }, { tag: 'FieldEdited', field: 'orgName', value: 'x' })).toEqual({ + tag: 'loading', + }); + }); + + it('a completed logo upload sets logoDocumentId + dirty', () => { + const withCat = reduce(loaded(), { + tag: 'Upload', + msg: { type: 'CategoriesLoaded', categories: [logoCategory] }, + }); + const selected = reduce(withCat, { + tag: 'Upload', + msg: { type: 'FileSelected', categoryId: 'org-logo', localId: 'a', fileName: 'l.png', fileSizeMb: 0.1 }, + }); + const done = reduce(selected, { + tag: 'Upload', + msg: { type: 'UploadComplete', localId: 'a', documentId: 'doc-1' }, + }); + expect(done.tag === 'loaded' && done.draft.logoDocumentId).toBe('doc-1'); + expect(done.tag === 'loaded' && done.dirty).toBe(true); + }); + + it('removing the logo clears logoDocumentId + dirty', () => { + const withLogo = reduce(loaded(), { + tag: 'Upload', + msg: { type: 'UploadComplete', localId: 'a', documentId: 'doc-1' }, + }); + const removed = reduce(withLogo, { + tag: 'Upload', + msg: { type: 'UploadRemoved', localId: 'a' }, + }); + expect(removed.tag === 'loaded' && removed.draft.logoDocumentId).toBeUndefined(); + expect(removed.tag === 'loaded' && removed.dirty).toBe(true); + }); + + it('DraftLoaded (sub-org switch) keeps the loaded logo category, drops uploads', () => { + const withCat = reduce(loaded(), { + tag: 'Upload', + msg: { type: 'CategoriesLoaded', categories: [logoCategory] }, + }); + const switched = reduce(withCat, { + tag: 'DraftLoaded', + view: view({ draft: { ...template, subOrgId: 'cibg-vakbekwaamheid' } }), + }); + expect(switched.tag === 'loaded' && switched.upload.categories).toHaveLength(1); + expect(switched.tag === 'loaded' && switched.upload.uploads).toHaveLength(0); + expect(switched.tag === 'loaded' && switched.subOrgId).toBe('cibg-vakbekwaamheid'); + }); +}); diff --git a/src/app/brief/domain/org-template.machine.ts b/src/app/brief/domain/org-template.machine.ts new file mode 100644 index 0000000..f9e5dd0 --- /dev/null +++ b/src/app/brief/domain/org-template.machine.ts @@ -0,0 +1,100 @@ +import { assertNever } from '@shared/kernel/fp'; +import { Margins, OrgTemplate, OrgTemplateAdminView, OrgTemplateVersion } from './org-template'; +import { UploadMsg, UploadState, initialUpload, reduceUpload } from '@shared/upload/upload.machine'; + +/** + * The admin org-template editor as one Elm-style machine (WP-26, PRD Brief v2 §5) — + * the same idiom as the wizards. The DRAFT org template is form state (edited in + * place on the canvas); publish/rollback are effects that come back as `DraftLoaded`. + * `dirty` tracks unsaved edits (the store debounce-saves them). The logo upload is + * the composable upload sub-machine folded in, exactly like the wizards fold + * `reduceUpload` — its `UploadComplete`/`UploadRemoved` also mutate `draft.logoDocumentId`. + */ + +/** The org-identity text fields editable directly on the letter canvas. */ +export type OrgTemplateTextField = + | 'orgName' + | 'returnAddress' + | 'footerContact' + | 'footerLegal' + | 'signatureName' + | 'signatureRole' + | 'signatureClosing'; + +export type OrgTemplateState = + | { tag: 'loading' } + | { tag: 'failed'; reason: string } + | { + tag: 'loaded'; + subOrgId: string; + draft: OrgTemplate; + publishedVersion: number; + history: readonly OrgTemplateVersion[]; + unsentBriefs: number; + dirty: boolean; + /** Logo upload sub-state (single file, `org-logo` category). */ + upload: UploadState; + }; + +export const initial: OrgTemplateState = { tag: 'loading' }; + +export type OrgTemplateMsg = + | { tag: 'Loading' } + | { tag: 'DraftLoaded'; view: OrgTemplateAdminView } + | { tag: 'LoadFailed'; reason: string } + | { tag: 'FieldEdited'; field: OrgTemplateTextField; value: string } + | { tag: 'MarginEdited'; edge: keyof Margins; value: number } + /** Carries the draft that was saved: clears `dirty` only if no edit landed during + the round-trip (reference-equal), so a concurrent edit keeps its pending save. */ + | { tag: 'DraftSaved'; savedDraft: OrgTemplate } + | { tag: 'Upload'; msg: UploadMsg }; + +/** Edit the loaded draft; a no-op in any non-loaded state (illegal by construction). */ +function editDraft( + s: OrgTemplateState, + f: (draft: OrgTemplate) => OrgTemplate, +): OrgTemplateState { + return s.tag === 'loaded' ? { ...s, draft: f(s.draft), dirty: true } : s; +} + +export function reduce(s: OrgTemplateState, m: OrgTemplateMsg): OrgTemplateState { + switch (m.tag) { + case 'Loading': + return { tag: 'loading' }; + case 'LoadFailed': + return { tag: 'failed', reason: m.reason }; + case 'DraftLoaded': + return { + tag: 'loaded', + subOrgId: m.view.draft.subOrgId, + draft: m.view.draft, + publishedVersion: m.view.publishedVersion, + history: m.view.history, + unsentBriefs: m.view.unsentBriefs, + dirty: false, + // Keep the loaded logo category across sub-org switches (it's the same + // `org-logo` category, loaded once); drop only any in-flight/finished uploads. + upload: s.tag === 'loaded' ? { ...s.upload, uploads: [], rejections: {} } : initialUpload, + }; + case 'FieldEdited': + return editDraft(s, (d) => ({ ...d, [m.field]: m.value })); + case 'MarginEdited': + return editDraft(s, (d) => ({ ...d, margins: { ...d.margins, [m.edge]: m.value } })); + case 'DraftSaved': + return s.tag === 'loaded' && s.draft === m.savedDraft ? { ...s, dirty: false } : s; + case 'Upload': { + if (s.tag !== 'loaded') return s; + const upload = reduceUpload(s.upload, m.msg); + // A completed/removed logo upload also updates the draft's logoDocumentId. + if (m.msg.type === 'UploadComplete') + return { ...s, upload, draft: { ...s.draft, logoDocumentId: m.msg.documentId }, dirty: true }; + if (m.msg.type === 'UploadRemoved') { + const { logoDocumentId: _dropped, ...rest } = s.draft; + return { ...s, upload, draft: rest, dirty: true }; + } + return { ...s, upload }; + } + default: + return assertNever(m); + } +} diff --git a/src/app/brief/domain/org-template.ts b/src/app/brief/domain/org-template.ts index d97a05f..0153f43 100644 --- a/src/app/brief/domain/org-template.ts +++ b/src/app/brief/domain/org-template.ts @@ -29,3 +29,39 @@ export interface OrgTemplate { /** 0 = draft; n>0 = the published snapshot this letter renders with. */ readonly version: number; } + +// --- admin editor (WP-26) --- + +/** A published snapshot in the version history: who is faked, `publishedAt` is real. */ +export interface OrgTemplateVersion { + readonly version: number; + readonly publishedAt: string; + readonly template: OrgTemplate; +} + +/** The admin editor's view of one sub-org: the editable draft plus publish metadata. */ +export interface OrgTemplateAdminView { + readonly draft: OrgTemplate; + readonly publishedVersion: number; + readonly history: readonly OrgTemplateVersion[]; + /** How many not-yet-sent letters a publish would re-render (the impact count). */ + readonly unsentBriefs: number; +} + +/** One row in the sub-org switcher. */ +export interface SubOrgSummary { + readonly subOrgId: string; + readonly orgName: string; + readonly publishedVersion: number; +} + +/** Publish outcome: the new version and how many unsent letters it touched. */ +export interface PublishResult { + readonly version: number; + readonly affectedUnsentBriefs: number; +} + +/** Margin bounds (server-owned, `OrgTemplateRules`): the FE mirrors them for instant + feedback via ``; the server re-validates and stays the authority. */ +export const MARGIN_MIN_MM = 10; +export const MARGIN_MAX_MM = 50; diff --git a/src/app/brief/infrastructure/org-template.adapter.spec.ts b/src/app/brief/infrastructure/org-template.adapter.spec.ts new file mode 100644 index 0000000..b15234d --- /dev/null +++ b/src/app/brief/infrastructure/org-template.adapter.spec.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from 'vitest'; +import { OrgTemplateAdminViewDto, OrgTemplateDto } from '@shared/infrastructure/api-client'; +import { parseOrgTemplateAdminView } from './org-template.adapter'; + +const draft: OrgTemplateDto = { + subOrgId: 'cibg-registers', + orgName: 'CIBG', + returnAddress: 'Postbus 1', + footerContact: 'info@cibg.nl', + footerLegal: 'onderdeel van VWS', + signatureName: 'A. de Vries', + signatureRole: 'Hoofd Registratie', + signatureClosing: 'Met vriendelijke groet,', + margins: { topMm: 25, rightMm: 20, bottomMm: 25, leftMm: 20 }, + version: 3, +}; + +const view: OrgTemplateAdminViewDto = { + draft, + publishedVersion: 3, + unsentBriefs: 2, + history: [{ version: 2, publishedAt: '2026-06-01', template: draft }], +}; + +describe('parseOrgTemplateAdminView', () => { + it('parses a well-formed admin view', () => { + const r = parseOrgTemplateAdminView(view); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.value.draft.orgName).toBe('CIBG'); + expect(r.value.publishedVersion).toBe(3); + expect(r.value.unsentBriefs).toBe(2); + expect(r.value.history).toHaveLength(1); + expect(r.value.history[0].version).toBe(2); + }); + + it('rejects a missing draft', () => { + const r = parseOrgTemplateAdminView({ ...view, draft: undefined }); + expect(r.ok).toBe(false); + }); + + it('rejects a missing count field', () => { + const r = parseOrgTemplateAdminView({ ...view, unsentBriefs: undefined }); + expect(r.ok).toBe(false); + }); + + it('rejects a malformed history entry', () => { + const r = parseOrgTemplateAdminView({ + ...view, + history: [{ version: 2, publishedAt: '2026-06-01', template: { ...draft, orgName: undefined } }], + }); + expect(r.ok).toBe(false); + }); +}); diff --git a/src/app/brief/infrastructure/org-template.adapter.ts b/src/app/brief/infrastructure/org-template.adapter.ts new file mode 100644 index 0000000..5e82379 --- /dev/null +++ b/src/app/brief/infrastructure/org-template.adapter.ts @@ -0,0 +1,163 @@ +import { Injectable, inject } from '@angular/core'; +import { Result, ok, err } from '@shared/kernel/fp'; +import { runSubmit } from '@shared/application/submit'; +import { currentRole } from '@shared/infrastructure/role'; +import { problemDetail } from '@shared/infrastructure/api-error'; +import { environment } from '../../../environments/environment'; +import { + ApiClient, + OrgTemplateAdminViewDto, + OrgTemplateDto, + OrgTemplateVersionDto, + PublishOrgTemplateResponse, + SubOrgSummaryDto, +} from '@shared/infrastructure/api-client'; +import { + OrgTemplate, + OrgTemplateAdminView, + OrgTemplateVersion, + PublishResult, + SubOrgSummary, +} from '@brief/domain/org-template'; +import { parseOrgTemplate } from '@brief/infrastructure/brief.adapter'; + +/** + * The only place admin org-template HTTP lives (ADR-0001 boundary). CRUD/publish/ + * rollback go through the generated client (X-Role added by `roleInterceptor`); + * `parse*` narrows the untrusted wire shape. The proefbrief is `text/html` and + * `ExcludeFromDescription`'d — a hand-written fetch, same seam as `letter-preview.adapter`. + */ + +const FAILED = $localize`:@@orgTemplate.action.failed:De actie is niet gelukt. Probeer het later opnieuw.`; +const PROEFBRIEF_FAILED = $localize`:@@orgTemplate.proefbrief.failed:De proefbrief kon niet worden geopend.`; + +@Injectable({ providedIn: 'root' }) +export class OrgTemplateAdapter { + private client = inject(ApiClient); + + async list(): Promise> { + const r = await runSubmit(() => this.client.orgTemplates(), FAILED); + if (!r.ok) return r; + const out: SubOrgSummary[] = []; + for (const s of r.value ?? []) { + const parsed = parseSubOrg(s); + if (!parsed.ok) return parsed; + out.push(parsed.value); + } + return ok(out); + } + + async load(subOrgId: string): Promise> { + const r = await runSubmit(() => this.client.orgTemplateGET(subOrgId), FAILED); + return r.ok ? parseAdminView(r.value) : r; + } + + async save(subOrgId: string, draft: OrgTemplate): Promise> { + const r = await runSubmit( + () => this.client.orgTemplatePUT(subOrgId, { draft: toDto(draft) }), + FAILED, + ); + return r.ok ? parseAdminView(r.value) : r; + } + + async publish(subOrgId: string): Promise> { + const r = await runSubmit(() => this.client.orgTemplatePublish(subOrgId), FAILED); + return r.ok ? parsePublish(r.value) : r; + } + + async rollback( + subOrgId: string, + version: number, + ): Promise> { + const r = await runSubmit(() => this.client.orgTemplateRollback(subOrgId, version), FAILED); + return r.ok ? parseAdminView(r.value) : r; + } + + /** Proefbrief: the unpublished draft rendered over a fixture letter, opened as a Blob. */ + async proefbrief(subOrgId: string): Promise> { + let res: Response; + try { + res = await fetch( + `${environment.apiBaseUrl}/api/v1/admin/org-template/${encodeURIComponent(subOrgId)}/preview`, + { headers: { 'X-Role': currentRole() } }, + ); + } catch { + return err(PROEFBRIEF_FAILED); + } + if (!res.ok) { + try { + return err(problemDetail(await res.json(), PROEFBRIEF_FAILED)); + } catch { + return err(PROEFBRIEF_FAILED); + } + } + return ok(await res.blob()); + } +} + +// --- parse: wire → domain, validating at the boundary --- + +function parseSubOrg(dto: SubOrgSummaryDto): Result { + if (typeof dto.subOrgId !== 'string' || typeof dto.orgName !== 'string') + return err('sub-org: bad shape'); + return ok({ + subOrgId: dto.subOrgId, + orgName: dto.orgName, + publishedVersion: dto.publishedVersion ?? 0, + }); +} + +function parseVersion(dto: OrgTemplateVersionDto): Result { + if (typeof dto.version !== 'number' || typeof dto.publishedAt !== 'string') + return err('version: bad shape'); + const template = parseOrgTemplate(dto.template); + if (!template.ok) return template; + return ok({ version: dto.version, publishedAt: dto.publishedAt, template: template.value }); +} + +export function parseOrgTemplateAdminView( + dto: OrgTemplateAdminViewDto, +): Result { + const draft = parseOrgTemplate(dto.draft); + if (!draft.ok) return draft; + if (typeof dto.publishedVersion !== 'number' || typeof dto.unsentBriefs !== 'number') + return err('admin-view: bad shape'); + const history: OrgTemplateVersion[] = []; + for (const v of dto.history ?? []) { + const parsed = parseVersion(v); + if (!parsed.ok) return parsed; + history.push(parsed.value); + } + return ok({ + draft: draft.value, + publishedVersion: dto.publishedVersion, + history, + unsentBriefs: dto.unsentBriefs, + }); +} + +const parseAdminView = parseOrgTemplateAdminView; + +function parsePublish(dto: PublishOrgTemplateResponse): Result { + if (typeof dto.version !== 'number' || typeof dto.affectedUnsentBriefs !== 'number') + return err('publish: bad shape'); + return ok({ version: dto.version, affectedUnsentBriefs: dto.affectedUnsentBriefs }); +} + +// --- toDto: domain → wire (for save) --- + +function toDto(t: OrgTemplate): OrgTemplateDto { + return { + subOrgId: t.subOrgId, + orgName: t.orgName, + returnAddress: t.returnAddress, + ...(t.logoDocumentId != null ? { logoDocumentId: t.logoDocumentId } : {}), + footerContact: t.footerContact, + footerLegal: t.footerLegal, + signatureName: t.signatureName, + signatureRole: t.signatureRole, + signatureClosing: t.signatureClosing, + margins: { ...t.margins }, + version: t.version, + }; +} diff --git a/src/app/brief/ui/brief.page.ts b/src/app/brief/ui/brief.page.ts index 59ec5ed..41f5788 100644 --- a/src/app/brief/ui/brief.page.ts +++ b/src/app/brief/ui/brief.page.ts @@ -50,6 +50,7 @@ import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-compos {{ fromTemplateCaption() }}

} -

{{ orgTemplate().orgName }}

-
{{ orgTemplate().returnAddress }}
+ @if (logoUrl()) { + + } + @if (editing()) { + + + } @else { +

{{ orgTemplate().orgName }}

+
{{ orgTemplate().returnAddress }}
+ }
{{ recipientText() }}
@@ -209,14 +250,51 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
-

{{ orgTemplate().signatureClosing }}

-

{{ orgTemplate().signatureName }}

-

{{ orgTemplate().signatureRole }}

+ @if (editing()) { + + + + } @else { +

{{ orgTemplate().signatureClosing }}

+

{{ orgTemplate().signatureName }}

+

{{ orgTemplate().signatureRole }}

+ }
@for (top of pageBreaks(); track $index) { @@ -238,7 +316,11 @@ export class LetterCanvasComponent { placeholders = input([]); diagnostics = input([]); zoom = input(1); + /** The org logo's content URL (letterhead), or null when none is set. */ + logoUrl = input(null); edit = output(); + /** 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`); @@ -253,6 +335,14 @@ export class LetterCanvasComponent { ); referenceLabel = input($localize`:@@brief.canvas.reference:Ons kenmerk`); dateLabel = input($localize`:@@brief.canvas.date:Datum`); + logoAlt = input($localize`:@@brief.canvas.logoAlt:Logo van de organisatie`); + orgNameLabel = input($localize`:@@brief.canvas.orgName:Organisatienaam`); + returnAddressLabel = input($localize`:@@brief.canvas.returnAddress:Retouradres`); + signatureClosingLabel = input($localize`:@@brief.canvas.signatureClosing:Afsluiting`); + signatureNameLabel = input($localize`:@@brief.canvas.signatureName:Naam ondertekenaar`); + signatureRoleLabel = input($localize`:@@brief.canvas.signatureRole:Functie ondertekenaar`); + footerContactLabel = input($localize`:@@brief.canvas.footerContact:Contactgegevens (voettekst)`); + footerLegalLabel = input($localize`:@@brief.canvas.footerLegal:Juridische voettekst`); protected showSample = signal(false); protected letterDate = formatDatumNl(new Date()); @@ -260,6 +350,12 @@ export class LetterCanvasComponent { /** 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'); + + protected emitEdit(field: OrgTemplateTextField, event: Event) { + this.templateEdit.emit({ field, value: (event.target as HTMLInputElement | HTMLTextAreaElement).value }); + } protected marginStyle = computed(() => { const m = this.orgTemplate().margins; diff --git a/src/app/brief/ui/letter-composer/letter-composer.component.ts b/src/app/brief/ui/letter-composer/letter-composer.component.ts index 1415b6b..4d828c8 100644 --- a/src/app/brief/ui/letter-composer/letter-composer.component.ts +++ b/src/app/brief/ui/letter-composer/letter-composer.component.ts @@ -76,6 +76,7 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti (); orgTemplate = input.required(); + logoUrl = input(null); availablePassages = input([]); diagnostics = input([]); canEdit = input(false); diff --git a/src/app/brief/ui/org-template-editor/org-template-editor.component.ts b/src/app/brief/ui/org-template-editor/org-template-editor.component.ts new file mode 100644 index 0000000..405f0cc --- /dev/null +++ b/src/app/brief/ui/org-template-editor/org-template-editor.component.ts @@ -0,0 +1,348 @@ +import { Component, computed, input, output } from '@angular/core'; +import { DatePipe } from '@angular/common'; +import { HeadingComponent } from '@shared/ui/heading/heading.component'; +import { ButtonComponent } from '@shared/ui/button/button.component'; +import { AlertComponent } from '@shared/ui/alert/alert.component'; +import { FileInputComponent } from '@shared/ui/upload/file-input/file-input.component'; +import { SingleUploadComponent } from '@shared/ui/upload/single-upload/single-upload.component'; +import { UploadState } from '@shared/upload/upload.machine'; +import { Brief } from '@brief/domain/brief'; +import { + MARGIN_MAX_MM, + MARGIN_MIN_MM, + Margins, + OrgTemplate, + OrgTemplateVersion, + SubOrgSummary, +} from '@brief/domain/org-template'; +import { OrgTemplateTextField } from '@brief/domain/org-template.machine'; +import { LetterCanvasComponent } from '@brief/ui/letter-canvas/letter-canvas.component'; + +const LOGO_CATEGORY = 'org-logo'; +const EDGES: readonly (keyof Margins)[] = ['topMm', 'rightMm', 'bottomMm', 'leftMm']; + +/** A minimal read-only sample letter, so the admin sees the org identity in context + while editing (content itself is not the admin's to change). */ +export const SAMPLE_LETTER_BRIEF: Brief = { + briefId: 'VOORBEELD-0001', + beroep: 'arts', + templateId: 'sample', + drafterId: 'sample', + status: { tag: 'draft' }, + placeholders: [ + { key: 'naam_zorgverlener', label: 'Naam zorgverlener', autoResolvable: true }, + { key: 'datum', label: 'Datum', autoResolvable: true }, + ], + sections: [ + { + sectionKey: 'body', + title: 'Voorbeeldinhoud', + required: true, + locked: true, + blocks: [ + { + type: 'freeText', + blockId: 'sample-1', + content: { + paragraphs: [ + { + nodes: [ + { type: 'text', text: 'Geachte ' }, + { type: 'placeholder', key: 'naam_zorgverlener' }, + { type: 'text', text: ',' }, + ], + }, + { + nodes: [ + { + type: 'text', + text: 'Dit is voorbeeldinhoud. Alleen de huisstijl-onderdelen (logo, afzender, ondertekening en voettekst) zijn hier bewerkbaar.', + }, + ], + }, + ], + }, + }, + ], + }, + ], +}; + +/** + * Organism (WP-26): the admin org-template editor. The mirror of the drafter's + * composer — the letter canvas runs in `editableRegions='template'` so the + * letterhead/signature/footer are edited in place, while the content is a read-only + * sample. Margins, logo upload, version history and the publish bar sit around it. + * Presentational: every mutation is an output the store turns into a command. + */ +@Component({ + selector: 'app-org-template-editor', + imports: [ + DatePipe, + HeadingComponent, + ButtonComponent, + AlertComponent, + FileInputComponent, + SingleUploadComponent, + LetterCanvasComponent, + ], + styles: [ + ` + :host { + display: block; + } + .toolbar { + display: flex; + justify-content: space-between; + align-items: end; + gap: var(--rhc-space-max-md); + flex-wrap: wrap; + margin-block-end: var(--rhc-space-max-lg); + } + .field { + display: flex; + flex-direction: column; + gap: var(--rhc-space-max-2xs); + } + .save { + color: var(--rhc-color-foreground-subtle); + font-size: 0.9em; + } + .section { + margin-block-start: var(--rhc-space-max-xl); + } + .margins { + display: flex; + gap: var(--rhc-space-max-md); + flex-wrap: wrap; + border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default); + border-radius: var(--rhc-border-radius-md); + padding: var(--rhc-space-max-md); + } + .margins input { + width: 6rem; + } + .history-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--rhc-space-max-sm); + } + .history-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--rhc-space-max-md); + border-block-end: var(--rhc-border-width-sm) solid var(--rhc-color-border-default); + padding-block-end: var(--rhc-space-max-sm); + } + .bar { + display: flex; + flex-wrap: wrap; + gap: var(--rhc-space-max-md); + align-items: center; + margin-block-start: var(--rhc-space-max-xl); + } + .published { + color: var(--rhc-color-foreground-subtle); + } + `, + ], + template: ` +
+ + {{ saveText() }} +
+ + + +
+ {{ marginsLegend() }} + @for (edge of edges; track edge) { + + } +
+ +
+ {{ logoHeading() }} + @if (logoCategory()) { + + } + @if (logoRejection()) { + {{ logoRejection() }} + } + @if (logoUploads().length) { +
    + @for (u of logoUploads(); track u.localId) { +
  • + } +
+ } +
+ +
+ {{ historyHeading() }} + @if (history().length === 0) { +

{{ noHistory() }}

+ } @else { +
    + @for (v of history(); track v.version) { +
  • + {{ versionLabel() }} {{ v.version }} · {{ v.publishedAt | date: 'longDate' }} + + {{ rollbackLabel() }} + +
  • + } +
+ } +
+ +
+ {{ publishedLabel() }} {{ publishedVersion() }} + @if (pendingPublish()) { + {{ impactText() }} + + {{ confirmLabel() }} + + + {{ cancelLabel() }} + + } @else { + + {{ publishLabel() }} + + @if (!draftValid()) { + {{ invalidHint() }} + } + } + + {{ proefbriefLabel() }} + +
+ `, +}) +export class OrgTemplateEditorComponent { + draft = input.required(); + logoUrl = input(null); + uploadState = input.required(); + subOrgs = input([]); + selectedSubOrgId = input(null); + history = input([]); + publishedVersion = input(0); + unsentBriefs = input(0); + draftValid = input(false); + busy = input(false); + pendingPublish = input(false); + saveText = input(''); + sampleBrief = input(SAMPLE_LETTER_BRIEF); + previewUrlFor = input<(documentId: string) => string | undefined>(); + + selectSubOrg = output(); + templateEdit = output<{ field: OrgTemplateTextField; value: string }>(); + marginEdit = output<{ edge: keyof Margins; value: number }>(); + logoSelected = output(); + logoRemoved = output(); + logoRetry = output(); + requestPublish = output(); + confirmPublish = output(); + cancelPublish = output(); + rollback = output(); + proefbrief = output(); + + protected readonly edges = EDGES; + protected readonly MIN = MARGIN_MIN_MM; + protected readonly MAX = MARGIN_MAX_MM; + + protected logoCategory = computed(() => + this.uploadState().categories.find((c) => c.categoryId === LOGO_CATEGORY), + ); + protected logoUploads = computed(() => + this.uploadState().uploads.filter((u) => u.categoryId === LOGO_CATEGORY), + ); + protected logoRejection = computed(() => this.uploadState().rejections[LOGO_CATEGORY]); + + protected onSelectSubOrg(event: Event) { + this.selectSubOrg.emit((event.target as HTMLSelectElement).value); + } + protected onMargin(edge: keyof Margins, event: Event) { + const value = (event.target as HTMLInputElement).valueAsNumber; + if (Number.isFinite(value)) this.marginEdit.emit({ edge, value }); + } + + protected edgeLabel(edge: keyof Margins): string { + switch (edge) { + case 'topMm': + return $localize`:@@orgTemplate.margin.top:Boven (mm)`; + case 'rightMm': + return $localize`:@@orgTemplate.margin.right:Rechts (mm)`; + case 'bottomMm': + return $localize`:@@orgTemplate.margin.bottom:Onder (mm)`; + case 'leftMm': + return $localize`:@@orgTemplate.margin.left:Links (mm)`; + } + } + + protected impactText = computed(() => + $localize`:@@orgTemplate.publish.impact:Dit raakt ${this.unsentBriefs()}:count: nog niet verzonden brieven. Publiceren?`, + ); + + protected subOrgLabel = input($localize`:@@orgTemplate.subOrg:Organisatieonderdeel`); + protected marginsLegend = input($localize`:@@orgTemplate.margins:Marges (mm, tussen ${MARGIN_MIN_MM}:min: en ${MARGIN_MAX_MM}:max:)`); + protected logoHeading = input($localize`:@@orgTemplate.logo:Logo`); + protected historyHeading = input($localize`:@@orgTemplate.history:Versiegeschiedenis`); + protected noHistory = input($localize`:@@orgTemplate.history.none:Nog niets gepubliceerd.`); + protected versionLabel = input($localize`:@@orgTemplate.version:Versie`); + protected rollbackLabel = input($localize`:@@orgTemplate.rollback:Terugzetten in concept`); + protected publishedLabel = input($localize`:@@orgTemplate.published:Gepubliceerde versie:`); + protected publishLabel = input($localize`:@@orgTemplate.publish:Publiceren`); + protected confirmLabel = input($localize`:@@orgTemplate.publish.confirm:Bevestigen`); + protected cancelLabel = input($localize`:@@orgTemplate.publish.cancel:Annuleren`); + protected proefbriefLabel = input($localize`:@@orgTemplate.proefbrief:Proefbrief`); + protected invalidHint = input( + $localize`:@@orgTemplate.invalid:Vul organisatienaam en ondertekenaar in; marges tussen ${MARGIN_MIN_MM}:min: en ${MARGIN_MAX_MM}:max: mm.`, + ); +} diff --git a/src/app/brief/ui/org-template-editor/org-template-editor.stories.ts b/src/app/brief/ui/org-template-editor/org-template-editor.stories.ts new file mode 100644 index 0000000..0609dac --- /dev/null +++ b/src/app/brief/ui/org-template-editor/org-template-editor.stories.ts @@ -0,0 +1,81 @@ +import type { Meta, StoryObj } from '@storybook/angular'; +import { OrgTemplateEditorComponent } from './org-template-editor.component'; +import { OrgTemplate, OrgTemplateVersion, SubOrgSummary } from '@brief/domain/org-template'; +import { UploadState, initialUpload } from '@shared/upload/upload.machine'; + +const draft: 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: 3, +}; + +const subOrgs: SubOrgSummary[] = [ + { subOrgId: 'cibg-registers', orgName: 'CIBG — Registers', publishedVersion: 3 }, + { subOrgId: 'cibg-vakbekwaamheid', orgName: 'CIBG — Vakbekwaamheid', publishedVersion: 1 }, +]; + +const history: OrgTemplateVersion[] = [ + { version: 3, publishedAt: '2026-06-20', template: draft }, + { version: 2, publishedAt: '2026-05-11', template: draft }, +]; + +const uploadWithCategory: UploadState = { + ...initialUpload, + categories: [ + { + categoryId: 'org-logo', + label: 'Logo', + description: 'Logo van de organisatie', + required: false, + acceptedTypes: ['image/png', 'image/jpeg'], + maxSizeMb: 2, + multiple: false, + allowPostDelivery: false, + }, + ], +}; + +const meta: Meta = { + title: 'Domein/Brief/Org Template Editor', + component: OrgTemplateEditorComponent, + args: { + draft, + logoUrl: null, + uploadState: uploadWithCategory, + subOrgs, + selectedSubOrgId: 'cibg-registers', + history, + publishedVersion: 3, + unsentBriefs: 4, + draftValid: true, + busy: false, + pendingPublish: false, + saveText: '', + }, +}; +export default meta; +type Story = StoryObj; + +export const Editing: Story = {}; + +export const PublishConfirm: Story = { + args: { pendingPublish: true }, +}; + +export const Invalid: Story = { + args: { + draft: { ...draft, orgName: '', margins: { ...draft.margins, topMm: 5 } }, + draftValid: false, + }, +}; + +export const NoHistory: Story = { + args: { history: [], publishedVersion: 0 }, +}; diff --git a/src/app/brief/ui/org-template.page.ts b/src/app/brief/ui/org-template.page.ts new file mode 100644 index 0000000..ebf9b33 --- /dev/null +++ b/src/app/brief/ui/org-template.page.ts @@ -0,0 +1,128 @@ +import { Component, computed, effect, inject } from '@angular/core'; +import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; +import { AlertComponent } from '@shared/ui/alert/alert.component'; +import { ButtonComponent } from '@shared/ui/button/button.component'; +import { ASYNC } from '@shared/ui/async/async.component'; +import { AccessStore } from '@shared/application/access.store'; +import { UploadAdapter } from '@shared/upload/upload.adapter'; +import { OrgTemplateStore } from '@brief/application/org-template.store'; +import { OrgTemplateEditorComponent } from '@brief/ui/org-template-editor/org-template-editor.component'; + +/** Page: thin container for the admin org-template editor (WP-26). Deny-by-default + capability gate (`orgtemplate:edit`) — a denial alert for non-admins, the editor + for admins. Loads once the capability resolves; wires store commands to the organism. */ +@Component({ + selector: 'app-org-template-page', + imports: [ + PageShellComponent, + AlertComponent, + ButtonComponent, + ...ASYNC, + OrgTemplateEditorComponent, + ], + styles: [ + ` + .save { + color: var(--rhc-color-foreground-subtle); + font-size: 0.9em; + } + `, + ], + template: ` + + @if (store.lastError(); as err) { + {{ err }} + } + + @if (!access.ready()) { + + } @else if (!canEdit()) { + {{ deniedText }} + } @else { + + + {{ failedText }} + {{ retryText }} + + + @if (store.draft(); as draft) { + + } + + + } + + `, +}) +export class OrgTemplatePage { + protected store = inject(OrgTemplateStore); + protected access = inject(AccessStore); + private uploadAdapter = inject(UploadAdapter); + + protected canEdit = computed(() => this.access.can('orgtemplate:edit')); + protected previewUrlFor = (documentId: string) => this.uploadAdapter.contentUrl(documentId); + + protected heading = $localize`:@@orgTemplate.page.heading:Huisstijl beheren`; + protected intro = $localize`:@@orgTemplate.page.intro:Beheer per organisatieonderdeel het uiterlijk van de brief: logo, afzender, ondertekening, voettekst en marges.`; + protected deniedText = $localize`:@@orgTemplate.page.denied:U hebt geen rechten om organisatiesjablonen te beheren.`; + protected failedText = $localize`:@@orgTemplate.page.failed:Het sjabloon kon niet worden geladen.`; + protected retryText = $localize`:@@orgTemplate.page.retry:Opnieuw proberen`; + + private savingText = $localize`:@@orgTemplate.page.saving:Concept opslaan…`; + private savedText = $localize`:@@orgTemplate.page.saved:Concept opgeslagen`; + private saveErrorText = $localize`:@@orgTemplate.page.saveError:Opslaan mislukt`; + protected saveText = computed(() => { + switch (this.store.saveState().tag) { + case 'Saving': + return this.savingText; + case 'Saved': + return this.savedText; + case 'Error': + return this.saveErrorText; + default: + return ''; + } + }); + + private loadRequested = false; + constructor() { + // Load once the capability resolves to `allowed` (a 403 GET would be wasted + // otherwise). Depends only on `canEdit()` + a plain flag — never on the store + // model, so dispatching `Loading` inside `load()` can't retrigger this effect. + effect(() => { + if (this.canEdit() && !this.loadRequested) { + this.loadRequested = true; + void this.store.load(); + } + }); + } + + protected reload() { + void this.store.load(); + } +} diff --git a/src/app/brief/ui/passage-picker/passage-picker.component.ts b/src/app/brief/ui/passage-picker/passage-picker.component.ts index 652a425..e79328c 100644 --- a/src/app/brief/ui/passage-picker/passage-picker.component.ts +++ b/src/app/brief/ui/passage-picker/passage-picker.component.ts @@ -14,7 +14,7 @@ import { LibraryPassage } from '@brief/domain/brief'; ` :host { display: block; - background: var(--rhc-color-wit, #fff); + 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-md); diff --git a/src/app/shared/application/access.store.ts b/src/app/shared/application/access.store.ts index b324573..6f815ea 100644 --- a/src/app/shared/application/access.store.ts +++ b/src/app/shared/application/access.store.ts @@ -33,4 +33,11 @@ export class AccessStore { const rd = this.capabilities(); return rd.tag === 'Success' && rd.value.includes(capability); } + + /** True once `/me` has resolved (success or failure) — lets a page-level gate tell + "still loading" apart from "denied", so an admin doesn't flash the denial alert. */ + readonly ready = computed(() => { + const tag = this.capabilities().tag; + return tag === 'Success' || tag === 'Failure'; + }); } diff --git a/src/app/shared/upload/upload.adapter.ts b/src/app/shared/upload/upload.adapter.ts index e9055fc..67bd826 100644 --- a/src/app/shared/upload/upload.adapter.ts +++ b/src/app/shared/upload/upload.adapter.ts @@ -38,6 +38,12 @@ export interface XhrUploadHandle { /** Sentinel rejection so the caller can tell a user-cancel from a real failure. */ export const UPLOAD_ABORTED = Symbol('upload-aborted'); +/** Direct URL to a stored document's bytes. Pure (no injection) so a store can build + a letterhead-logo `src` without pulling `ApiClient` into its dependency graph. */ +export function uploadContentUrl(documentId: string): string { + return `${environment.apiBaseUrl}/api/v1/uploads/${documentId}/content`; +} + /** * Infrastructure: the only place upload HTTP lives. Reads (categories, status, * delete) go through the NSwag client; the multipart POST is hand-written XHR @@ -94,7 +100,7 @@ export class UploadAdapter { * browser opens it. demo-* ids (dev simulation) have no bytes and 404 here. */ contentUrl(documentId: string): string { - return `${environment.apiBaseUrl}/api/v1/uploads/${documentId}/content`; + return uploadContentUrl(documentId); } /**