feat(fp): WP-26 — admin org-template editor
CI / codeql (javascript-typescript) (push) Failing after 1m19s
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 / api-client-drift (push) Successful in 1m40s
CI / e2e (push) Failing after 3h11m33s
CI / codeql (javascript-typescript) (push) Failing after 1m19s
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 / 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:
@@ -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 },
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user