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; 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(initial, reduce); readonly model = this.store.model; readonly subOrgs = signal([]); readonly selectedSubOrgId = signal(null); private actionState = signal({ 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({ tag: 'Idle' }); /** The publish impact-confirm gate (PRD §7h: show N affected letters before POST). */ readonly pendingPublish = signal(false); readonly remoteData = computed>(() => { 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(() => { const s = this.model(); return s.tag === 'loaded' ? s : null; }); readonly draft = computed(() => 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(() => { 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(); 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; 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(); } }