import { assertNever } from '@shared/kernel/fp'; import { Margins, OrgTemplate, OrgTemplateAdminView, OrgTemplateVersion } from './org-template'; import { UploadMsg, UploadState, initialUpload, reduceUpload } from '@shared/upload/upload.machine'; /** * The admin org-template editor as one Elm-style machine (WP-26, PRD Brief v2 §5) — * the same idiom as the wizards. The DRAFT org template is form state (edited in * place on the canvas); publish/rollback are effects that come back as `DraftLoaded`. * `dirty` tracks unsaved edits (the store debounce-saves them). The logo upload is * the composable upload sub-machine folded in, exactly like the wizards fold * `reduceUpload` — its `UploadComplete`/`UploadRemoved` also mutate `draft.logoDocumentId`. */ /** The org-identity text fields editable directly on the letter canvas. */ export type OrgTemplateTextField = | 'orgName' | 'returnAddress' | 'footerContact' | 'footerLegal' | 'signatureName' | 'signatureRole' | 'signatureClosing'; export type OrgTemplateState = | { tag: 'loading' } | { tag: 'failed'; reason: string } | { tag: 'loaded'; subOrgId: string; draft: OrgTemplate; publishedVersion: number; history: readonly OrgTemplateVersion[]; unsentBriefs: number; dirty: boolean; /** Logo upload sub-state (single file, `org-logo` category). */ upload: UploadState; }; export const initial: OrgTemplateState = { tag: 'loading' }; export type OrgTemplateMsg = | { tag: 'Loading' } | { tag: 'DraftLoaded'; view: OrgTemplateAdminView } | { tag: 'LoadFailed'; reason: string } | { tag: 'FieldEdited'; field: OrgTemplateTextField; value: string } | { tag: 'MarginEdited'; edge: keyof Margins; value: number } /** Carries the draft that was saved: clears `dirty` only if no edit landed during the round-trip (reference-equal), so a concurrent edit keeps its pending save. */ | { tag: 'DraftSaved'; savedDraft: OrgTemplate } | { tag: 'Upload'; msg: UploadMsg }; /** Edit the loaded draft; a no-op in any non-loaded state (illegal by construction). */ function editDraft(s: OrgTemplateState, f: (draft: OrgTemplate) => OrgTemplate): OrgTemplateState { return s.tag === 'loaded' ? { ...s, draft: f(s.draft), dirty: true } : s; } export function reduce(s: OrgTemplateState, m: OrgTemplateMsg): OrgTemplateState { switch (m.tag) { case 'Loading': return { tag: 'loading' }; case 'LoadFailed': return { tag: 'failed', reason: m.reason }; case 'DraftLoaded': return { tag: 'loaded', subOrgId: m.view.draft.subOrgId, draft: m.view.draft, publishedVersion: m.view.publishedVersion, history: m.view.history, unsentBriefs: m.view.unsentBriefs, dirty: false, // Keep the loaded logo category across sub-org switches (it's the same // `org-logo` category, loaded once); drop only any in-flight/finished uploads. upload: s.tag === 'loaded' ? { ...s.upload, uploads: [], rejections: {} } : initialUpload, }; case 'FieldEdited': return editDraft(s, (d) => ({ ...d, [m.field]: m.value })); case 'MarginEdited': return editDraft(s, (d) => ({ ...d, margins: { ...d.margins, [m.edge]: m.value } })); case 'DraftSaved': return s.tag === 'loaded' && s.draft === m.savedDraft ? { ...s, dirty: false } : s; case 'Upload': { if (s.tag !== 'loaded') return s; const upload = reduceUpload(s.upload, m.msg); // A completed/removed logo upload also updates the draft's logoDocumentId. if (m.msg.type === 'UploadComplete') return { ...s, upload, draft: { ...s.draft, logoDocumentId: m.msg.documentId }, dirty: true, }; if (m.msg.type === 'UploadRemoved') { const { logoDocumentId: _dropped, ...rest } = s.draft; return { ...s, upload, draft: rest, dirty: true }; } return { ...s, upload }; } default: return assertNever(m); } }