import { assertNever } from '@shared/kernel/fp'; import { Margins, OrgTemplate, OrgTemplateAdminView, OrgTemplateVersion } from './org-template'; import { UploadMsg, UploadState, initialUpload, reduceUpload } from '@shared/domain/upload.machine'; /** * The admin org-template editor as one Elm-style machine (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`. * * `action` (RD-13) owns the one-shot action lifecycle AND the publish impact-confirm * gate as ONE four-variant union, replacing two independent store-level signals * (`actionState` + `pendingPublish`). Before RD-13, `pendingPublish === true && busy * === true` was representable and meaningless — the confirm dialog could show while a * publish was already in flight. A single field with one tag at a time makes that * combination unrepresentable. */ /** The org-identity text fields editable directly on the letter canvas. */ export type OrgTemplateTextField = | 'orgName' | 'returnAddress' | 'footerContact' | 'footerLegal' | 'signatureName' | 'signatureRole' | 'signatureClosing'; /** The one-shot action lifecycle (publish/rollback/proefbrief), plus the publish impact-confirm gate, owned by the reducer instead of two independent store-level signals (RD-13). `ConfirmingPublish` is a variant of this SAME union, so "confirming a publish while one is already in flight" is unrepresentable — no state can ever carry both at once. */ export type OrgTemplateActionState = | { tag: 'Idle' } | { tag: 'ConfirmingPublish' } | { tag: 'Busy' } | { tag: 'Failed'; error: string }; 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; action: OrgTemplateActionState; }; 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 } | { tag: 'PublishRequested' } // opens the publish impact-confirm gate | { tag: 'PublishCancelled' } // closes it without publishing | { tag: 'ActionStarted' } // a one-shot action (publish/rollback/proefbrief) began | { tag: 'ActionFinished' } // it completed successfully | { tag: 'ActionFailed'; error: string }; // it failed, carrying the message to show /** 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, // A fresh load clears a stale action error rather than letting it outlive // the reload (RD-13, same as brief's RD-12). action: { tag: 'Idle' }, }; 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 }; } // The action lifecycle (RD-13): a no-op unless a template is loaded, since there // is nothing to attach the action state to otherwise. `ConfirmingPublish` and // `Busy` are variants of one field, so ActionStarted overwriting it to `Busy` is // what makes the two mutually exclusive by construction — not by convention. case 'PublishRequested': return s.tag === 'Loaded' ? { ...s, action: { tag: 'ConfirmingPublish' } } : s; case 'PublishCancelled': return s.tag === 'Loaded' ? { ...s, action: { tag: 'Idle' } } : s; case 'ActionStarted': return s.tag === 'Loaded' ? { ...s, action: { tag: 'Busy' } } : s; case 'ActionFinished': return s.tag === 'Loaded' ? { ...s, action: { tag: 'Idle' } } : s; case 'ActionFailed': return s.tag === 'Loaded' ? { ...s, action: { tag: 'Failed', error: m.error } } : s; default: return assertNever(m); } }