`npm run format:check` (a CI gate) had drifted red across 44 files — pre-existing files plus recently-added ones committed without formatting. Ran `prettier --write .`; no logic changes. Also regenerates documentation.json (compodoc reflects the reformatted component sources). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
300 lines
11 KiB
TypeScript
300 lines
11 KiB
TypeScript
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';
|
|
import { PendingSave, registerPendingSave } from '@shared/application/pending-saves';
|
|
|
|
/** 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 implements PendingSave {
|
|
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() ?? [],
|
|
});
|
|
});
|
|
// Flush a pending debounced edit before navigation/unload (see pending-saves.ts).
|
|
registerPendingSave(this);
|
|
}
|
|
|
|
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.saveTimer = undefined;
|
|
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.
|
|
// Null the handle when it fires so `hasPendingSave()` reflects "a write is still owed".
|
|
this.saveTimer = setTimeout(() => {
|
|
this.saveTimer = undefined;
|
|
void this.flushSave();
|
|
}, 600);
|
|
}
|
|
|
|
/** True while a debounced edit hasn't been written yet (PendingSave). */
|
|
hasPendingSave = () => this.saveTimer !== undefined;
|
|
/** Flush a pending debounced save now and await it; no-op when nothing is pending. */
|
|
async flushPending() {
|
|
if (this.saveTimer === undefined) return;
|
|
clearTimeout(this.saveTimer);
|
|
this.saveTimer = undefined;
|
|
await this.flushSave();
|
|
}
|
|
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);
|
|
this.saveTimer = undefined;
|
|
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);
|
|
this.saveTimer = undefined;
|
|
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);
|
|
this.saveTimer = undefined;
|
|
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();
|
|
}
|
|
}
|