=> ({ 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.
\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.
\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.
\nA 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() }}