diff --git a/scripts/governance-check.mjs b/scripts/governance-check.mjs
index 41bb1de9..9e388ef4 100644
--- a/scripts/governance-check.mjs
+++ b/scripts/governance-check.mjs
@@ -164,11 +164,12 @@ function maintainabilityGate(files) {
if (files.length === 0) {
return { skipped: true, reason: "no changed governed TS/TSX files" };
}
- const eslintBin = path.join(ROOT, "node_modules", ".bin", "eslint");
+ const eslintJs = path.join(ROOT, "node_modules", "eslint", "bin", "eslint.js");
const ruleArgs = MAINTAINABILITY_RULES.flatMap((rule) => ["--rule", rule]);
const result = spawnSync(
- eslintBin,
+ process.execPath,
[
+ eslintJs,
...files,
...ruleArgs,
"--max-warnings=0",
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-details-section.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-details-section.tsx
new file mode 100644
index 00000000..a1ed784d
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-details-section.tsx
@@ -0,0 +1,65 @@
+import { Building2, MapPin, Users, Wrench } from "lucide-react";
+import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+import { CompDocDetailRow } from "./comp-doc-detail-row";
+import { CompDocEditableRow } from "./comp-doc-editable-row";
+
+type CompDocDetailsSectionProps = {
+ row: WorkOrderTableRow;
+ site: CompDocSiteInfo;
+ editDateCompleted: string;
+ onDateCompletedChange: (value: string) => void;
+ amazonRep: string;
+ seaRep: string;
+ isGenerated: boolean;
+};
+
+export function CompDocDetailsSection({
+ row,
+ site,
+ editDateCompleted,
+ onDateCompletedChange,
+ amazonRep,
+ seaRep,
+ isGenerated,
+}: CompDocDetailsSectionProps) {
+ return (
+
+
+ Work Order Details
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-draft.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-draft.tsx
new file mode 100644
index 00000000..fad1529f
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-draft.tsx
@@ -0,0 +1,50 @@
+import { Ban, Save, Sparkles } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { COMP_DOC_FOOTER_BTN_SX } from "./comp-doc-footer-btn-sx";
+
+type CompDocDialogFooterDraftProps = {
+ onMarkNotRequired: () => void;
+ onSaveDraft: () => void;
+ onGenerate: () => void;
+};
+
+export function CompDocDialogFooterDraft({
+ onMarkNotRequired,
+ onSaveDraft,
+ onGenerate,
+}: CompDocDialogFooterDraftProps) {
+ return (
+ <>
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-generated.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-generated.tsx
new file mode 100644
index 00000000..91b9792c
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-generated.tsx
@@ -0,0 +1,35 @@
+import { CheckCircle2, Download } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { COMP_DOC_FOOTER_BTN_SX } from "./comp-doc-footer-btn-sx";
+
+type CompDocDialogFooterGeneratedProps = {
+ generatedAt: string;
+ onDownloadPdf: () => void;
+};
+
+export function CompDocDialogFooterGenerated({
+ generatedAt,
+ onDownloadPdf,
+}: CompDocDialogFooterGeneratedProps) {
+ return (
+ <>
+
+ Generated {generatedAt}
+
+
+
+
+ >
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-not-required.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-not-required.tsx
new file mode 100644
index 00000000..da1976bb
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-not-required.tsx
@@ -0,0 +1,47 @@
+import { Ban, Sparkles, Undo2 } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { COMP_DOC_FOOTER_BTN_SX } from "./comp-doc-footer-btn-sx";
+
+type CompDocDialogFooterNotRequiredProps = {
+ onUndo: () => void;
+};
+
+export function CompDocDialogFooterNotRequired({ onUndo }: CompDocDialogFooterNotRequiredProps) {
+ return (
+ <>
+
+ Marked{" "}
+ not required for this work order
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer.tsx
new file mode 100644
index 00000000..4e35987c
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer.tsx
@@ -0,0 +1,47 @@
+import type { DocState } from "@/domain/work-orders/types/completion-doc";
+import { CompDocDialogFooterDraft } from "./comp-doc-dialog-footer-draft";
+import { CompDocDialogFooterGenerated } from "./comp-doc-dialog-footer-generated";
+import { CompDocDialogFooterNotRequired } from "./comp-doc-dialog-footer-not-required";
+
+type CompDocDialogFooterProps = {
+ docState: DocState;
+ generatedAt: string;
+ onMarkNotRequired: () => void;
+ onUndo: () => void;
+ onSaveDraft: () => void;
+ onGenerate: () => void;
+ onDownloadPdf: () => void;
+};
+
+export function CompDocDialogFooter({
+ docState,
+ generatedAt,
+ onMarkNotRequired,
+ onUndo,
+ onSaveDraft,
+ onGenerate,
+ onDownloadPdf,
+}: CompDocDialogFooterProps) {
+ return (
+
+ {docState === "draft" && (
+
+ )}
+ {docState === "generated" && (
+
+ )}
+ {docState === "not-required" && }
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-header.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-header.tsx
new file mode 100644
index 00000000..d014c7e4
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-header.tsx
@@ -0,0 +1,94 @@
+import { FileText, X } from "lucide-react";
+import type { CompletionDocStateConfig } from "@/domain/work-orders/types/completion-doc";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+import { serviceTitle } from "@/domain/work-orders/utils/completion-doc-service-title";
+
+type CompDocDialogHeaderProps = {
+ row: WorkOrderTableRow;
+ cfg: CompletionDocStateConfig;
+ onClose: () => void;
+};
+
+export function CompDocDialogHeader({ row, cfg, onClose }: CompDocDialogHeaderProps) {
+ return (
+
+
+
+
+
+ Completion Document
+
+
+ WO{" "}
+
+ #{row.woNumber}
+
+ {" · "}
+ {serviceTitle(row)}
+
+
+
+
+
+
+ {cfg.pillLabel}
+
+
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog.tsx
index 833f9047..6415f3fd 100644
--- a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog.tsx
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog.tsx
@@ -1,39 +1,15 @@
-import { useEffect, useMemo, useState, type MouseEvent } from "react";
+import type { MouseEvent } from "react";
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
-import { Ban, CheckCircle2, Download, FileText, Save, Sparkles, Undo2, X } from "lucide-react";
-import { toast } from "react-toastify";
-import { Button } from "@/components/ui/button";
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
import { Text } from "@/components/ui/text";
-import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
-import type {
- CompDocData,
- CompDocSaveStatus,
- DocFields,
- DocState,
-} from "@/domain/work-orders/types/completion-doc";
+import type { CompDocData, CompDocSaveStatus } from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
-import { getMockWoWizardLocationDetail } from "@/domain/work-orders/mocks/wo-wizard-mock-data";
-import { buildDocHtml, printDoc } from "@/domain/work-orders/utils/completion-doc-html";
-import { resolveCompDocSite } from "@/domain/work-orders/utils/completion-doc-site";
-import {
- DOC_STATE_CFG,
- docStateOf,
- fmtDocNow,
-} from "@/domain/work-orders/utils/completion-doc-state";
-import { serviceTitle } from "@/domain/work-orders/utils/completion-doc-service-title";
+import { useCompDocDialog } from "@/app/(protected)/workorders/_hooks/use-comp-doc-dialog";
+import { CompDocDialogFooter } from "./comp-doc-dialog-footer";
+import { CompDocDialogHeader } from "./comp-doc-dialog-header";
import { CompDocPreview } from "./comp-doc-preview";
import { CompDocSidebar } from "./comp-doc-sidebar";
-const FOOTER_BTN_SX = {
- minHeight: 28,
- height: 28,
- fontSize: 12,
- px: 1.25,
- py: 0.5,
- gap: 0.75,
-} as const;
-
type CompDocDialogProps = {
row: WorkOrderTableRow;
open: boolean;
@@ -51,102 +27,25 @@ export function CompDocDialog({
onSave,
onDateCompletedChange,
}: CompDocDialogProps) {
- const locId = String(row.locationId || "");
- const isMockLocation = locId.startsWith("mock-");
- const { data: apiLocationDetail } = useLocationDetail(
- isMockLocation || !locId ? undefined : locId,
- );
- const locationDetail = isMockLocation ? getMockWoWizardLocationDetail(locId) : apiLocationDetail;
-
- const site = useMemo(() => resolveCompDocSite(row, locationDetail), [row, locationDetail]);
-
- const [notes, setNotes] = useState("");
- const [docState, setDocState] = useState("draft");
- const [generatedAt, setGeneratedAt] = useState("");
- const [editDateCompleted, setEditDateCompleted] = useState("");
-
- useEffect(() => {
- if (!open) return;
- setNotes(data?.notes ?? "");
- setDocState(docStateOf(row.docStatus));
- setGeneratedAt(data?.generatedAt ?? "");
- setEditDateCompleted(row.completedDate || "");
- }, [open, row.id, row.docStatus, row.completedDate, data?.notes, data?.generatedAt]);
-
- const amazonRep = row.pocName || "";
- const seaRep = row.tech || "";
- const cfg = DOC_STATE_CFG[docState];
- const isNotRequired = docState === "not-required";
- const isGenerated = docState === "generated";
-
- const docFields: DocFields = {
- street: site.street,
- city: site.city,
- state: site.state,
- zip: site.zip,
- dateCompleted: editDateCompleted,
+ const {
+ site,
+ notes,
+ setNotes,
+ docState,
+ generatedAt,
+ editDateCompleted,
+ setEditDateCompleted,
amazonRep,
seaRep,
- };
-
- const persist = (extra: Partial, status: CompDocSaveStatus) => {
- onSave({ notes, generatedAt, ...extra }, status);
- };
-
- function handleMarkNotRequired() {
- setDocState("not-required");
- persist({}, "NN");
- }
-
- function handleUndo() {
- setDocState("draft");
- persist({}, "No");
- }
-
- function handleSaveDraft() {
- persist({}, "No");
- if (editDateCompleted !== (row.completedDate || "")) {
- onDateCompletedChange?.(editDateCompleted);
- }
- toast(
-
- Draft saved
-
,
- {
- position: "bottom-center",
- autoClose: 3000,
- hideProgressBar: true,
- closeButton: false,
- icon: false,
- className: "!min-h-0 !bg-transparent !p-0 !shadow-none",
- },
- );
- }
-
- function handleGenerate() {
- const ts = fmtDocNow();
- setGeneratedAt(ts);
- setDocState("generated");
- persist({ generatedAt: ts }, "Yes");
- if (editDateCompleted !== (row.completedDate || "")) {
- onDateCompletedChange?.(editDateCompleted);
- }
- }
-
- function handleDownloadPdf() {
- printDoc(buildDocHtml(row, { notes, generatedAt }, docFields));
- }
+ cfg,
+ isNotRequired,
+ isGenerated,
+ handleMarkNotRequired,
+ handleUndo,
+ handleSaveDraft,
+ handleGenerate,
+ handleDownloadPdf,
+ } = useCompDocDialog({ row, open, data, onSave, onDateCompletedChange });
return (
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-draft-toast.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-draft-toast.tsx
new file mode 100644
index 00000000..910b7227
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-draft-toast.tsx
@@ -0,0 +1,29 @@
+import { CheckCircle2 } from "lucide-react";
+import { toast } from "react-toastify";
+
+export function showCompDocDraftSavedToast() {
+ toast(
+
+ Draft saved
+
,
+ {
+ position: "bottom-center",
+ autoClose: 3000,
+ hideProgressBar: true,
+ closeButton: false,
+ icon: false,
+ className: "!min-h-0 !bg-transparent !p-0 !shadow-none",
+ },
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-editable-row.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-editable-row.tsx
new file mode 100644
index 00000000..8e9261e2
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-editable-row.tsx
@@ -0,0 +1,54 @@
+import type { CSSProperties } from "react";
+
+const COMP_DOC_INPUT_CLS = "w-full rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-1";
+
+const COMP_DOC_INPUT_STYLE: CSSProperties = {
+ fontSize: 13,
+ border: "1px solid var(--color-border)",
+ color: "var(--color-text-primary)",
+ background: "var(--color-bg-surface)",
+};
+
+const COMP_DOC_INPUT_DISABLED_STYLE: CSSProperties = {
+ opacity: 0.6,
+ cursor: "default",
+ pointerEvents: "none",
+};
+
+type CompDocEditableRowProps = {
+ label: string;
+ value: string;
+ onChange: (value: string) => void;
+ type?: string;
+ placeholder?: string;
+ disabled?: boolean;
+};
+
+export function CompDocEditableRow({
+ label,
+ value,
+ onChange,
+ type = "text",
+ placeholder = "",
+ disabled = false,
+}: CompDocEditableRowProps) {
+ return (
+
+
+ {label}
+
+
onChange(e.target.value)}
+ readOnly={disabled}
+ placeholder={placeholder}
+ className={COMP_DOC_INPUT_CLS}
+ style={{
+ ...COMP_DOC_INPUT_STYLE,
+ ...(disabled ? COMP_DOC_INPUT_DISABLED_STYLE : {}),
+ }}
+ />
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-footer-btn-sx.ts b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-footer-btn-sx.ts
new file mode 100644
index 00000000..f5c0f2ba
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-footer-btn-sx.ts
@@ -0,0 +1,8 @@
+export const COMP_DOC_FOOTER_BTN_SX = {
+ minHeight: 28,
+ height: 28,
+ fontSize: 12,
+ px: 1.25,
+ py: 0.5,
+ gap: 0.75,
+} as const;
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-not-required-stamp.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-not-required-stamp.tsx
new file mode 100644
index 00000000..ab3cbf04
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-not-required-stamp.tsx
@@ -0,0 +1,25 @@
+import { Ban } from "lucide-react";
+
+export function CompDocNotRequiredStamp() {
+ return (
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-notes-field.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-notes-field.tsx
new file mode 100644
index 00000000..f6deb5e5
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-notes-field.tsx
@@ -0,0 +1,50 @@
+type CompDocNotesFieldProps = {
+ notes: string;
+ onNotesChange: (value: string) => void;
+ isGenerated: boolean;
+};
+
+export function CompDocNotesField({ notes, onNotesChange, isGenerated }: CompDocNotesFieldProps) {
+ return (
+
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-badge.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-badge.tsx
new file mode 100644
index 00000000..9df6c499
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-badge.tsx
@@ -0,0 +1,32 @@
+import { CheckCircle2, FileText } from "lucide-react";
+
+type CompDocPreviewBadgeProps = {
+ isGenerated: boolean;
+};
+
+export function CompDocPreviewBadge({ isGenerated }: CompDocPreviewBadgeProps) {
+ return (
+
+
+ {isGenerated ? (
+
+ ) : (
+
+ )}
+ {isGenerated ? "Generated document" : "Preview — not yet generated"}
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-header.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-header.tsx
new file mode 100644
index 00000000..3fdeb30a
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-header.tsx
@@ -0,0 +1,36 @@
+import { Building2, Mail, MapPin, Phone, Printer } from "lucide-react";
+import { SEAHAVEN_HEADER } from "@/domain/work-orders/utils/completion-doc-html";
+import { CompDocCoLine } from "./comp-doc-co-line";
+
+export function CompDocPreviewHeader() {
+ return (
+ <>
+
+
+

+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-procedure.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-procedure.tsx
new file mode 100644
index 00000000..871468fc
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-procedure.tsx
@@ -0,0 +1,87 @@
+import { Text } from "@/components/ui/text";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+import { procedureFor, SAFETY_BULLETS } from "@/domain/work-orders/utils/completion-doc-procedures";
+import { CompDocProcedureList } from "./comp-doc-procedure-list";
+
+type CompDocPreviewProcedureProps = {
+ row: WorkOrderTableRow;
+ notes: string;
+};
+
+export function CompDocPreviewProcedure({ row, notes }: CompDocPreviewProcedureProps) {
+ const proc = procedureFor(row.pm);
+
+ return (
+ <>
+
+ {SAFETY_BULLETS.map((b, i) => (
+ -
+ {b}
+
+ ))}
+
+
+
+ {proc.heading}
+
+
+ {proc.intro}
+
+
+
+ {notes.length > 0 && (
+ <>
+
+ PM Comments
+
+
+ {notes}
+
+ >
+ )}
+ >
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-summary.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-summary.tsx
new file mode 100644
index 00000000..c9716b8d
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-summary.tsx
@@ -0,0 +1,96 @@
+import { CheckCircle2 } from "lucide-react";
+import { Text } from "@/components/ui/text";
+import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+import { serviceTitle } from "@/domain/work-orders/utils/completion-doc-service-title";
+
+type CompDocPreviewSummaryProps = {
+ row: WorkOrderTableRow;
+ site: CompDocSiteInfo;
+ generatedAt: string;
+ isGenerated: boolean;
+};
+
+export function CompDocPreviewSummary({
+ row,
+ site,
+ generatedAt,
+ isGenerated,
+}: CompDocPreviewSummaryProps) {
+ return (
+ <>
+ {isGenerated && (
+
+ Generated · {generatedAt} · v1
+
+ )}
+
+
+ {serviceTitle(row).toUpperCase()}
+
+
+ WO# {row.woNumber}
+
+
+
+
+ Site ID — {row.site}
+
+
+ Amazon.com Services LLC {row.site}
+
+
+ {site.street},
+
+
+ {site.label}
+
+
+
+
+ Vendor / Technician:
+ {row.tech || "—"}
+ {row.company ? ` · ${row.company}` : ""}
+
+ >
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview.tsx
index 9769b8b4..64e167bc 100644
--- a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview.tsx
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview.tsx
@@ -1,12 +1,10 @@
-import { Ban, Building2, CheckCircle2, FileText, Mail, MapPin, Phone, Printer } from "lucide-react";
-import { Text } from "@/components/ui/text";
import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
-import { SEAHAVEN_HEADER } from "@/domain/work-orders/utils/completion-doc-html";
-import { procedureFor, SAFETY_BULLETS } from "@/domain/work-orders/utils/completion-doc-procedures";
-import { serviceTitle } from "@/domain/work-orders/utils/completion-doc-service-title";
-import { CompDocCoLine } from "./comp-doc-co-line";
-import { CompDocProcedureList } from "./comp-doc-procedure-list";
+import { CompDocNotRequiredStamp } from "./comp-doc-not-required-stamp";
+import { CompDocPreviewBadge } from "./comp-doc-preview-badge";
+import { CompDocPreviewHeader } from "./comp-doc-preview-header";
+import { CompDocPreviewProcedure } from "./comp-doc-preview-procedure";
+import { CompDocPreviewSummary } from "./comp-doc-preview-summary";
type CompDocPreviewProps = {
row: WorkOrderTableRow;
@@ -25,8 +23,6 @@ export function CompDocPreview({
isGenerated,
isNotRequired,
}: CompDocPreviewProps) {
- const proc = procedureFor(row.pm);
-
return (
-
-
- {isGenerated ? (
-
- ) : (
-
- )}
- {isGenerated ? "Generated document" : "Preview — not yet generated"}
-
-
+
-
-
-

-
-
-
-
-
-
-
-
-
-
+
-
- {isGenerated && (
-
- Generated · {generatedAt} · v1
-
- )}
-
-
- {serviceTitle(row).toUpperCase()}
-
-
- WO# {row.woNumber}
-
-
-
-
- Site ID — {row.site}
-
-
- Amazon.com Services LLC {row.site}
-
-
- {site.street},
-
-
- {site.label}
-
-
-
-
- Vendor / Technician:
- {row.tech || "—"}
- {row.company ? ` · ${row.company}` : ""}
-
-
-
- {SAFETY_BULLETS.map((b, i) => (
- -
- {b}
-
- ))}
-
-
-
- {proc.heading}
-
-
- {proc.intro}
-
-
-
- {notes.length > 0 && (
- <>
-
- PM Comments
-
-
- {notes}
-
- >
- )}
+
- {isNotRequired && (
-
- )}
+ {isNotRequired && }
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-sidebar.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-sidebar.tsx
index 8e3b2c47..1da580c2 100644
--- a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-sidebar.tsx
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-sidebar.tsx
@@ -1,21 +1,8 @@
-import type { CSSProperties } from "react";
-import { Building2, MapPin, Users, Wrench } from "lucide-react";
import type { CompDocSiteInfo, DocState } from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
-import {
- DOC_STATE_CFG,
- docStatusCardDescription,
-} from "@/domain/work-orders/utils/completion-doc-state";
-import { CompDocDetailRow } from "./comp-doc-detail-row";
-
-const COMP_DOC_INPUT_CLS = "w-full rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-1";
-
-const COMP_DOC_INPUT_STYLE: CSSProperties = {
- fontSize: 13,
- border: "1px solid var(--color-border)",
- color: "var(--color-text-primary)",
- background: "var(--color-bg-surface)",
-};
+import { CompDocDetailsSection } from "./comp-doc-details-section";
+import { CompDocNotesField } from "./comp-doc-notes-field";
+import { CompDocStatusCard } from "./comp-doc-status-card";
type CompDocSidebarProps = {
row: WorkOrderTableRow;
@@ -44,163 +31,23 @@ export function CompDocSidebar({
onNotesChange,
onDateCompletedChange,
}: CompDocSidebarProps) {
- const cfg = DOC_STATE_CFG[docState];
- const StateIcon = cfg.icon;
-
- const editRow = (
- label: string,
- value: string,
- onChange: (v: string) => void,
- type = "text",
- placeholder = "",
- ) => (
-
-
- {label}
-
-
onChange(e.target.value)}
- readOnly={isGenerated}
- placeholder={placeholder}
- className={COMP_DOC_INPUT_CLS}
- style={{
- ...COMP_DOC_INPUT_STYLE,
- ...(isGenerated
- ? {
- opacity: 0.6,
- cursor: "default",
- pointerEvents: "none" as const,
- }
- : {}),
- }}
- />
-
- );
-
return (
-
-
-
-
-
-
- {cfg.cardTitle}
-
-
- {docStatusCardDescription(docState, generatedAt)}
-
-
-
-
-
-
- Work Order Details
-
-
-
-
-
- {editRow("Date Work Completed", editDateCompleted, onDateCompletedChange, "date")}
-
-
-
-
-
-
-
-
+
+
+
);
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-status-card.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-status-card.tsx
new file mode 100644
index 00000000..b2aaf7d7
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-status-card.tsx
@@ -0,0 +1,61 @@
+import type { DocState } from "@/domain/work-orders/types/completion-doc";
+import {
+ DOC_STATE_CFG,
+ docStatusCardDescription,
+} from "@/domain/work-orders/utils/completion-doc-state";
+
+type CompDocStatusCardProps = {
+ docState: DocState;
+ generatedAt: string;
+};
+
+export function CompDocStatusCard({ docState, generatedAt }: CompDocStatusCardProps) {
+ const cfg = DOC_STATE_CFG[docState];
+ const StateIcon = cfg.icon;
+
+ return (
+
+
+
+
+
+
+ {cfg.cardTitle}
+
+
+ {docStatusCardDescription(docState, generatedAt)}
+
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_hooks/use-comp-doc-dialog.ts b/src/app/(protected)/workorders/_hooks/use-comp-doc-dialog.ts
new file mode 100644
index 00000000..fb3013e5
--- /dev/null
+++ b/src/app/(protected)/workorders/_hooks/use-comp-doc-dialog.ts
@@ -0,0 +1,129 @@
+import { useEffect, useMemo, useState } from "react";
+import { showCompDocDraftSavedToast } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-draft-toast";
+import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
+import type {
+ CompDocData,
+ CompDocSaveStatus,
+ DocFields,
+ DocState,
+} from "@/domain/work-orders/types/completion-doc";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+import { getMockWoWizardLocationDetail } from "@/domain/work-orders/mocks/wo-wizard-mock-data";
+import { buildDocHtml, printDoc } from "@/domain/work-orders/utils/completion-doc-html";
+import { resolveCompDocSite } from "@/domain/work-orders/utils/completion-doc-site";
+import {
+ DOC_STATE_CFG,
+ docStateOf,
+ fmtDocNow,
+} from "@/domain/work-orders/utils/completion-doc-state";
+
+type UseCompDocDialogParams = {
+ row: WorkOrderTableRow;
+ open: boolean;
+ data?: CompDocData;
+ onSave: (data: CompDocData, status: CompDocSaveStatus) => void;
+ onDateCompletedChange?: (date: string) => void;
+};
+
+export function useCompDocDialog({
+ row,
+ open,
+ data,
+ onSave,
+ onDateCompletedChange,
+}: UseCompDocDialogParams) {
+ const locId = String(row.locationId || "");
+ const isMockLocation = locId.startsWith("mock-");
+ const { data: apiLocationDetail } = useLocationDetail(
+ isMockLocation || !locId ? undefined : locId,
+ );
+ const locationDetail = isMockLocation ? getMockWoWizardLocationDetail(locId) : apiLocationDetail;
+ const site = useMemo(() => resolveCompDocSite(row, locationDetail), [row, locationDetail]);
+
+ const [notes, setNotes] = useState("");
+ const [docState, setDocState] = useState("draft");
+ const [generatedAt, setGeneratedAt] = useState("");
+ const [editDateCompleted, setEditDateCompleted] = useState("");
+
+ useEffect(() => {
+ if (!open) return;
+ setNotes(data?.notes ?? "");
+ setDocState(docStateOf(row.docStatus));
+ setGeneratedAt(data?.generatedAt ?? "");
+ setEditDateCompleted(row.completedDate || "");
+ }, [open, row.id, row.docStatus, row.completedDate, data?.notes, data?.generatedAt]);
+
+ const amazonRep = row.pocName || "";
+ const seaRep = row.tech || "";
+ const cfg = DOC_STATE_CFG[docState];
+ const isNotRequired = docState === "not-required";
+ const isGenerated = docState === "generated";
+
+ const docFields: DocFields = {
+ street: site.street,
+ city: site.city,
+ state: site.state,
+ zip: site.zip,
+ dateCompleted: editDateCompleted,
+ amazonRep,
+ seaRep,
+ };
+
+ function persist(extra: Partial, status: CompDocSaveStatus) {
+ onSave({ notes, generatedAt, ...extra }, status);
+ }
+
+ function syncCompletedDateIfChanged() {
+ if (editDateCompleted !== (row.completedDate || "")) {
+ onDateCompletedChange?.(editDateCompleted);
+ }
+ }
+
+ function handleMarkNotRequired() {
+ setDocState("not-required");
+ persist({}, "NN");
+ }
+
+ function handleUndo() {
+ setDocState("draft");
+ persist({}, "No");
+ }
+
+ function handleSaveDraft() {
+ persist({}, "No");
+ syncCompletedDateIfChanged();
+ showCompDocDraftSavedToast();
+ }
+
+ function handleGenerate() {
+ const ts = fmtDocNow();
+ setGeneratedAt(ts);
+ setDocState("generated");
+ persist({ generatedAt: ts }, "Yes");
+ syncCompletedDateIfChanged();
+ }
+
+ function handleDownloadPdf() {
+ printDoc(buildDocHtml(row, { notes, generatedAt }, docFields));
+ }
+
+ return {
+ site,
+ notes,
+ setNotes,
+ docState,
+ generatedAt,
+ editDateCompleted,
+ setEditDateCompleted,
+ amazonRep,
+ seaRep,
+ cfg,
+ isNotRequired,
+ isGenerated,
+ handleMarkNotRequired,
+ handleUndo,
+ handleSaveDraft,
+ handleGenerate,
+ handleDownloadPdf,
+ };
+}
diff --git a/src/app/(protected)/workorders/index.tsx b/src/app/(protected)/workorders/index.tsx
index 945e2840..ac6eee5a 100644
--- a/src/app/(protected)/workorders/index.tsx
+++ b/src/app/(protected)/workorders/index.tsx
@@ -149,6 +149,11 @@ export default function WorkOrdersListPage() {
if (!open) setDocRow(null);
}}
data={completionDocs[String(activeDocRow.id)]}
+ // docStatus and completedDate persist server-side through the real work-order
+ // update API; patchField serializes same-id saves so a status save and a
+ // date save never race each other. There is no notes/content persistence
+ // endpoint on this branch, so `data` (notes/generatedAt) stays client-local
+ // for the preview only.
onSave={(data, status) => {
setCompletionDocs((prev) => ({ ...prev, [String(activeDocRow.id)]: data }));
patchField(activeDocRow.id, { docStatus: status });
diff --git a/src/domain/work-orders/mappers/wizard-draft-mapper.ts b/src/domain/work-orders/mappers/wizard-draft-mapper.ts
index d15215c4..09733358 100644
--- a/src/domain/work-orders/mappers/wizard-draft-mapper.ts
+++ b/src/domain/work-orders/mappers/wizard-draft-mapper.ts
@@ -109,6 +109,7 @@ export function mapWizardDraftToFormValues(draft: WorkOrderWizardDraft): WorkOrd
dueDate: draft.dueDate,
scheduledDate: draft.scheduleWeekOnly ? "" : draft.scheduledOn,
completedDate: "",
+ docStatus: "",
assignedTo: draft.assigneeName,
description: buildDescription(draft),
po: "",
diff --git a/src/domain/work-orders/mappers/work-order-mapper.ts b/src/domain/work-orders/mappers/work-order-mapper.ts
index d4a1e358..432dbca7 100644
--- a/src/domain/work-orders/mappers/work-order-mapper.ts
+++ b/src/domain/work-orders/mappers/work-order-mapper.ts
@@ -290,6 +290,7 @@ export function mapWorkOrderDetail(raw: unknown): WorkOrderDetail {
vendorNTE: wo.vendorNTE != null ? String(wo.vendorNTE) : "",
scheduledDate: toDateInputValue(readString(wo, "scheduledDate", "ScheduledDate")),
completedDate: toDateInputValue(readString(wo, "completedDate", "CompletedDate")),
+ docStatus: readDocStatus(wo),
source: readString(wo, "source", "Source"),
comments: Array.isArray(wo.comments)
? wo.comments.map(mapWorkOrderComment)
@@ -321,6 +322,7 @@ export function mapWorkOrderDetailToFormValues(detail: WorkOrderDetail): WorkOrd
dueDate: detail.dueDate,
scheduledDate: detail.scheduledDate,
completedDate: detail.completedDate,
+ docStatus: detail.docStatus,
assignedTo: detail.assignedTo,
description: detail.description,
po: detail.po,
@@ -346,6 +348,7 @@ export function mapWorkOrderFormToCreatePayload(
DueDate: values.dueDate || null,
ScheduledDate: values.scheduledDate || null,
CompletedDate: values.completedDate || null,
+ DocStatus: values.docStatus || null,
AssignTo: values.assignedTo || null,
Description: values.description,
PO: values.po || null,
@@ -380,6 +383,7 @@ export function mapWorkOrderFormToUpdatePayload(
VendorNTE: values.vendorNTE ? parseFloat(values.vendorNTE) : null,
ScheduledDate: values.scheduledDate || null,
CompletedDate: values.completedDate || null,
+ DocStatus: values.docStatus || null,
CreatedDate: values.createdDate || null,
Source: values.source || null,
};
diff --git a/src/domain/work-orders/mocks/wo-table-mock-data.ts b/src/domain/work-orders/mocks/wo-table-mock-data.ts
index 6aa7da53..1db76d97 100644
--- a/src/domain/work-orders/mocks/wo-table-mock-data.ts
+++ b/src/domain/work-orders/mocks/wo-table-mock-data.ts
@@ -502,6 +502,7 @@ export function buildMockWorkOrderDetailFromTableRow(row: WorkOrderTableRow): Wo
vendorNTE: "",
scheduledDate: row.scheduledOn,
completedDate: row.status === "Completed" ? todayIso() : "",
+ docStatus: row.docStatus,
source: row.type,
comments: [],
auditLog: [],
diff --git a/src/domain/work-orders/mocks/wo-wizard-mock-data.ts b/src/domain/work-orders/mocks/wo-wizard-mock-data.ts
index 075a250d..e0bf967c 100644
--- a/src/domain/work-orders/mocks/wo-wizard-mock-data.ts
+++ b/src/domain/work-orders/mocks/wo-wizard-mock-data.ts
@@ -150,6 +150,7 @@ export function buildMockWorkOrderDetail(values: WorkOrderFormValues): WorkOrder
vendorNTE: values.vendorNTE,
scheduledDate: values.scheduledDate,
completedDate: values.completedDate,
+ docStatus: values.docStatus ?? "",
source: values.source,
comments: [],
auditLog: [],
diff --git a/src/domain/work-orders/types/work-order-detail.ts b/src/domain/work-orders/types/work-order-detail.ts
index 9e6c802d..0012de0f 100644
--- a/src/domain/work-orders/types/work-order-detail.ts
+++ b/src/domain/work-orders/types/work-order-detail.ts
@@ -1,5 +1,6 @@
import type { WorkOrderCommentType, WorkOrderPriority, WorkOrderStatus } from "./work-order-common";
import type { WorkOrderDispatchSummary } from "./dispatch";
+import type { CompletionDocStatus } from "./work-order-table-row";
export interface WorkOrderComment {
id: string | number;
@@ -44,6 +45,8 @@ export interface WorkOrderDetail {
vendorNTE: string;
scheduledDate: string;
completedDate: string;
+ /** Completion-document status (API DocStatus/CompletionDoc); persisted via full update. */
+ docStatus: CompletionDocStatus;
source: string;
comments: WorkOrderComment[];
auditLog: WorkOrderAuditEntry[];
@@ -61,6 +64,8 @@ export interface WorkOrderFormValues {
dueDate: string;
scheduledDate: string;
completedDate: string;
+ /** Optional: absent from the wizard's zod-validated shape, present once loaded from the server. */
+ docStatus?: CompletionDocStatus;
assignedTo: string;
description: string;
po: string;
@@ -84,6 +89,7 @@ export function createDefaultWorkOrderFormValues(): WorkOrderFormValues {
dueDate: "",
scheduledDate: "",
completedDate: "",
+ docStatus: "",
assignedTo: "",
description: "",
po: "",
diff --git a/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts b/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts
index 8a77af3f..440c7d59 100644
--- a/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts
+++ b/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts
@@ -1,3 +1,4 @@
+import { useMemo } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "react-toastify";
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
@@ -13,6 +14,7 @@ import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-ta
import type { WizardWOStatus } from "@/domain/work-orders/types/work-order-wizard";
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
import { enrichTableRow } from "@/domain/work-orders/utils/table-filter-utils";
+import { createWorkOrderPatchQueue } from "@/domain/work-orders/utils/work-order-patch-queue";
import { queryKeys } from "@/infra/query-key/query-key";
export type WorkOrderTablePatch = Partial;
@@ -30,17 +32,72 @@ async function loadFormAndUpdate(
return detail;
}
+function buildFullUpdateValues(
+ values: ReturnType,
+ patch: WorkOrderTablePatch,
+): ReturnType {
+ return {
+ ...values,
+ workOrderNumber: patch.woNumber ?? values.workOrderNumber,
+ internalWONumber: patch.woNumber ?? values.internalWONumber,
+ locationId: patch.locationId != null ? String(patch.locationId) : values.locationId,
+ dueDate: patch.dueDate ?? values.dueDate,
+ scheduledDate: patch.scheduledOn ?? values.scheduledDate,
+ completedDate: patch.completedDate ?? values.completedDate,
+ docStatus: patch.docStatus ?? values.docStatus,
+ assignedTo: patch.dispatcherName ?? values.assignedTo,
+ problem: patch.pm ?? values.problem,
+ source: patch.type ?? values.source,
+ priority: values.priority,
+ status:
+ patch.status !== undefined
+ ? mapWizardStatusToApi(patch.status as WizardWOStatus)
+ : values.status,
+ description: [
+ values.description,
+ patch.pocNotes ? `POC notes: ${patch.pocNotes}` : "",
+ patch.pocName || patch.pocPhone
+ ? `POC: ${[patch.pocName, patch.pocPhone].filter(Boolean).join(" · ")}`
+ : "",
+ ]
+ .filter(Boolean)
+ .join("\n"),
+ };
+}
+
+async function runWorkOrderPatch(
+ id: string | number,
+ patch: WorkOrderTablePatch,
+ useFullUpdate: boolean | undefined,
+): Promise {
+ if (patch.status !== undefined && !useFullUpdate) {
+ const apiStatus = mapWizardStatusToApi(patch.status as WizardWOStatus);
+ await workOrdersApi.changeStatus(id, apiStatus);
+ return patch;
+ }
+ if (patch.dispatcherId !== undefined && !useFullUpdate) {
+ await workOrdersApi.changeAssignment(id, patch.dispatcherId || "");
+ return {
+ ...patch,
+ dispatcherName: patch.dispatcherName ?? "",
+ };
+ }
+ await loadFormAndUpdate(id, (values) => buildFullUpdateValues(values, patch));
+ return patch;
+}
+
export function useWorkOrderTableMutations(
onPatch: (id: string | number, patch: WorkOrderTablePatch) => void,
) {
const queryClient = useQueryClient();
+ const enqueuePatch = useMemo(() => createWorkOrderPatchQueue(), []);
const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
};
const patchMutation = useMutation({
- mutationFn: async ({
+ mutationFn: ({
id,
patch,
useFullUpdate,
@@ -48,47 +105,7 @@ export function useWorkOrderTableMutations(
id: string | number;
patch: WorkOrderTablePatch;
useFullUpdate?: boolean;
- }) => {
- if (patch.status !== undefined && !useFullUpdate) {
- const apiStatus = mapWizardStatusToApi(patch.status as WizardWOStatus);
- await workOrdersApi.changeStatus(id, apiStatus);
- return patch;
- }
- if (patch.dispatcherId !== undefined && !useFullUpdate) {
- await workOrdersApi.changeAssignment(id, patch.dispatcherId || "");
- return {
- ...patch,
- dispatcherName: patch.dispatcherName ?? "",
- };
- }
- await loadFormAndUpdate(id, (values) => ({
- ...values,
- workOrderNumber: patch.woNumber ?? values.workOrderNumber,
- internalWONumber: patch.woNumber ?? values.internalWONumber,
- locationId: patch.locationId != null ? String(patch.locationId) : values.locationId,
- dueDate: patch.dueDate ?? values.dueDate,
- scheduledDate: patch.scheduledOn ?? values.scheduledDate,
- completedDate: patch.completedDate ?? values.completedDate,
- assignedTo: patch.dispatcherName ?? values.assignedTo,
- problem: patch.pm ?? values.problem,
- source: patch.type ?? values.source,
- priority: values.priority,
- status:
- patch.status !== undefined
- ? mapWizardStatusToApi(patch.status as WizardWOStatus)
- : values.status,
- description: [
- values.description,
- patch.pocNotes ? `POC notes: ${patch.pocNotes}` : "",
- patch.pocName || patch.pocPhone
- ? `POC: ${[patch.pocName, patch.pocPhone].filter(Boolean).join(" · ")}`
- : "",
- ]
- .filter(Boolean)
- .join("\n"),
- }));
- return patch;
- },
+ }) => enqueuePatch(id, () => runWorkOrderPatch(id, patch, useFullUpdate)),
onMutate: async ({ id, patch }) => {
onPatch(id, patch);
},
diff --git a/src/domain/work-orders/utils/work-order-patch-queue.ts b/src/domain/work-orders/utils/work-order-patch-queue.ts
new file mode 100644
index 00000000..caf2e217
--- /dev/null
+++ b/src/domain/work-orders/utils/work-order-patch-queue.ts
@@ -0,0 +1,20 @@
+/**
+ * Serializes async work per work-order id so concurrent patches (e.g. a completion-doc
+ * status save firing alongside a completed-date save) never race two overlapping
+ * get-then-update round trips against the same record. Each call for a given id waits
+ * for the previous one to settle before starting its own fetch-and-update cycle.
+ */
+export function createWorkOrderPatchQueue() {
+ const chains = new Map>();
+
+ return function enqueue(id: string | number, run: () => Promise): Promise {
+ const key = String(id);
+ const previous = chains.get(key) ?? Promise.resolve();
+ const settled = previous.catch(() => undefined).then(run);
+ chains.set(
+ key,
+ settled.catch(() => undefined),
+ );
+ return settled;
+ };
+}
diff --git a/src/test/domain/work-orders/mappers/work-order-form-payload.test.ts b/src/test/domain/work-orders/mappers/work-order-form-payload.test.ts
index f39bf66a..5e2d6c5a 100644
--- a/src/test/domain/work-orders/mappers/work-order-form-payload.test.ts
+++ b/src/test/domain/work-orders/mappers/work-order-form-payload.test.ts
@@ -64,4 +64,18 @@ describe("mapWorkOrderFormToUpdatePayload", () => {
expect(payload.PO).toBeNull();
expect(payload.LocationId).toBeNull();
});
+
+ it("persists the completion-document status alongside the completed date", () => {
+ const values = {
+ ...createDefaultWorkOrderFormValues(),
+ createdDate: "2026-01-15",
+ completedDate: "2026-07-20",
+ docStatus: "Yes" as const,
+ };
+
+ const payload = mapWorkOrderFormToUpdatePayload(1, values);
+
+ expect(payload.CompletedDate).toBe("2026-07-20");
+ expect(payload.DocStatus).toBe("Yes");
+ });
});
diff --git a/src/test/domain/work-orders/mappers/work-order-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-mapper.test.ts
index 632fa346..b65f3e78 100644
--- a/src/test/domain/work-orders/mappers/work-order-mapper.test.ts
+++ b/src/test/domain/work-orders/mappers/work-order-mapper.test.ts
@@ -1,6 +1,8 @@
import { describe, expect, it } from "vitest";
import {
mapUpliftRequest,
+ mapWorkOrderDetail,
+ mapWorkOrderDetailToFormValues,
mapWorkOrderDispatchSummary,
} from "@/domain/work-orders/mappers/work-order-mapper";
@@ -22,6 +24,27 @@ describe("mapWorkOrderDispatchSummary", () => {
});
});
+describe("mapWorkOrderDetail completion-document status", () => {
+ it("reads DocStatus from the API payload so it round-trips into form values", () => {
+ const detail = mapWorkOrderDetail({
+ Id: 42,
+ CompletedDate: "2026-07-20",
+ DocStatus: "Yes",
+ });
+
+ expect(detail.docStatus).toBe("Yes");
+
+ const formValues = mapWorkOrderDetailToFormValues(detail);
+ expect(formValues.docStatus).toBe("Yes");
+ expect(formValues.completedDate).toBe("2026-07-20");
+ });
+
+ it("falls back to an empty doc status for unknown/missing values", () => {
+ const detail = mapWorkOrderDetail({ Id: 1 });
+ expect(detail.docStatus).toBe("");
+ });
+});
+
describe("mapUpliftRequest", () => {
it("maps decided uplift fields from PascalCase payload", () => {
const result = mapUpliftRequest({
diff --git a/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx
new file mode 100644
index 00000000..f0750f80
--- /dev/null
+++ b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx
@@ -0,0 +1,156 @@
+import type { ReactNode } from "react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { act, renderHook, waitFor } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { WorkOrderDetail, WorkOrderFormValues } from "@/domain/work-orders/types/work-order";
+
+const getById = vi.fn();
+const update = vi.fn();
+const changeStatus = vi.fn();
+const changeAssignment = vi.fn();
+
+vi.mock("@/domain/work-orders/api/work-orders-api", () => ({
+ workOrdersApi: {
+ getById: (...args: unknown[]) => getById(...args),
+ update: (...args: unknown[]) => update(...args),
+ changeStatus: (...args: unknown[]) => changeStatus(...args),
+ changeAssignment: (...args: unknown[]) => changeAssignment(...args),
+ },
+}));
+
+vi.mock("react-toastify", () => ({ toast: { error: vi.fn() } }));
+
+import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
+
+function delay(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+function makeWrapper() {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+ });
+ return function Wrapper({ children }: { children: ReactNode }) {
+ return {children};
+ };
+}
+
+const BASE_DETAIL: WorkOrderDetail = {
+ id: 42,
+ internalWONumber: "WO-42",
+ workOrderNumber: "WO-42",
+ title: "PM — SEA1",
+ location: "Seattle",
+ locationId: "loc-1",
+ locationZip: "98101",
+ assignedTo: "Dispatcher",
+ dueDate: "",
+ createdDate: "2026-06-01",
+ createdDateFull: "2026-06-01T00:00:00Z",
+ description: "",
+ priority: "Sev 3",
+ status: "Open",
+ po: "",
+ tt: "",
+ problem: "HVAC",
+ trade: "",
+ subTrade: "",
+ vendorNTE: "",
+ scheduledDate: "2026-06-01",
+ completedDate: "",
+ docStatus: "No",
+ source: "PM",
+ comments: [],
+ auditLog: [],
+ dispatches: [],
+};
+
+describe("useWorkOrderTableMutations completion-doc persistence", () => {
+ let serverState: WorkOrderDetail;
+ let events: string[];
+
+ beforeEach(() => {
+ getById.mockReset();
+ update.mockReset();
+ changeStatus.mockReset();
+ changeAssignment.mockReset();
+ serverState = { ...BASE_DETAIL };
+ events = [];
+
+ getById.mockImplementation(async () => {
+ events.push("getById:start");
+ await delay(15);
+ events.push("getById:end");
+ return { ...serverState };
+ });
+
+ update.mockImplementation(async (_id: unknown, values: WorkOrderFormValues) => {
+ events.push("update:start");
+ await delay(5);
+ serverState = {
+ ...serverState,
+ scheduledDate: values.scheduledDate,
+ completedDate: values.completedDate,
+ docStatus: values.docStatus ?? serverState.docStatus,
+ status: values.status,
+ };
+ events.push("update:end");
+ return { ...serverState };
+ });
+ });
+
+ it("maps docStatus into the real full-update payload instead of dropping it", async () => {
+ const onPatch = vi.fn();
+ const { result } = renderHook(() => useWorkOrderTableMutations(onPatch), {
+ wrapper: makeWrapper(),
+ });
+
+ act(() => {
+ result.current.patchField(42, { docStatus: "Yes" });
+ });
+
+ await waitFor(() => expect(events).toContain("update:end"));
+
+ expect(update.mock.calls[0]?.[1]).toMatchObject({ docStatus: "Yes" });
+ expect(serverState.docStatus).toBe("Yes");
+ });
+
+ it("serializes a completed-date save fired alongside a status save instead of racing", async () => {
+ const onPatch = vi.fn();
+ const { result } = renderHook(() => useWorkOrderTableMutations(onPatch), {
+ wrapper: makeWrapper(),
+ });
+
+ act(() => {
+ result.current.patchField(42, { docStatus: "Yes" });
+ result.current.patchField(42, { completedDate: "2026-07-20" });
+ });
+
+ await waitFor(() => expect(events.filter((event) => event === "update:end")).toHaveLength(2));
+
+ // The second full-update cycle must not start its GET until the first PUT settled.
+ expect(events).toEqual([
+ "getById:start",
+ "getById:end",
+ "update:start",
+ "update:end",
+ "getById:start",
+ "getById:end",
+ "update:start",
+ "update:end",
+ ]);
+
+ // Neither save clobbers the other: both the status and the date survive.
+ expect(serverState.docStatus).toBe("Yes");
+ expect(serverState.completedDate).toBe("2026-07-20");
+
+ // Scheduling and lifecycle status are untouched by either completion-doc save.
+ expect(serverState.scheduledDate).toBe(BASE_DETAIL.scheduledDate);
+ expect(serverState.status).toBe(BASE_DETAIL.status);
+ for (const call of update.mock.calls) {
+ const values = call[1] as WorkOrderFormValues;
+ expect(values.scheduledDate).toBe(BASE_DETAIL.scheduledDate);
+ expect(values.status).not.toBe("Done");
+ }
+ });
+});
diff --git a/src/test/domain/work-orders/utils/work-order-patch-queue.test.ts b/src/test/domain/work-orders/utils/work-order-patch-queue.test.ts
new file mode 100644
index 00000000..c088e3c1
--- /dev/null
+++ b/src/test/domain/work-orders/utils/work-order-patch-queue.test.ts
@@ -0,0 +1,62 @@
+import { describe, expect, it } from "vitest";
+import { createWorkOrderPatchQueue } from "@/domain/work-orders/utils/work-order-patch-queue";
+
+function delay(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+describe("createWorkOrderPatchQueue", () => {
+ it("serializes calls for the same id so the second never starts before the first settles", async () => {
+ const enqueue = createWorkOrderPatchQueue();
+ const events: string[] = [];
+
+ const first = enqueue(1, async () => {
+ events.push("first:start");
+ await delay(20);
+ events.push("first:end");
+ return "first";
+ });
+ const second = enqueue(1, async () => {
+ events.push("second:start");
+ await delay(1);
+ events.push("second:end");
+ return "second";
+ });
+
+ await Promise.all([first, second]);
+
+ expect(events).toEqual(["first:start", "first:end", "second:start", "second:end"]);
+ });
+
+ it("does not block work queued for a different id", async () => {
+ const enqueue = createWorkOrderPatchQueue();
+ const events: string[] = [];
+
+ const slow = enqueue(1, async () => {
+ events.push("slow:start");
+ await delay(20);
+ events.push("slow:end");
+ });
+ const fast = enqueue(2, async () => {
+ events.push("fast:start");
+ await delay(1);
+ events.push("fast:end");
+ });
+
+ await Promise.all([slow, fast]);
+
+ expect(events.indexOf("fast:end")).toBeLessThan(events.indexOf("slow:end"));
+ });
+
+ it("still runs the next task after a prior task rejects", async () => {
+ const enqueue = createWorkOrderPatchQueue();
+
+ await expect(
+ enqueue(1, async () => {
+ throw new Error("boom");
+ }),
+ ).rejects.toThrow("boom");
+
+ await expect(enqueue(1, async () => "recovered")).resolves.toBe("recovered");
+ });
+});