feat(fp): WP-26 — admin org-template editor
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:
eho
2026-07-06 08:22:55 +02:00
co-authored by Claude Opus 4.8
parent 1bb9383344
commit f6c837f281
20 changed files with 5530 additions and 182 deletions
+1
View File
@@ -50,6 +50,7 @@ import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-compos
<app-letter-composer
[brief]="s.brief"
[orgTemplate]="orgTemplate"
[logoUrl]="store.logoUrl()"
[availablePassages]="s.availablePassages"
[diagnostics]="store.diagnostics()"
[canEdit]="store.canEdit()"
@@ -18,6 +18,7 @@ import { formatDatumNl } from '@shared/kernel/datum';
import { Paragraph } from '@shared/kernel/rich-text';
import { Brief, LetterBlock, LibraryPassage } from '@brief/domain/brief';
import { OrgTemplate } from '@brief/domain/org-template';
import { OrgTemplateTextField } from '@brief/domain/org-template.machine';
import { Diagnostic } from '@brief/domain/placeholders';
import { BriefMsg } from '@brief/domain/brief.machine';
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). */
.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-radius: var(--rhc-border-radius-md);
padding: var(--rhc-space-max-lg);
overflow-x: auto;
}
.surface .letter {
box-shadow: 0 1px 4px rgb(0 0 0 / 0.15);
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. */
.from-template {
background: var(--rhc-color-grijs-100, #f5f5f5);
outline: 2mm solid var(--rhc-color-grijs-100, #f5f5f5);
background: var(--rhc-color-grijs-100);
outline: 2mm solid var(--rhc-color-grijs-100);
}
.from-template-caption {
font-size: 7.5pt;
/* grijs-700, not foreground-subtle: subtle misses AA contrast on the tint. */
color: var(--rhc-color-grijs-700, #334155);
color: var(--rhc-color-grijs-700);
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: `
@@ -137,8 +159,27 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
@if (tintTemplate()) {
<p class="from-template-caption">{{ fromTemplateCaption() }}</p>
}
<p class="org-wordmark">{{ orgTemplate().orgName }}</p>
<address class="return-address">{{ orgTemplate().returnAddress }}</address>
@if (logoUrl()) {
<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>
<dl class="reference">
<div>
@@ -209,14 +250,51 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
</div>
<div class="letter__signature" [class.from-template]="tintTemplate()">
<p>{{ orgTemplate().signatureClosing }}</p>
<p class="signature-name">{{ orgTemplate().signatureName }}</p>
<p>{{ orgTemplate().signatureRole }}</p>
@if (editing()) {
<input
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 class="letter__footer" [class.from-template]="tintTemplate()">
<div class="footer-contact">{{ orgTemplate().footerContact }}</div>
<div class="footer-legal">{{ orgTemplate().footerLegal }}</div>
@if (editing()) {
<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>
@for (top of pageBreaks(); track $index) {
@@ -238,7 +316,11 @@ export class LetterCanvasComponent {
placeholders = input<readonly PlaceholderOption[]>([]);
diagnostics = input<readonly Diagnostic[]>([]);
zoom = input(1);
/** The org logo's content URL (letterhead), or null when none is set. */
logoUrl = input<string | null>(null);
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`);
hideSampleLabel = input($localize`:@@brief.preview.hideSample:Testwaarden verbergen`);
@@ -253,6 +335,14 @@ export class LetterCanvasComponent {
);
referenceLabel = input($localize`:@@brief.canvas.reference:Ons kenmerk`);
dateLabel = input($localize`:@@brief.canvas.date:Datum`);
logoAlt = input($localize`:@@brief.canvas.logoAlt:Logo van de organisatie`);
orgNameLabel = input($localize`:@@brief.canvas.orgName:Organisatienaam`);
returnAddressLabel = input($localize`:@@brief.canvas.returnAddress:Retouradres`);
signatureClosingLabel = input($localize`:@@brief.canvas.signatureClosing:Afsluiting`);
signatureNameLabel = input($localize`:@@brief.canvas.signatureName:Naam ondertekenaar`);
signatureRoleLabel = input($localize`:@@brief.canvas.signatureRole:Functie ondertekenaar`);
footerContactLabel = input($localize`:@@brief.canvas.footerContact:Contactgegevens (voettekst)`);
footerLegalLabel = input($localize`:@@brief.canvas.footerLegal:Juridische voettekst`);
protected showSample = signal(false);
protected letterDate = formatDatumNl(new Date());
@@ -260,6 +350,12 @@ export class LetterCanvasComponent {
/** The letterhead/signature/footer are tinted "not yours" only while composing —
in 'none' the whole surface is read-only, in 'template' they ARE the editable focus. */
protected tintTemplate = computed(() => this.editableRegions() === 'content');
/** Admin edit-in-place: the org-identity regions render as controls. */
protected editing = computed(() => this.editableRegions() === 'template');
protected emitEdit(field: OrgTemplateTextField, event: Event) {
this.templateEdit.emit({ field, value: (event.target as HTMLInputElement | HTMLTextAreaElement).value });
}
protected marginStyle = computed(() => {
const m = this.orgTemplate().margins;
@@ -76,6 +76,7 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
<app-letter-canvas
[brief]="brief()"
[orgTemplate]="orgTemplate()"
[logoUrl]="logoUrl()"
[editableRegions]="canEdit() ? 'content' : 'none'"
[availablePassages]="availablePassages()"
[placeholders]="menu()"
@@ -139,6 +140,7 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
export class LetterComposerComponent {
brief = input.required<Brief>();
orgTemplate = input.required<OrgTemplate>();
logoUrl = input<string | null>(null);
availablePassages = input<readonly LibraryPassage[]>([]);
diagnostics = input<readonly Diagnostic[]>([]);
canEdit = input(false);
@@ -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.`,
);
}
@@ -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 },
};
+128
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();
}
}
@@ -14,7 +14,7 @@ import { LibraryPassage } from '@brief/domain/brief';
`
:host {
display: block;
background: var(--rhc-color-wit, #fff);
background: var(--rhc-color-wit);
border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);
border-radius: var(--rhc-border-radius-md);
padding: var(--rhc-space-max-md);