feat(fp): WP-26 — admin org-template editor
Some checks failed
CI / frontend (push) Failing after 57s
CI / storybook-a11y (push) Successful in 4m27s
CI / backend (push) Successful in 1m15s
CI / codeql (csharp) (push) Failing after 1m42s
CI / codeql (javascript-typescript) (push) Failing after 1m19s
CI / api-client-drift (push) Successful in 1m40s
CI / e2e (push) Failing after 3h11m33s

Edit the letter's org identity in place on the same canvas the drafter composes
on (editableRegions='template'): letterhead/signature/footer become inline
controls, content a read-only sample. Margins (bounded), logo upload (reuses the
shared upload transport + single-upload), version history + rollback, proefbrief,
and publish-with-impact-confirmation. House form-machine idiom
(org-template.machine.ts) + root store with debounced save. Capability-gated
(orgtemplate:edit) with a deny-by-default alert; route /brief/huisstijl.

Backend + generated client were already in place (WP-23). Also fixes a
pre-existing red check:tokens (WP-24 canvas hex fallbacks) and threads the
published logo through to the drafter's canvas.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-06 08:22:22 +02:00
parent 1bb9383344
commit f6c837f281
20 changed files with 5530 additions and 182 deletions

View File

@@ -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-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-24](WP-24-letter-canvas.md) | Letter canvas (edit on the letter) | 6 · Brief v2 | done |
| [WP-25](WP-25-letter-preview-html.md) | Server-rendered letter preview (HTML; PDF deferred) | 6 · Brief v2 | todo | | [WP-25](WP-25-letter-preview-html.md) | Server-rendered letter preview (HTML; PDF deferred) | 6 · Brief v2 | todo |
| [WP-26](WP-26-org-template-editor.md) | Admin org-template editor | 6 · Brief v2 | todo | | [WP-26](WP-26-org-template-editor.md) | Admin org-template editor | 6 · Brief v2 | done |
| [WP-27](WP-27-brief-ux-layer.md) | Brief UX layer (undo/redo, standaardbrief, diff) | 6 · Brief v2 | todo | | [WP-27](WP-27-brief-ux-layer.md) | Brief UX layer (undo/redo, standaardbrief, diff) | 6 · Brief v2 | todo |
| [WP-28](WP-28-brief-v2-demo-polish.md) | Brief v2 demo polish (scenarios, e2e, docs) | 6 · Brief v2 | todo | | [WP-28](WP-28-brief-v2-demo-polish.md) | Brief v2 demo polish (scenarios, e2e, docs) | 6 · Brief v2 | todo |

View File

