`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>
103 lines
4.0 KiB
TypeScript
103 lines
4.0 KiB
TypeScript
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);
|
|
}
|
|
}
|