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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user