@@ -1,6 +1,6 @@
# WP-26 — Admin org-template editor # WP-26 — Admin org-template editor
Status: todo Status: done
Phase: 6 — Brief v2 (edit-on-the-letter, org templates, server-rendered preview) Phase: 6 — Brief v2 (edit-on-the-letter, org templates, server-rendered preview)
## Why ## Why
@@ -58,17 +58,45 @@ content a read-only sample). PRD Brief v2 §5, §7h.
## Acceptance criteria ## 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. 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. 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. rollback copies an old version into the draft.
- [ ] Non-admin on `/brief/huisstijl` sees the denial alert; API would 403 anyway. - [x] Non-admin on `/brief/huisstijl` sees the denial alert; API would 403 anyway.
- [ ] Logo upload validates type/size client-side (existing `rejectReason`) and - [x] Logo upload validates type/size client-side (existing `rejectReason`) and
renders on the canvas after upload. renders on the canvas after upload.
- [ ] Machine spec covers field edits, dirty tracking, publish/rollback outcomes. - [x] Machine spec covers field edits, dirty tracking, publish/rollback outcomes.
- [ ] Full GREEN. - [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 `<input>`/`<textarea>` controls (aria-labelled)
and shows the logo `<img>`; the letter body stays a read-only sample
(`SAMPLE_LETTER_BRIEF`). `logoUrl` input added to the canvas and wired through the
composer too, so a published logo shows to the drafter (AC2).
- **Load-effect loop (caught in the live walk)**: the page's initial-load effect must
NOT read the store model — `load()` dispatches `Loading` (a fresh object), which
would retrigger a model-reading effect into a runaway loop that saturated the page.
Gated on `canEdit()` + a plain `loadRequested` flag instead.
- **`AccessStore.ready`** added (tiny): a page-level capability gate needs to tell
"still loading `/me`" from "denied" so an admin doesn't flash the denial alert.
- **`uploadContentUrl`** pure helper extracted from `UploadAdapter.contentUrl` so
`BriefStore` can build a logo `src` without pulling `ApiClient` into its DI graph
(kept its spec green).
- **Role caching caveat**: `/me` loads once. Open the app with `?role=admin` from the
first navigation that touches the editor (the dev role stub, like `?scenario=`);
switching role mid-session won't refetch capabilities (out of scope, POC).
- **Dirty race**: `DraftSaved` carries the saved draft and clears `dirty` only if it's
reference-equal to the current draft, so an edit landing during a save round-trip
keeps its pending save.
## Verification ## Verification

File diff suppressed because one or more lines are too long

View File

@@ -51,6 +51,12 @@ export const routes: Routes = [
canActivate: [authGuard], canActivate: [authGuard],
loadComponent: () => import('@brief/ui/brief.page').then((m) => m.BriefPage), 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', path: 'concepts',
loadComponent: () => import('./showcase/concepts.page').then((m) => m.ConceptsPage), loadComponent: () => import('./showcase/concepts.page').then((m) => m.ConceptsPage),

View File

@@ -13,6 +13,7 @@ import { BriefMsg, BriefState, initial, reduce } from '@brief/domain/brief.machi
import { OrgTemplate } from '@brief/domain/org-template'; import { OrgTemplate } from '@brief/domain/org-template';
import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter'; import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';
import { LetterPreviewAdapter } from '@brief/infrastructure/letter-preview.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 /** Transient action state (submit/approve/reject/send/resetDemo) — one tagged union
instead of a busy boolean + a nullable error sitting side by side. */ 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. */ stays untouched by design). Set from every server view that carries it. */
readonly orgTemplate = signal<OrgTemplate | null>(null); readonly orgTemplate = signal<OrgTemplate | null>(null);
/** The org logo's content URL for the letterhead, or null when the template has none. */
readonly logoUrl = computed<string | null>(() => {
const id = this.orgTemplate()?.logoDocumentId;
return id ? uploadContentUrl(id) : null;
});
/** The load lifecycle as `RemoteData`, for `<app-async>` — the machine keeps /** The load lifecycle as `RemoteData`, for `<app-async>` — the machine keeps
owning the letter's own domain lifecycle (draft/submitted/approved/…); this is owning the letter's own domain lifecycle (draft/submitted/approved/…); this is
purely a projection of its loading/failed tags onto the shared async seam. */ purely a projection of its loading/failed tags onto the shared async seam. */

View File

@@ -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<OrgTemplateState, { tag: 'loaded' }>;
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<OrgTemplateState, OrgTemplateMsg>(initial, reduce);
readonly model = this.store.model;
readonly subOrgs = signal<readonly SubOrgSummary[]>([]);
readonly selectedSubOrgId = signal<string | null>(null);
private actionState = signal<ActionState>({ 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<SaveState>({ tag: 'Idle' });
/** The publish impact-confirm gate (PRD §7h: show N affected letters before POST). */
readonly pendingPublish = signal(false);
readonly remoteData = computed<RemoteData<Error | undefined, LoadedState>>(() => {
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<LoadedState | null>(() => {
const s = this.model();
return s.tag === 'loaded' ? s : null;
});
readonly draft = computed<OrgTemplate | null>(() => 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<string | null>(() => {
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<string, File>();
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<typeof setTimeout>;
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();
}
}

View File

@@ -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> = {}): 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');
});
});

View File

@@ -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);
}
}

View File

