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