@@ -29,3 +29,39 @@ export interface OrgTemplate {
/** 0 = draft; n>0 = the published snapshot this letter renders with. */ /** 0 = draft; n>0 = the published snapshot this letter renders with. */
readonly version: number; 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 `<input min max>`; the server re-validates and stays the authority. */
export const MARGIN_MIN_MM = 10;
export const MARGIN_MAX_MM = 50;

View File

@@ -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);
});
});

View File

@@ -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<Result<string, SubOrgSummary[]>> {
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<Result<string, OrgTemplateAdminView>> {
const r = await runSubmit(() => this.client.orgTemplateGET(subOrgId), FAILED);
return r.ok ? parseAdminView(r.value) : r;
}
async save(subOrgId: string, draft: OrgTemplate): Promise<Result<string, OrgTemplateAdminView>> {
const r = await runSubmit(
() => this.client.orgTemplatePUT(subOrgId, { draft: toDto(draft) }),
FAILED,
);
return r.ok ? parseAdminView(r.value) : r;
}
async publish(subOrgId: string): Promise<Result<string, PublishResult>> {
const r = await runSubmit(() => this.client.orgTemplatePublish(subOrgId), FAILED);
return r.ok ? parsePublish(r.value) : r;
}
async rollback(
subOrgId: string,
version: number,
): Promise<Result<string, OrgTemplateAdminView>> {
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<Result<string, Blob>> {
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<string, SubOrgSummary> {
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<string, OrgTemplateVersion> {
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<string, OrgTemplateAdminView> {
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<string, PublishResult> {
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,
};
}

View File

@@ -50,6 +50,7 @@ import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-compos
<app-letter-composer <app-letter-composer
[brief]="s.brief" [brief]="s.brief"
[orgTemplate]="orgTemplate" [orgTemplate]="orgTemplate"
[logoUrl]="store.logoUrl()"
[availablePassages]="s.availablePassages" [availablePassages]="s.availablePassages"
[diagnostics]="store.diagnostics()" [diagnostics]="store.diagnostics()"
[canEdit]="store.canEdit()" [canEdit]="store.canEdit()"

View File

@@ -18,6 +18,7 @@ import { formatDatumNl } from '@shared/kernel/datum';
import { Paragraph } from '@shared/kernel/rich-text'; import { Paragraph } from '@shared/kernel/rich-text';
import { Brief, LetterBlock, LibraryPassage } from '@brief/domain/brief'; import { Brief, LetterBlock, LibraryPassage } from '@brief/domain/brief';
import { OrgTemplate } from '@brief/domain/org-template'; import { OrgTemplate } from '@brief/domain/org-template';
import { OrgTemplateTextField } from '@brief/domain/org-template.machine';
import { Diagnostic } from '@brief/domain/placeholders'; import { Diagnostic } from '@brief/domain/placeholders';
import { BriefMsg } from '@brief/domain/brief.machine'; import { BriefMsg } from '@brief/domain/brief.machine';
import { LetterSectionComponent } from '@brief/ui/letter-section/letter-section.component'; import { LetterSectionComponent } from '@brief/ui/letter-section/letter-section.component';
@@ -70,26 +71,47 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
} }
/* Portal-side chrome around the letter surface (not part of the contract file). */ /* Portal-side chrome around the letter surface (not part of the contract file). */
.surface { .surface {
background: var(--rhc-color-grijs-100, #f0f0f0); background: var(--rhc-color-grijs-100);
border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default); border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);
border-radius: var(--rhc-border-radius-md); border-radius: var(--rhc-border-radius-md);
padding: var(--rhc-space-max-lg); padding: var(--rhc-space-max-lg);
overflow-x: auto; overflow-x: auto;
} }
.surface .letter { .surface .letter {
box-shadow: 0 1px 4px rgb(0 0 0 / 0.15); box-shadow: 0 1px 4px rgb(0 0 0 / 0.15); /* token-ok: paper drop-shadow, not a palette colour */
} }
/* Org-identity regions: visibly not the drafter's to edit. */ /* Org-identity regions: visibly not the drafter's to edit. */
.from-template { .from-template {
background: var(--rhc-color-grijs-100, #f5f5f5); background: var(--rhc-color-grijs-100);
outline: 2mm solid var(--rhc-color-grijs-100, #f5f5f5); outline: 2mm solid var(--rhc-color-grijs-100);
} }
.from-template-caption { .from-template-caption {
font-size: 7.5pt; font-size: 7.5pt;
/* grijs-700, not foreground-subtle: subtle misses AA contrast on the tint. */ /* grijs-700, not foreground-subtle: subtle misses AA contrast on the tint. */
color: var(--rhc-color-grijs-700, #334155); color: var(--rhc-color-grijs-700);
margin: 0 0 2mm; margin: 0 0 2mm;
} }
/* Admin edit-in-place (editableRegions='template'): the org-identity fields
become controls styled to sit in the letter, with a visible editable affordance. */
.tmpl-input,
.tmpl-textarea {
font: inherit;
color: inherit;
width: 100%;
box-sizing: border-box;
background: var(--rhc-color-geel-100);
border: var(--rhc-border-width-sm) dashed var(--rhc-color-border-strong);
border-radius: var(--rhc-border-radius-sm);
padding: 0.5mm 1mm;
}
.tmpl-textarea {
resize: vertical;
}
.org-logo {
max-height: 20mm;
max-width: 60mm;
margin-block-end: 3mm;
}
`, `,
], ],
template: ` template: `
@@ -137,8 +159,27 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
@if (tintTemplate()) { @if (tintTemplate()) {
<p class="from-template-caption">{{ fromTemplateCaption() }}</p> <p class="from-template-caption">{{ fromTemplateCaption() }}</p>
} }
<p class="org-wordmark">{{ orgTemplate().orgName }}</p> @if (logoUrl()) {
<address class="return-address">{{ orgTemplate().returnAddress }}</address> <img class="org-logo" [src]="logoUrl()" [alt]="logoAlt()" />
}
@if (editing()) {
<input
class="tmpl-input org-wordmark"
[value]="orgTemplate().orgName"
[attr.aria-label]="orgNameLabel()"
(input)="emitEdit('orgName', $event)"
/>
<textarea
class="tmpl-textarea return-address"
rows="2"
[value]="orgTemplate().returnAddress"
[attr.aria-label]="returnAddressLabel()"
(input)="emitEdit('returnAddress', $event)"
></textarea>
} @else {
<p class="org-wordmark">{{ orgTemplate().orgName }}</p>
<address class="return-address">{{ orgTemplate().returnAddress }}</address>
}
<address class="address-window">{{ recipientText() }}</address> <address class="address-window">{{ recipientText() }}</address>
<dl class="reference"> <dl class="reference">
<div> <div>
@@ -209,14 +250,51 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
</div> </div>
<div class="letter__signature" [class.from-template]="tintTemplate()"> <div class="letter__signature" [class.from-template]="tintTemplate()">
<p>{{ orgTemplate().signatureClosing }}</p> @if (editing()) {
<p class="signature-name">{{ orgTemplate().signatureName }}</p> <input
<p>{{ orgTemplate().signatureRole }}</p> class="tmpl-input"
[value]="orgTemplate().signatureClosing"
[attr.aria-label]="signatureClosingLabel()"
(input)="emitEdit('signatureClosing', $event)"
/>
<input
class="tmpl-input signature-name"
[value]="orgTemplate().signatureName"
[attr.aria-label]="signatureNameLabel()"
(input)="emitEdit('signatureName', $event)"
/>
<input
class="tmpl-input"
[value]="orgTemplate().signatureRole"
[attr.aria-label]="signatureRoleLabel()"
(input)="emitEdit('signatureRole', $event)"
/>
} @else {
<p>{{ orgTemplate().signatureClosing }}</p>
<p class="signature-name">{{ orgTemplate().signatureName }}</p>
<p>{{ orgTemplate().signatureRole }}</p>
}
</div> </div>
<div class="letter__footer" [class.from-template]="tintTemplate()"> <div class="letter__footer" [class.from-template]="tintTemplate()">
<div class="footer-contact">{{ orgTemplate().footerContact }}</div> @if (editing()) {
<div class="footer-legal">{{ orgTemplate().footerLegal }}</div> <textarea
class="tmpl-textarea footer-contact"
rows="2"
[value]="orgTemplate().footerContact"
[attr.aria-label]="footerContactLabel()"
(input)="emitEdit('footerContact', $event)"
></textarea>
<input
class="tmpl-input footer-legal"
[value]="orgTemplate().footerLegal"
[attr.aria-label]="footerLegalLabel()"
(input)="emitEdit('footerLegal', $event)"
/>
} @else {
<div class="footer-contact">{{ orgTemplate().footerContact }}</div>
<div class="footer-legal">{{ orgTemplate().footerLegal }}</div>
}
</div> </div>
@for (top of pageBreaks(); track $index) { @for (top of pageBreaks(); track $index) {
@@ -238,7 +316,11 @@ export class LetterCanvasComponent {
placeholders = input<readonly PlaceholderOption[]>([]); placeholders = input<readonly PlaceholderOption[]>([]);
diagnostics = input<readonly Diagnostic[]>([]); diagnostics = input<readonly Diagnostic[]>([]);
zoom = input(1); zoom = input(1);
/** The org logo's content URL (letterhead), or null when none is set. */
logoUrl = input<string | null>(null);
edit = output<BriefMsg>(); edit = output<BriefMsg>();
/** An in-place edit to an org-identity field (only in `editableRegions='template'`). */
templateEdit = output<{ field: OrgTemplateTextField; value: string }>();
showSampleLabel = input($localize`:@@brief.preview.showSample:Voorbeeld met testwaarden`); showSampleLabel = input($localize`:@@brief.preview.showSample:Voorbeeld met testwaarden`);
hideSampleLabel = input($localize`:@@brief.preview.hideSample:Testwaarden verbergen`); hideSampleLabel = input($localize`:@@brief.preview.hideSample:Testwaarden verbergen`);
@@ -253,6 +335,14 @@ export class LetterCanvasComponent {
); );
referenceLabel = input($localize`:@@brief.canvas.reference:Ons kenmerk`); referenceLabel = input($localize`:@@brief.canvas.reference:Ons kenmerk`);
dateLabel = input($localize`:@@brief.canvas.date:Datum`); 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 showSample = signal(false);
protected letterDate = formatDatumNl(new Date()); protected letterDate = formatDatumNl(new Date());
@@ -260,6 +350,12 @@ export class LetterCanvasComponent {
/** The letterhead/signature/footer are tinted "not yours" only while composing — /** 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. */ in 'none' the whole surface is read-only, in 'template' they ARE the editable focus. */
protected tintTemplate = computed(() => this.editableRegions() === 'content'); 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(() => { protected marginStyle = computed(() => {
const m = this.orgTemplate().margins; const m = this.orgTemplate().margins;

View File

@@ -76,6 +76,7 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
<app-letter-canvas <app-letter-canvas
[brief]="brief()" [brief]="brief()"
[orgTemplate]="orgTemplate()" [orgTemplate]="orgTemplate()"
[logoUrl]="logoUrl()"
[editableRegions]="canEdit() ? 'content' : 'none'" [editableRegions]="canEdit() ? 'content' : 'none'"
[availablePassages]="availablePassages()" [availablePassages]="availablePassages()"
[placeholders]="menu()" [placeholders]="menu()"
@@ -139,6 +140,7 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
export class LetterComposerComponent { export class LetterComposerComponent {
brief = input.required<Brief>(); brief = input.required<Brief>();
orgTemplate = input.required<OrgTemplate>(); orgTemplate = input.required<OrgTemplate>();
logoUrl = input<string | null>(null);
availablePassages = input<readonly LibraryPassage[]>([]); availablePassages = input<readonly LibraryPassage[]>([]);
diagnostics = input<readonly Diagnostic[]>([]); diagnostics = input<readonly Diagnostic[]>([]);
canEdit = input(false); canEdit = input(false);

View File

@@ -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: `
<div class="toolbar">
<label class="field">
<span>{{ subOrgLabel() }}</span>
<select class="form-select" (change)="onSelectSubOrg($event)">
@for (o of subOrgs(); track o.subOrgId) {
<option [value]="o.subOrgId" [selected]="o.subOrgId === selectedSubOrgId()">
{{ o.orgName }}
</option>
}
</select>
</label>
<span class="save" role="status" aria-live="polite">{{ saveText() }}</span>
</div>
<app-letter-canvas
[brief]="sampleBrief()"
[orgTemplate]="draft()"
[logoUrl]="logoUrl()"
editableRegions="template"
(templateEdit)="templateEdit.emit($event)"
/>
<fieldset class="section margins">
<legend>{{ marginsLegend() }}</legend>
@for (edge of edges; track edge) {
<label class="field">
<span>{{ edgeLabel(edge) }}</span>
<input
class="form-control"
type="number"
[min]="MIN"
[max]="MAX"
[value]="draft().margins[edge]"
(input)="onMargin(edge, $event)"
/>
</label>
}
</fieldset>
<section class="section">
<app-heading [level]="3">{{ logoHeading() }}</app-heading>
@if (logoCategory()) {
<app-file-input
inputId="org-logo-input"
[accept]="logoCategory()!.acceptedTypes"
[maxSizeMb]="logoCategory()!.maxSizeMb"
[label]="logoHeading()"
(filesSelected)="logoSelected.emit($event)"
/>
}
@if (logoRejection()) {
<app-alert type="error">{{ logoRejection() }}</app-alert>
}
@if (logoUploads().length) {
<ul class="file-list">
@for (u of logoUploads(); track u.localId) {
<li
app-single-upload
[upload]="u"
[previewUrlFor]="previewUrlFor()"
(remove)="logoRemoved.emit(u.localId)"
(retry)="logoRetry.emit(u.localId)"
></li>
}
</ul>
}
</section>
<section class="section">
<app-heading [level]="3">{{ historyHeading() }}</app-heading>
@if (history().length === 0) {
<p class="published">{{ noHistory() }}</p>
} @else {
<ul class="history-list">
@for (v of history(); track v.version) {
<li class="history-row">
<span>{{ versionLabel() }} {{ v.version }} · {{ v.publishedAt | date: 'longDate' }}</span>
<app-button variant="subtle" [disabled]="busy()" (click)="rollback.emit(v.version)">
{{ rollbackLabel() }}
</app-button>
</li>
}
</ul>
}
</section>
<div class="bar">
<span class="published">{{ publishedLabel() }} {{ publishedVersion() }}</span>
@if (pendingPublish()) {
<app-alert type="warning">{{ impactText() }}</app-alert>
<app-button variant="primary" [disabled]="busy()" (click)="confirmPublish.emit()">
{{ confirmLabel() }}
</app-button>
<app-button variant="subtle" [disabled]="busy()" (click)="cancelPublish.emit()">
{{ cancelLabel() }}
</app-button>
} @else {
<app-button
variant="primary"
[disabled]="!draftValid() || busy()"
(click)="requestPublish.emit()"
>
{{ publishLabel() }}
</app-button>
@if (!draftValid()) {
<span class="published">{{ invalidHint() }}</span>
}
}
<app-button variant="secondary" [disabled]="busy()" (click)="proefbrief.emit()">
{{ proefbriefLabel() }}
</app-button>
</div>
`,
})
export class OrgTemplateEditorComponent {
draft = input.required<OrgTemplate>();
logoUrl = input<string | null>(null);
uploadState = input.required<UploadState>();
subOrgs = input<readonly SubOrgSummary[]>([]);
selectedSubOrgId = input<string | null>(null);
history = input<readonly OrgTemplateVersion[]>([]);
publishedVersion = input(0);
unsentBriefs = input(0);
draftValid = input(false);
busy = input(false);
pendingPublish = input(false);
saveText = input('');
sampleBrief = input<Brief>(SAMPLE_LETTER_BRIEF);
previewUrlFor = input<(documentId: string) => string | undefined>();
selectSubOrg = output<string>();
templateEdit = output<{ field: OrgTemplateTextField; value: string }>();
marginEdit = output<{ edge: keyof Margins; value: number }>();
logoSelected = output<File[]>();
logoRemoved = output<string>();
logoRetry = output<string>();
requestPublish = output<void>();
confirmPublish = output<void>();
cancelPublish = output<void>();
rollback = output<number>();
proefbrief = output<void>();
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.`,
);
}

View File

@@ -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<OrgTemplateEditorComponent> = {
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<OrgTemplateEditorComponent>;
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 },
};

View File

@@ -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: `
<app-page-shell [heading]="heading" [intro]="intro" backLink="/brief">
@if (store.lastError(); as err) {
<app-alert type="error">{{ err }}</app-alert>
}
@if (!access.ready()) {
<!-- wait for /me before deciding — avoids flashing the denial to an admin -->
} @else if (!canEdit()) {
<app-alert type="error">{{ deniedText }}</app-alert>
} @else {
<app-async [data]="store.remoteData()">
<ng-template appAsyncError>
<app-alert type="error">{{ failedText }}</app-alert>
<app-button variant="secondary" (click)="reload()">{{ retryText }}</app-button>
</ng-template>
<ng-template appAsyncLoaded>
@if (store.draft(); as draft) {
<app-org-template-editor
[draft]="draft"
[logoUrl]="store.logoUrl()"
[uploadState]="store.uploadState()"
[subOrgs]="store.subOrgs()"
[selectedSubOrgId]="store.selectedSubOrgId()"
[history]="store.history()"
[publishedVersion]="store.publishedVersion()"
[unsentBriefs]="store.unsentBriefs()"
[draftValid]="store.draftValid()"
[busy]="store.busy()"
[pendingPublish]="store.pendingPublish()"
[saveText]="saveText()"
[previewUrlFor]="previewUrlFor"
(selectSubOrg)="store.selectSubOrg($event)"
(templateEdit)="store.edit({ tag: 'FieldEdited', field: $event.field, value: $event.value })"
(marginEdit)="store.edit({ tag: 'MarginEdited', edge: $event.edge, value: $event.value })"
(logoSelected)="store.onLogoSelected($event)"
(logoRemoved)="store.onLogoRemoved($event)"
(logoRetry)="store.onLogoRetry($event)"
(requestPublish)="store.requestPublish()"
(confirmPublish)="store.confirmPublish()"
(cancelPublish)="store.cancelPublish()"
(rollback)="store.rollback($event)"
(proefbrief)="store.proefbrief()"
/>
}
</ng-template>
</app-async>
}
</app-page-shell>
`,
})
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();
}
}

View File

@@ -14,7 +14,7 @@ import { LibraryPassage } from '@brief/domain/brief';
` `
:host { :host {
display: block; 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: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);
border-radius: var(--rhc-border-radius-md); border-radius: var(--rhc-border-radius-md);
padding: var(--rhc-space-max-md); padding: var(--rhc-space-max-md);

View File

@@ -33,4 +33,11 @@ export class AccessStore {
const rd = this.capabilities(); const rd = this.capabilities();
return rd.tag === 'Success' && rd.value.includes(capability); 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';
});
} }

View File

@@ -38,6 +38,12 @@ export interface XhrUploadHandle {
/** Sentinel rejection so the caller can tell a user-cancel from a real failure. */ /** Sentinel rejection so the caller can tell a user-cancel from a real failure. */
export const UPLOAD_ABORTED = Symbol('upload-aborted'); 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, * Infrastructure: the only place upload HTTP lives. Reads (categories, status,
* delete) go through the NSwag client; the multipart POST is hand-written XHR * 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. * browser opens it. demo-* ids (dev simulation) have no bytes and 404 here.
*/ */
contentUrl(documentId: string): string { contentUrl(documentId: string): string {
return `${environment.apiBaseUrl}/api/v1/uploads/${documentId}/content`; return uploadContentUrl(documentId);
} }
/** /**