;
+};
+
+export function CompDocDialog({
+ row,
+ open,
+ onOpenChange,
+ data,
+ onSave,
+ onDateCompletedChange,
+}: CompDocDialogProps) {
+ const dialog = useCompDocDialog({
+ row,
+ open,
+ data,
+ onSave,
+ onDateCompletedChange,
+ });
+ const handleClose = dialog.isUploading ? undefined : () => onOpenChange(false);
+
+ return (
+ ) => e.stopPropagation(),
+ },
+ }}
+ >
+
+
+ Completion Document — WO #{dialog.info.woNumber}
+
+
+ Generate, save or mark not-required the completion document for this work order.
+
+
+
+
handleClose?.()}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
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-edit-row.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-edit-row.tsx
new file mode 100644
index 00000000..3823f39b
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-edit-row.tsx
@@ -0,0 +1,51 @@
+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)",
+};
+
+type CompDocEditRowProps = {
+ label: string;
+ value: string;
+ onChange: (value: string) => void;
+ type?: string;
+ readOnly?: boolean;
+};
+
+export function CompDocEditRow({
+ label,
+ value,
+ onChange,
+ type = "text",
+ readOnly = false,
+}: CompDocEditRowProps) {
+ return (
+
+
+ {label}
+
+
onChange(e.target.value)}
+ readOnly={readOnly}
+ className={COMP_DOC_INPUT_CLS}
+ style={{
+ ...COMP_DOC_INPUT_STYLE,
+ ...(readOnly
+ ? {
+ opacity: 0.6,
+ cursor: "default",
+ pointerEvents: "none" as const,
+ }
+ : {}),
+ }}
+ />
+
+ );
+}
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-footer-button-sx.ts b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-footer-button-sx.ts
new file mode 100644
index 00000000..f5c0f2ba
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-footer-button-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 (
+
+
+ PM Comments{" "}
+
+ — optional, included in the document
+
+
+
+ );
+}
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-company-header.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-company-header.tsx
new file mode 100644
index 00000000..0797a44d
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-company-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 CompDocPreviewCompanyHeader() {
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-document.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-document.tsx
new file mode 100644
index 00000000..4c8c7872
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-document.tsx
@@ -0,0 +1,58 @@
+import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+import { CompDocPreviewCompanyHeader } from "./comp-doc-preview-company-header";
+import { CompDocPreviewProcedureNotes } from "./comp-doc-preview-procedure-notes";
+import { CompDocPreviewServiceInfo } from "./comp-doc-preview-service-info";
+import { CompDocPreviewVendorSafety } from "./comp-doc-preview-vendor-safety";
+
+type CompDocPreviewDocumentProps = {
+ row: WorkOrderTableRow;
+ site: CompDocSiteInfo;
+ notes: string;
+ generatedAt: string;
+ isGenerated: boolean;
+ isNotRequired: boolean;
+};
+
+export function CompDocPreviewDocument({
+ row,
+ site,
+ notes,
+ generatedAt,
+ isGenerated,
+ isNotRequired,
+}: CompDocPreviewDocumentProps) {
+ return (
+
+ );
+}
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-not-required-stamp.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-not-required-stamp.tsx
new file mode 100644
index 00000000..c3ed0ec2
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-not-required-stamp.tsx
@@ -0,0 +1,25 @@
+import { Ban } from "lucide-react";
+
+export function CompDocPreviewNotRequiredStamp() {
+ return (
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-procedure-notes.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-procedure-notes.tsx
new file mode 100644
index 00000000..e0c13e1a
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-procedure-notes.tsx
@@ -0,0 +1,68 @@
+import { Text } from "@/components/ui/text";
+import { procedureFor } from "@/domain/work-orders/utils/completion-doc-procedures";
+import { CompDocProcedureList } from "./comp-doc-procedure-list";
+
+type CompDocPreviewProcedureNotesProps = {
+ pm: string;
+ notes: string;
+};
+
+export function CompDocPreviewProcedureNotes({ pm, notes }: CompDocPreviewProcedureNotesProps) {
+ const proc = procedureFor(pm);
+
+ return (
+ <>
+
+ {proc.heading}
+
+
+ {proc.intro}
+
+
+
+ {Boolean(notes) && (
+ <>
+
+ PM Comments
+
+
+ {notes}
+
+ >
+ )}
+ >
+ );
+}
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-service-info.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-service-info.tsx
new file mode 100644
index 00000000..8dd82d83
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-service-info.tsx
@@ -0,0 +1,89 @@
+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 CompDocPreviewServiceInfoProps = {
+ row: WorkOrderTableRow;
+ site: CompDocSiteInfo;
+ isGenerated: boolean;
+ generatedAt: string;
+};
+
+export function CompDocPreviewServiceInfo({
+ row,
+ site,
+ isGenerated,
+ generatedAt,
+}: CompDocPreviewServiceInfoProps) {
+ 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}
+
+
+ >
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-status-badge.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-status-badge.tsx
new file mode 100644
index 00000000..5cf12ae6
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-status-badge.tsx
@@ -0,0 +1,32 @@
+import { CheckCircle2, FileText } from "lucide-react";
+
+type CompDocPreviewStatusBadgeProps = {
+ isGenerated: boolean;
+};
+
+export function CompDocPreviewStatusBadge({ isGenerated }: CompDocPreviewStatusBadgeProps) {
+ return (
+
+
+ {isGenerated ? (
+
+ ) : (
+
+ )}
+ {isGenerated ? "Generated document" : "Preview — not yet generated"}
+
+
+ );
+}
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-vendor-safety.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-vendor-safety.tsx
new file mode 100644
index 00000000..807eca19
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-vendor-safety.tsx
@@ -0,0 +1,35 @@
+import { Text } from "@/components/ui/text";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+import { SAFETY_BULLETS } from "@/domain/work-orders/utils/completion-doc-procedures";
+
+type CompDocPreviewVendorSafetyProps = {
+ row: WorkOrderTableRow;
+};
+
+export function CompDocPreviewVendorSafety({ row }: CompDocPreviewVendorSafetyProps) {
+ return (
+ <>
+
+ Vendor / Technician:
+ {row.tech || "—"}
+ {row.company ? ` · ${row.company}` : ""}
+
+
+
+ {SAFETY_BULLETS.map((b, i) => (
+
+ {b}
+
+ ))}
+
+ >
+ );
+}
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
new file mode 100644
index 00000000..df26f1a8
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview.tsx
@@ -0,0 +1,65 @@
+import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+import { CompDocPreviewDocument } from "./comp-doc-preview-document";
+import { CompDocPreviewNotRequiredStamp } from "./comp-doc-preview-not-required-stamp";
+import { CompDocPreviewStatusBadge } from "./comp-doc-preview-status-badge";
+
+type CompDocPreviewProps = {
+ row: WorkOrderTableRow;
+ site: CompDocSiteInfo;
+ notes: string;
+ generatedAt: string;
+ isGenerated: boolean;
+ isNotRequired: boolean;
+};
+
+export function CompDocPreview({
+ row,
+ site,
+ notes,
+ generatedAt,
+ isGenerated,
+ isNotRequired,
+}: CompDocPreviewProps) {
+ return (
+
+
+
+
+
+
+
+ {isNotRequired && }
+
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-procedure-list.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-procedure-list.tsx
new file mode 100644
index 00000000..5d9de8b9
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-procedure-list.tsx
@@ -0,0 +1,32 @@
+import type { Procedure } from "@/domain/work-orders/utils/completion-doc-procedures";
+
+type CompDocProcedureListProps = {
+ proc: Procedure;
+};
+
+export function CompDocProcedureList({ proc }: CompDocProcedureListProps) {
+ return (
+
+ {proc.steps.map((st, i) => (
+
+ {st.text}
+ {st.sub != null && (
+
+ {st.sub.map((x, j) => (
+
+ {x}
+
+ ))}
+
+ )}
+
+ ))}
+
+ );
+}
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
new file mode 100644
index 00000000..02035224
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-sidebar.tsx
@@ -0,0 +1,174 @@
+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";
+import { CompDocEditRow } from "./comp-doc-edit-row";
+
+type CompDocSidebarProps = {
+ row: WorkOrderTableRow;
+ site: CompDocSiteInfo;
+ docState: DocState;
+ generatedAt: string;
+ notes: string;
+ editDateCompleted: string;
+ amazonRep: string;
+ seaRep: string;
+ isGenerated: boolean;
+ onNotesChange: (value: string) => void;
+ onDateCompletedChange: (value: string) => void;
+};
+
+export function CompDocSidebar({
+ row,
+ site,
+ docState,
+ generatedAt,
+ notes,
+ editDateCompleted,
+ amazonRep,
+ seaRep,
+ isGenerated,
+ onNotesChange,
+ onDateCompletedChange,
+}: CompDocSidebarProps) {
+ const displayState: DocState =
+ isGenerated && docState !== "not-required" ? "generated" : docState;
+ const cfg = DOC_STATE_CFG[displayState];
+ const StateIcon = cfg.icon;
+
+ return (
+
+
+
+
+
+
+
+
+ {cfg.cardTitle}
+
+
+ {docStatusCardDescription(displayState, generatedAt)}
+
+
+
+
+
+
+ Work Order Details
+
+
+
+
+
+
+
+
+
+
+
+
+
+ PM Comments{" "}
+
+ — optional, included in the document
+
+
+
+
+
+ );
+}
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/_components/comp-doc/use-comp-doc-dialog-seed.ts b/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-dialog-seed.ts
new file mode 100644
index 00000000..d189971f
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-dialog-seed.ts
@@ -0,0 +1,59 @@
+import { useEffect, useRef, type Dispatch, type SetStateAction } from "react";
+import type { CompDocData, DocState } from "@/domain/work-orders/types/completion-doc";
+import type { CompletionDocStatus } from "@/domain/work-orders/types/work-order-table-row";
+import { docStateOf } from "@/domain/work-orders/utils/completion-doc-state";
+
+type UseCompDocDialogSeedArgs = {
+ open: boolean;
+ infoId: string | number;
+ infoDocStatus: CompletionDocStatus;
+ infoCompletedDate?: string | null;
+ completionDocStatus?: CompletionDocStatus | null;
+ data?: CompDocData;
+ setNotes: Dispatch>;
+ setDocState: Dispatch>;
+ setGeneratedAt: Dispatch>;
+ setEditDateCompleted: Dispatch>;
+};
+
+/** Prototype seeds once per open/WO session; detail refetches must not wipe edits. */
+export function useCompDocDialogSeed({
+ open,
+ infoId,
+ infoDocStatus,
+ infoCompletedDate,
+ completionDocStatus,
+ data,
+ setNotes,
+ setDocState,
+ setGeneratedAt,
+ setEditDateCompleted,
+}: UseCompDocDialogSeedArgs) {
+ const seedKeyRef = useRef(null);
+
+ useEffect(() => {
+ if (!open) {
+ seedKeyRef.current = null;
+ return;
+ }
+ const key = String(infoId);
+ if (seedKeyRef.current === key) return;
+ seedKeyRef.current = key;
+ setNotes(data?.notes ?? "");
+ setDocState(docStateOf(completionDocStatus || infoDocStatus));
+ setGeneratedAt(data?.generatedAt ?? "");
+ setEditDateCompleted(infoCompletedDate || "");
+ }, [
+ open,
+ infoId,
+ infoDocStatus,
+ infoCompletedDate,
+ completionDocStatus,
+ data?.notes,
+ data?.generatedAt,
+ setNotes,
+ setDocState,
+ setGeneratedAt,
+ setEditDateCompleted,
+ ]);
+}
diff --git a/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-dialog.tsx b/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-dialog.tsx
new file mode 100644
index 00000000..365cf856
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-dialog.tsx
@@ -0,0 +1,179 @@
+import { useMemo, useRef, useState } from "react";
+import { toast } from "react-toastify";
+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 { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
+import { useUploadCompletionDoc } from "@/domain/work-orders/use-cases/use-work-order-mutations";
+import { resolveCompDocSite } from "@/domain/work-orders/utils/completion-doc-site";
+import { DOC_STATE_CFG } from "@/domain/work-orders/utils/completion-doc-state";
+import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits";
+import { useCompDocDialogSeed } from "./use-comp-doc-dialog-seed";
+import { useCompDocNotRequiredActions } from "./use-comp-doc-not-required-actions";
+import { useCompDocPdfActions } from "./use-comp-doc-pdf-actions";
+import { useCompDocPersistFlow } from "./use-comp-doc-persist-flow";
+
+type UseCompDocDialogArgs = {
+ row: WorkOrderTableRow;
+ open: boolean;
+ data?: CompDocData;
+ onSave: (data: CompDocData, status: CompDocSaveStatus) => void | Promise;
+ onDateCompletedChange?: (date: string) => void | Promise;
+};
+
+function notifyDraftSaved() {
+ toast(
+
+ Draft saved
+
,
+ {
+ position: "bottom-center",
+ autoClose: 3000,
+ hideProgressBar: true,
+ closeButton: false,
+ icon: false,
+ className: "!min-h-0 !bg-transparent !p-0 !shadow-none",
+ },
+ );
+}
+
+export function useCompDocDialog({
+ row,
+ open,
+ data,
+ onSave,
+ onDateCompletedChange,
+}: UseCompDocDialogArgs) {
+ const locId = String(row.locationId || "");
+ const { data: locationDetail } = useLocationDetail(locId || undefined);
+ const { data: boardDetail } = useWorkOrderBoardDetail(row.id, open);
+ const uploadCompletionMutation = useUploadCompletionDoc(row.id);
+ const pdfInputRef = useRef(null);
+ const [isFinalizing, setIsFinalizing] = useState(false);
+
+ const info = boardDetail?.info ?? row;
+ const completion = boardDetail?.completion;
+ const templateUrl = completion?.template?.templateUrl;
+ const signOffAttachment = completion?.signOffAttachment;
+ const rowVersion = info.rowVersion || row.rowVersion;
+ const isDocumentsReadOnly = isWorkOrderDocumentsReadOnly(info.status);
+ const site = useMemo(() => resolveCompDocSite(info, locationDetail), [info, locationDetail]);
+
+ const [notes, setNotes] = useState("");
+ const [docState, setDocState] = useState("draft");
+ const [generatedAt, setGeneratedAt] = useState("");
+ const [editDateCompleted, setEditDateCompleted] = useState("");
+
+ useCompDocDialogSeed({
+ open,
+ infoId: info.id,
+ infoDocStatus: info.docStatus,
+ infoCompletedDate: info.completedDate,
+ completionDocStatus: completion?.docStatus,
+ data,
+ setNotes,
+ setDocState,
+ setGeneratedAt,
+ setEditDateCompleted,
+ });
+
+ const amazonRep = info.pocName || "";
+ const seaRep = info.tech || "";
+ const cfg = DOC_STATE_CFG[docState];
+ const isNotRequired = docState === "not-required";
+ const isGenerated = docState === "generated" || Boolean(signOffAttachment);
+
+ const docFields: DocFields = {
+ street: site.street,
+ city: site.city,
+ state: site.state,
+ zip: site.zip,
+ dateCompleted: editDateCompleted,
+ amazonRep,
+ seaRep,
+ };
+
+ const { persist, syncDateIfChanged, refreshAuthoritativeState, handleSaveDraft, handleGenerate } =
+ useCompDocPersistFlow({
+ workOrderId: row.id,
+ notes,
+ generatedAt,
+ editDateCompleted,
+ currentCompletedDate: info.completedDate || "",
+ onSave,
+ onDateCompletedChange,
+ onGenerated: (ts) => {
+ setGeneratedAt(ts);
+ setDocState("generated");
+ },
+ onDraftSaved: notifyDraftSaved,
+ });
+
+ const { handleMarkNotRequired, handleUndo } = useCompDocNotRequiredActions({
+ docState,
+ setDocState,
+ persist,
+ refreshAuthoritativeState,
+ setIsFinalizing,
+ });
+
+ const { handleDownloadPdf, handleUploadPdf } = useCompDocPdfActions({
+ info,
+ signOffAttachment,
+ rowVersion,
+ notes,
+ generatedAt,
+ docFields,
+ uploadCompletionMutation,
+ persist,
+ syncDateIfChanged,
+ setGeneratedAt,
+ markGenerated: () => setDocState("generated"),
+ onPersistFailure: refreshAuthoritativeState,
+ onFinalizingChange: setIsFinalizing,
+ });
+
+ return {
+ info,
+ templateUrl,
+ signOffAttachment,
+ rowVersion,
+ isDocumentsReadOnly,
+ site,
+ notes,
+ setNotes,
+ docState,
+ generatedAt,
+ editDateCompleted,
+ setEditDateCompleted,
+ amazonRep,
+ seaRep,
+ cfg,
+ isNotRequired,
+ isGenerated,
+ isUploading: uploadCompletionMutation.isPending || isFinalizing,
+ pdfInputRef,
+ handleMarkNotRequired,
+ handleUndo,
+ handleSaveDraft,
+ handleGenerate,
+ handleDownloadPdf,
+ handleUploadPdf,
+ };
+}
diff --git a/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-not-required-actions.ts b/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-not-required-actions.ts
new file mode 100644
index 00000000..4159b926
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-not-required-actions.ts
@@ -0,0 +1,58 @@
+import { toast } from "react-toastify";
+import type {
+ CompDocData,
+ CompDocSaveStatus,
+ DocState,
+} from "@/domain/work-orders/types/completion-doc";
+
+type PersistFn = (data: Partial, status: CompDocSaveStatus) => void | Promise;
+
+type UseCompDocNotRequiredActionsArgs = {
+ docState: DocState;
+ setDocState: (state: DocState) => void;
+ persist: PersistFn;
+ refreshAuthoritativeState: () => void;
+ setIsFinalizing: (value: boolean) => void;
+};
+
+export function useCompDocNotRequiredActions({
+ docState,
+ setDocState,
+ persist,
+ refreshAuthoritativeState,
+ setIsFinalizing,
+}: UseCompDocNotRequiredActionsArgs) {
+ async function handleMarkNotRequired() {
+ const previous = docState;
+ setDocState("not-required");
+ setIsFinalizing(true);
+ try {
+ await persist({}, "NN");
+ } catch (error) {
+ setDocState(previous);
+ refreshAuthoritativeState();
+ toast.error(
+ error instanceof Error ? error.message : "Failed to mark document as not required",
+ );
+ } finally {
+ setIsFinalizing(false);
+ }
+ }
+
+ async function handleUndo() {
+ const previous = docState;
+ setDocState("draft");
+ setIsFinalizing(true);
+ try {
+ await persist({}, "No");
+ } catch (error) {
+ setDocState(previous);
+ refreshAuthoritativeState();
+ toast.error(error instanceof Error ? error.message : "Failed to undo not-required");
+ } finally {
+ setIsFinalizing(false);
+ }
+ }
+
+ return { handleMarkNotRequired, handleUndo };
+}
diff --git a/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-pdf-actions.ts b/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-pdf-actions.ts
new file mode 100644
index 00000000..5d3b6475
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-pdf-actions.ts
@@ -0,0 +1,98 @@
+import { toast } from "react-toastify";
+import type {
+ CompDocData,
+ CompDocSaveStatus,
+ DocFields,
+} from "@/domain/work-orders/types/completion-doc";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+import type { useUploadCompletionDoc } from "@/domain/work-orders/use-cases/use-work-order-mutations";
+import { buildDocHtml, printDoc } from "@/domain/work-orders/utils/completion-doc-html";
+import { fmtDocNow } from "@/domain/work-orders/utils/completion-doc-state";
+import {
+ formatMediaSizeLimit,
+ isWorkOrderFileTooLarge,
+} from "@/domain/work-orders/utils/work-order-media-limits";
+
+type UseCompDocPdfActionsArgs = {
+ info: WorkOrderTableRow;
+ signOffAttachment?: string | null;
+ rowVersion?: string;
+ notes: string;
+ generatedAt: string;
+ docFields: DocFields;
+ uploadCompletionMutation: ReturnType;
+ persist: (extra: Partial, status: CompDocSaveStatus) => Promise;
+ syncDateIfChanged: () => Promise;
+ setGeneratedAt: (value: string) => void;
+ markGenerated: () => void;
+ onPersistFailure?: () => void;
+ onFinalizingChange?: (finalizing: boolean) => void;
+};
+
+export function useCompDocPdfActions({
+ info,
+ signOffAttachment,
+ rowVersion,
+ notes,
+ generatedAt,
+ docFields,
+ uploadCompletionMutation,
+ persist,
+ syncDateIfChanged,
+ setGeneratedAt,
+ markGenerated,
+ onPersistFailure,
+ onFinalizingChange,
+}: UseCompDocPdfActionsArgs) {
+ function handleDownloadPdf() {
+ if (signOffAttachment) {
+ window.open(signOffAttachment, "_blank", "noopener,noreferrer");
+ return;
+ }
+ printDoc(buildDocHtml(info, { notes, generatedAt }, docFields));
+ }
+
+ function handleUploadPdf(files: FileList | null) {
+ const file = files?.[0];
+ if (!file) return;
+ if (isWorkOrderFileTooLarge(file)) {
+ toast.error(`File exceeds the ${formatMediaSizeLimit()} limit.`);
+ return;
+ }
+ if (file.type !== "application/pdf" && !file.name.toLowerCase().endsWith(".pdf")) {
+ toast.error("Please upload a PDF file.");
+ return;
+ }
+ if (!rowVersion) {
+ toast.error("Missing work order version. Refresh and try again.");
+ return;
+ }
+ uploadCompletionMutation.mutate(
+ { file, workOrderVersion: rowVersion },
+ {
+ onSuccess: () => {
+ // Keep close disabled across the gap between upload settle and docStatus persist.
+ onFinalizingChange?.(true);
+ void (async () => {
+ const ts = fmtDocNow();
+ try {
+ await syncDateIfChanged();
+ await persist({ generatedAt: ts }, "Yes");
+ setGeneratedAt(ts);
+ markGenerated();
+ } catch (error) {
+ onPersistFailure?.();
+ toast.error(
+ error instanceof Error ? error.message : "Failed to finalize completion document",
+ );
+ } finally {
+ onFinalizingChange?.(false);
+ }
+ })();
+ },
+ },
+ );
+ }
+
+ return { handleDownloadPdf, handleUploadPdf };
+}
diff --git a/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-persist-flow.ts b/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-persist-flow.ts
new file mode 100644
index 00000000..57a89c17
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-persist-flow.ts
@@ -0,0 +1,84 @@
+import { useQueryClient } from "@tanstack/react-query";
+import { toast } from "react-toastify";
+import type { CompDocData, CompDocSaveStatus } from "@/domain/work-orders/types/completion-doc";
+import { fmtDocNow } from "@/domain/work-orders/utils/completion-doc-state";
+import { queryKeys } from "@/infra/query-key/query-key";
+
+type UseCompDocPersistFlowArgs = {
+ workOrderId: string | number;
+ notes: string;
+ generatedAt: string;
+ editDateCompleted: string;
+ currentCompletedDate: string;
+ onSave: (data: CompDocData, status: CompDocSaveStatus) => void | Promise;
+ onDateCompletedChange?: (date: string) => void | Promise;
+ onGenerated: (generatedAt: string) => void;
+ onDraftSaved: () => void;
+};
+
+export function useCompDocPersistFlow({
+ workOrderId,
+ notes,
+ generatedAt,
+ editDateCompleted,
+ currentCompletedDate,
+ onSave,
+ onDateCompletedChange,
+ onGenerated,
+ onDraftSaved,
+}: UseCompDocPersistFlowArgs) {
+ const queryClient = useQueryClient();
+
+ const refreshAuthoritativeState = () => {
+ void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
+ void queryClient.invalidateQueries({
+ queryKey: queryKeys.workOrders.boardDetail(workOrderId),
+ });
+ void queryClient.invalidateQueries({
+ queryKey: queryKeys.workOrders.detail(workOrderId),
+ });
+ };
+
+ const persist = async (extra: Partial, status: CompDocSaveStatus) => {
+ await onSave({ notes, generatedAt, ...extra }, status);
+ };
+
+ const syncDateIfChanged = async () => {
+ if (editDateCompleted !== currentCompletedDate) {
+ await onDateCompletedChange?.(editDateCompleted);
+ }
+ };
+
+ async function handleSaveDraft() {
+ try {
+ // Prerequisites first; docStatus last.
+ await syncDateIfChanged();
+ await persist({}, "No");
+ onDraftSaved();
+ } catch (error) {
+ refreshAuthoritativeState();
+ toast.error(error instanceof Error ? error.message : "Failed to save draft");
+ }
+ }
+
+ async function handleGenerate() {
+ const ts = fmtDocNow();
+ try {
+ // Prerequisites first; only mark Generated after completedDate persists.
+ await syncDateIfChanged();
+ await persist({ generatedAt: ts }, "Yes");
+ onGenerated(ts);
+ } catch (error) {
+ refreshAuthoritativeState();
+ toast.error(error instanceof Error ? error.message : "Failed to generate document");
+ }
+ }
+
+ return {
+ persist,
+ syncDateIfChanged,
+ refreshAuthoritativeState,
+ handleSaveDraft,
+ handleGenerate,
+ };
+}
diff --git a/src/app/(protected)/workorders/_components/detail/audit-timeline.tsx b/src/app/(protected)/workorders/_components/detail/audit-timeline.tsx
new file mode 100644
index 00000000..2e646ab5
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/audit-timeline.tsx
@@ -0,0 +1,67 @@
+import { useMemo } from "react";
+import { Box, Typography } from "@mui/material";
+import type { WorkOrderAuditEntry } from "@/domain/work-orders/types/work-order";
+import { formatDateTime } from "@/lib/time-utils";
+
+type AuditTimelineProps = {
+ entries: WorkOrderAuditEntry[];
+ emptyMessage?: string;
+ maxHeightClass?: string;
+};
+
+export function AuditTimeline({
+ entries,
+ emptyMessage = "No changes recorded.",
+ maxHeightClass = "max-h-72",
+}: AuditTimelineProps) {
+ const sorted = useMemo(
+ () =>
+ [...entries].sort(
+ (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
+ ),
+ [entries],
+ );
+
+ if (!sorted.length) {
+ return {emptyMessage} ;
+ }
+
+ return (
+
+ {sorted.map((entry) => (
+
+
+
+ {entry.userName || "System"}
+
+
+ {entry.createdAt ? formatDateTime(entry.createdAt) : ""}
+
+
+
+ {entry.action ? (
+ entry.action
+ ) : (
+ <>
+ {entry.fieldName} {entry.oldValue} → {entry.newValue}
+ >
+ )}
+
+ {Boolean(entry.action && entry.fieldName) && (
+
+ {entry.fieldName}
+ {entry.oldValue || entry.newValue
+ ? `: ${entry.oldValue || "—"} → ${entry.newValue || "—"}`
+ : ""}
+
+ )}
+
+ ))}
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/detail/slide-over-audit-list.tsx b/src/app/(protected)/workorders/_components/detail/slide-over-audit-list.tsx
new file mode 100644
index 00000000..57c72270
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/slide-over-audit-list.tsx
@@ -0,0 +1,164 @@
+import { Settings } from "lucide-react";
+import { Text } from "@/components/ui/text";
+import type { WorkOrderAuditEntry, WorkOrderUser } from "@/domain/work-orders/types/work-order";
+import {
+ getAvatarColorFromName,
+ getDispatcherAvatarColor,
+ getInitialsFromName,
+} from "@/domain/work-orders/utils/dispatcher-avatar-color";
+import { formatBoardAuditAction } from "@/domain/work-orders/utils/format-board-audit-action";
+
+function isSystemEntry(entry: WorkOrderAuditEntry): boolean {
+ if (entry.type === "system") return true;
+ if (entry.type === "manual") return false;
+ const name = entry.userName?.trim();
+ return !name || name.toLowerCase() === "system";
+}
+
+function formatClock(date: Date): string {
+ return date.toLocaleTimeString("en-US", {
+ hour: "numeric",
+ minute: "2-digit",
+ });
+}
+
+/**
+ * Manual (image): `· 9:48 AM`
+ * System (image): `05/13 · 8:00 AM`
+ * Prefer API display strings that already include · / AM|PM.
+ */
+function formatAuditTime(value: string, isSystem: boolean): string {
+ if (!value) return "";
+ if (/[·•]/.test(value) || /\b(AM|PM)\b/i.test(value)) return value;
+
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) return value;
+
+ const timePart = formatClock(date);
+ if (isSystem) {
+ const datePart = date.toLocaleDateString("en-US", {
+ month: "2-digit",
+ day: "2-digit",
+ });
+ return `${datePart} · ${timePart}`;
+ }
+ return `· ${timePart}`;
+}
+
+type SlideOverAuditListProps = {
+ entries: WorkOrderAuditEntry[];
+ users?: WorkOrderUser[];
+};
+
+export function SlideOverAuditList({ entries, users = [] }: SlideOverAuditListProps) {
+ return (
+
+
+ Audit Log
+
+
+ {entries.length === 0 ? (
+
+ No changes recorded
+
+ ) : (
+ entries.map((entry, index) => {
+ const isSys = isSystemEntry(entry);
+ const dispatcher = users.find(
+ (user) =>
+ entry.dispatcherId != null && String(user.id) === String(entry.dispatcherId),
+ );
+ const name =
+ dispatcher?.name ||
+ entry.userName?.trim() ||
+ (entry.dispatcherId ? String(entry.dispatcherId) : "Unknown");
+ const color = dispatcher
+ ? getDispatcherAvatarColor(dispatcher)
+ : getAvatarColorFromName(name);
+ const initials = dispatcher
+ ? getInitialsFromName(dispatcher.name)
+ : getInitialsFromName(name);
+ const action = formatBoardAuditAction(entry);
+
+ return (
+
+ {isSys ? (
+
+
+
+ ) : (
+
+ {initials}
+
+ )}
+
+ {isSys ? (
+
+ System
+ —
+ {action}
+
+ ) : (
+
+
+ {name}
+
+ ·
+ {action}
+
+ )}
+
+ {formatAuditTime(entry.createdAt, isSys)}
+
+
+
+ );
+ })
+ )}
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/detail/slide-over-footer.tsx b/src/app/(protected)/workorders/_components/detail/slide-over-footer.tsx
new file mode 100644
index 00000000..82b03b26
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/slide-over-footer.tsx
@@ -0,0 +1,143 @@
+import { AlertTriangle, Ban, Check, Pencil } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Text } from "@/components/ui/text";
+
+type SlideOverFooterProps = {
+ isCancelled: boolean;
+ editing: boolean;
+ isDirty: boolean;
+ showUnsaved: boolean;
+ saving?: boolean;
+ onCancelWO: () => void;
+ onCancelEdit: () => void;
+ onClose: () => void;
+ onEdit: () => void;
+ onSave: () => void;
+ onDiscard: () => void;
+ onKeepEditing: () => void;
+};
+
+export function SlideOverFooter({
+ isCancelled,
+ editing,
+ isDirty,
+ showUnsaved,
+ saving,
+ onCancelWO,
+ onCancelEdit,
+ onClose,
+ onEdit,
+ onSave,
+ onDiscard,
+ onKeepEditing,
+}: SlideOverFooterProps) {
+ if (showUnsaved) {
+ return (
+
+
+
+ You have unsaved changes.
+
+
+
+ Discard
+
+
+
+ Save
+
+
+ Keep editing
+
+
+
+ );
+ }
+
+ if (isCancelled) {
+ return (
+
+
+
+ This work order has been cancelled and is read-only.
+
+
+ );
+ }
+
+ return (
+
+
+
+ Cancel WO
+
+
+
+
{
+ e.currentTarget.style.color = "var(--color-text-primary)";
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.color = "var(--color-text-muted)";
+ }}
+ >
+ Cancel
+
+
+ {editing ? (
+
+
+ Save
+
+ ) : (
+
+
+ Edit WO
+
+ )}
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/detail/slide-over-header.tsx b/src/app/(protected)/workorders/_components/detail/slide-over-header.tsx
new file mode 100644
index 00000000..0302625e
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/slide-over-header.tsx
@@ -0,0 +1,106 @@
+import { CircularProgress } from "@mui/material";
+import { X } from "lucide-react";
+import { WoTableStatusLabel } from "@/app/(protected)/workorders/_components/list/table/cells/wo-table-status-label";
+import { WoTableTypeLabel } from "@/app/(protected)/workorders/_components/list/table/cells/wo-table-type-label";
+import { Text } from "@/components/ui/text";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
+
+type SlideOverHeaderProps = {
+ row: WorkOrderTableRow;
+ isCancelled: boolean;
+ isFetching?: boolean;
+ closeDisabled?: boolean;
+ onClose: () => void;
+};
+
+export function SlideOverHeader({
+ row,
+ isCancelled,
+ isFetching = false,
+ closeDisabled = false,
+ onClose,
+}: SlideOverHeaderProps) {
+ return (
+
+
+
+ #{row.woNumber || "—"}
+
+
+ {isCancelled && (
+
+ Cancelled
+
+ )}
+
+ {Boolean(row.site) && (
+
+ {row.site}
+
+ )}
+
+ {row.isPastDue ? (
+
+
+ Past Due
+
+ ) : row.status ? (
+
+ ) : null}
+
+ {Boolean(row.type) && }
+
+
+
+ {isFetching && }
+ {
+ if (!closeDisabled) {
+ e.currentTarget.style.background = "var(--color-bg-muted)";
+ }
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.background = "transparent";
+ }}
+ >
+
+
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/detail/slide-over-info-row.tsx b/src/app/(protected)/workorders/_components/detail/slide-over-info-row.tsx
new file mode 100644
index 00000000..38942db6
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/slide-over-info-row.tsx
@@ -0,0 +1,22 @@
+import type { ReactNode } from "react";
+
+type SlideOverInfoRowProps = {
+ label: string;
+ children: ReactNode;
+};
+
+export function SlideOverInfoRow({ label, children }: SlideOverInfoRowProps) {
+ return (
+
+
+ {label}
+
+
+ {children}
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-audit-tab.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-audit-tab.tsx
new file mode 100644
index 00000000..1b8200e0
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-audit-tab.tsx
@@ -0,0 +1,11 @@
+import { SlideOverAuditList } from "@/app/(protected)/workorders/_components/detail/slide-over-audit-list";
+import type { WorkOrderAuditEntry, WorkOrderUser } from "@/domain/work-orders/types/work-order";
+
+type SlideOverAuditTabProps = {
+ entries: WorkOrderAuditEntry[];
+ users?: WorkOrderUser[];
+};
+
+export function SlideOverAuditTab({ entries, users }: SlideOverAuditTabProps) {
+ return ;
+}
diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-comments-tab.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-comments-tab.tsx
new file mode 100644
index 00000000..1d13f890
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-comments-tab.tsx
@@ -0,0 +1,265 @@
+import { useState } from "react";
+import { Pencil } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Text } from "@/components/ui/text";
+import type { WorkOrderComment } from "@/domain/work-orders/types/work-order";
+import { isOwnWorkOrderComment } from "@/domain/work-orders/utils/comment-ownership";
+import {
+ getAvatarColorFromName,
+ getInitialsFromName,
+} from "@/domain/work-orders/utils/dispatcher-avatar-color";
+
+function formatCommentTime(value: string): string {
+ if (!value) return "";
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) return value;
+ return date.toLocaleString("en-US", {
+ month: "short",
+ day: "numeric",
+ hour: "numeric",
+ minute: "2-digit",
+ });
+}
+
+const textareaStyle = {
+ border: "1px solid var(--color-border)",
+ color: "var(--color-text-primary)",
+ background: "var(--color-bg-surface)",
+} as const;
+
+type SlideOverCommentItemProps = {
+ comment: WorkOrderComment;
+ canEdit: boolean;
+ isEditing: boolean;
+ draftText: string;
+ isUpdatePending: boolean;
+ onStartEdit: () => void;
+ onDraftChange: (value: string) => void;
+ onCancelEdit: () => void;
+ onSaveEdit: () => void;
+};
+
+function SlideOverCommentItem({
+ comment,
+ canEdit,
+ isEditing,
+ draftText,
+ isUpdatePending,
+ onStartEdit,
+ onDraftChange,
+ onCancelEdit,
+ onSaveEdit,
+}: SlideOverCommentItemProps) {
+ const author = comment.userName || comment.commenter || "Unknown";
+ const color = getAvatarColorFromName(author);
+ const initials = getInitialsFromName(author);
+
+ return (
+
+
+ {initials}
+
+
+
+
+
+ {author}
+
+
+ {formatCommentTime(comment.createdDate)}
+
+
+ {canEdit && !isEditing && (
+
+
+
+ )}
+
+
+ {isEditing ? (
+
+ ) : (
+
+ {comment.commenttext}
+
+ )}
+
+
+ );
+}
+
+type SlideOverCommentsTabProps = {
+ comments: WorkOrderComment[];
+ isReadOnly: boolean;
+ isPending: boolean;
+ currentUserId?: string | number;
+ isUpdatePending?: boolean;
+ onSubmit: (text: string, options?: { onSuccess?: () => void }) => void;
+ onUpdateComment?: (
+ commentId: string | number,
+ text: string,
+ options?: { onSuccess?: () => void },
+ ) => void;
+};
+
+export function SlideOverCommentsTab({
+ comments,
+ isReadOnly,
+ isPending,
+ currentUserId,
+ isUpdatePending = false,
+ onSubmit,
+ onUpdateComment,
+}: SlideOverCommentsTabProps) {
+ const [commentText, setCommentText] = useState("");
+ const [editingCommentId, setEditingCommentId] = useState(null);
+ const [draftText, setDraftText] = useState("");
+
+ const handlePost = () => {
+ const text = commentText.trim();
+ if (!text || isReadOnly || isPending) return;
+ onSubmit(text, { onSuccess: () => setCommentText("") });
+ };
+
+ const startEdit = (comment: WorkOrderComment) => {
+ setEditingCommentId(comment.id);
+ setDraftText(comment.commenttext);
+ };
+
+ const cancelEdit = () => {
+ setEditingCommentId(null);
+ setDraftText("");
+ };
+
+ const saveEdit = () => {
+ if (editingCommentId == null || !onUpdateComment) return;
+ const text = draftText.trim();
+ if (!text || isUpdatePending) return;
+ onUpdateComment(editingCommentId, text, {
+ onSuccess: () => {
+ setEditingCommentId(null);
+ setDraftText("");
+ },
+ });
+ };
+
+ return (
+
+ {!isReadOnly && (
+ <>
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-actions.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-actions.tsx
new file mode 100644
index 00000000..51c262be
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-actions.tsx
@@ -0,0 +1,92 @@
+import type { RefObject } from "react";
+import { Ban, CheckCircle2, FileText, Upload } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Text } from "@/components/ui/text";
+import type { CompletionDocStatus } from "@/domain/work-orders/types/work-order-table-row";
+
+type SlideOverCompletionActionsProps = {
+ docStatus: CompletionDocStatus;
+ rowVersion?: string | number | null;
+ completionPending: boolean;
+ hasUploadHandler: boolean;
+ pdfInputRef: RefObject;
+ onPdfSelected: (files: FileList | null) => void;
+ onOpenCompletionDoc: () => void;
+};
+
+function DocStatusButtonContent({ docStatus }: { docStatus: CompletionDocStatus }) {
+ if (docStatus === "Yes") {
+ return (
+ <>
+
+ View / Edit Document
+ >
+ );
+ }
+ if (docStatus === "NN") {
+ return (
+ <>
+
+ Document Not Required — Review
+ >
+ );
+ }
+ return (
+ <>
+
+ Open completion document
+ >
+ );
+}
+
+export function SlideOverCompletionActions({
+ docStatus,
+ rowVersion,
+ completionPending,
+ hasUploadHandler,
+ pdfInputRef,
+ onPdfSelected,
+ onOpenCompletionDoc,
+}: SlideOverCompletionActionsProps) {
+ return (
+
+ {hasUploadHandler && (
+ <>
+ pdfInputRef.current?.click()}
+ startIcon={!completionPending ? : undefined}
+ >
+ Upload signed PDF
+
+ {
+ onPdfSelected(event.target.files);
+ event.target.value = "";
+ }}
+ />
+ {!rowVersion && (
+
+ Waiting for work order version before upload is available.
+
+ )}
+ >
+ )}
+
+
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-media.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-media.tsx
new file mode 100644
index 00000000..24e468d1
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-media.tsx
@@ -0,0 +1,52 @@
+import { Image as ImageIcon } from "lucide-react";
+import {
+ MediaUploader,
+ type MediaUploadRequest,
+} from "@/app/(protected)/workorders/_components/media/media-uploader";
+import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
+import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
+
+type SlideOverCompletionMediaProps = {
+ isReadOnly: boolean;
+ media: MediaFile[];
+ mediaPending: boolean;
+ onUploadMedia?: (requests: MediaUploadRequest[]) => void;
+ onCategorizeMedia?: (id: string, category: UploadableMediaCategory) => void;
+ onRemoveMedia?: (id: string) => void;
+ onRetryMedia?: (localId: string) => void;
+ onCancelMedia?: (localId: string) => void;
+};
+
+export function SlideOverCompletionMedia({
+ isReadOnly,
+ media,
+ mediaPending,
+ onUploadMedia,
+ onCategorizeMedia,
+ onRemoveMedia,
+ onRetryMedia,
+ onCancelMedia,
+}: SlideOverCompletionMediaProps) {
+ return (
+
+
+
+
+ Photos & Videos
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-readonly-status.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-readonly-status.tsx
new file mode 100644
index 00000000..e9310484
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-readonly-status.tsx
@@ -0,0 +1,45 @@
+import { CheckCircle2, FileText } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import type { CompletionDocStatus } from "@/domain/work-orders/types/work-order-table-row";
+
+type SlideOverCompletionReadonlyStatusProps = {
+ docStatus: CompletionDocStatus;
+ onOpenCompletionDoc: () => void;
+};
+
+export function SlideOverCompletionReadonlyStatus({
+ docStatus,
+ onOpenCompletionDoc,
+}: SlideOverCompletionReadonlyStatusProps) {
+ if (docStatus === "Yes") {
+ return (
+
+
+ View Document
+
+ );
+ }
+
+ return (
+
+
+
+ {docStatus === "NN"
+ ? "No completion document was required for this work order."
+ : "No completion document was generated."}
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-signoffs.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-signoffs.tsx
new file mode 100644
index 00000000..a0e51bf5
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-signoffs.tsx
@@ -0,0 +1,35 @@
+import type { WorkOrderBoardCompletion } from "@/domain/work-orders/types/work-order-board-detail";
+
+type SlideOverCompletionSignoffsProps = {
+ completion?: WorkOrderBoardCompletion;
+};
+
+export function SlideOverCompletionSignoffs({ completion }: SlideOverCompletionSignoffsProps) {
+ if (!completion || completion.dispatchSignoffs.length === 0) return null;
+
+ return (
+
+
+ Dispatch sign-offs
+
+ {completion.dispatchSignoffs.map((signoff, index) => (
+
+ {signoff.signoffType || "Sign-off"}
+ {signoff.name ? ` — ${signoff.name}` : ""}
+ {signoff.signedAt ? ` · ${signoff.signedAt}` : ""}
+
+ ))}
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab.tsx
new file mode 100644
index 00000000..290ec603
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab.tsx
@@ -0,0 +1,143 @@
+import { useRef } from "react";
+import { Download, FileText } from "lucide-react";
+import { toast } from "react-toastify";
+import type { MediaUploadRequest } from "@/app/(protected)/workorders/_components/media/media-uploader";
+import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
+import type { WorkOrderBoardCompletion } from "@/domain/work-orders/types/work-order-board-detail";
+import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
+import {
+ formatMediaSizeLimit,
+ isWorkOrderFileTooLarge,
+} from "@/domain/work-orders/utils/work-order-media-limits";
+import { SlideOverCompletionActions } from "./slide-over-completion-actions";
+import { SlideOverCompletionMedia } from "./slide-over-completion-media";
+import { SlideOverCompletionReadonlyStatus } from "./slide-over-completion-readonly-status";
+import { SlideOverCompletionSignoffs } from "./slide-over-completion-signoffs";
+
+function isMediaWO(type: WOType | ""): boolean {
+ return type === "Emergency" || type === "Reactive";
+}
+
+type SlideOverCompletionTabProps = {
+ row: WorkOrderTableRow;
+ isReadOnly: boolean;
+ completion?: WorkOrderBoardCompletion;
+ media: MediaFile[];
+ mediaPending?: boolean;
+ completionPending?: boolean;
+ onUploadMedia?: (requests: MediaUploadRequest[]) => void;
+ onCategorizeMedia?: (id: string, category: UploadableMediaCategory) => void;
+ onRemoveMedia?: (id: string) => void;
+ onRetryMedia?: (localId: string) => void;
+ onCancelMedia?: (localId: string) => void;
+ onUploadCompletionPdf?: (file: File) => void;
+ onOpenCompletionDoc: () => void;
+};
+
+export function SlideOverCompletionTab({
+ row,
+ isReadOnly,
+ completion,
+ media,
+ mediaPending = false,
+ completionPending = false,
+ onUploadMedia,
+ onCategorizeMedia,
+ onRemoveMedia,
+ onRetryMedia,
+ onCancelMedia,
+ onUploadCompletionPdf,
+ onOpenCompletionDoc,
+}: SlideOverCompletionTabProps) {
+ const pdfInputRef = useRef(null);
+ const docStatus = completion?.docStatus || row.docStatus;
+ const templateUrl = completion?.template?.templateUrl;
+ const signOffAttachment = completion?.signOffAttachment;
+
+ if (isMediaWO(row.type)) {
+ return (
+
+ );
+ }
+
+ const handlePdfSelected = (files: FileList | null) => {
+ const file = files?.[0];
+ if (!file || !onUploadCompletionPdf) return;
+ if (isWorkOrderFileTooLarge(file)) {
+ toast.error(`File exceeds the ${formatMediaSizeLimit()} limit.`);
+ return;
+ }
+ if (file.type !== "application/pdf" && !file.name.toLowerCase().endsWith(".pdf")) {
+ toast.error("Please upload a PDF file.");
+ return;
+ }
+ onUploadCompletionPdf(file);
+ };
+
+ return (
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-doc-badge.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-doc-badge.tsx
new file mode 100644
index 00000000..5282cda2
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-doc-badge.tsx
@@ -0,0 +1,44 @@
+import { Image as ImageIcon } from "lucide-react";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
+
+function isMediaWO(type: WOType | ""): boolean {
+ return type === "Emergency" || type === "Reactive";
+}
+
+const DOC_BADGE_STYLE: Record<"Yes" | "No" | "NN", { bg: string; text: string }> = {
+ Yes: { bg: "#F0FDF4", text: "#166534" },
+ No: { bg: "#FFF1F2", text: "#BE123C" },
+ NN: { bg: "#F3F4F6", text: "#6B7280" },
+};
+
+export function DocBadge({ row }: { row: WorkOrderTableRow }) {
+ if (isMediaWO(row.type)) {
+ return (
+
+
+ {row.mediaCount > 0 ? `${row.mediaCount} file${row.mediaCount !== 1 ? "s" : ""}` : "Media"}
+
+ );
+ }
+
+ const key = row.docStatus === "Yes" ? "Yes" : row.docStatus === "NN" ? "NN" : "No";
+ const style = DOC_BADGE_STYLE[key];
+ const label = key === "NN" ? "NN" : key;
+
+ return (
+
+ {label}
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab.tsx
new file mode 100644
index 00000000..b4c93318
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab.tsx
@@ -0,0 +1,163 @@
+import { useRef } from "react";
+import {
+ File as FileIcon,
+ FileText,
+ Film,
+ Image as ImageIcon,
+ Paperclip,
+ Upload,
+ X,
+} from "lucide-react";
+import { toast } from "react-toastify";
+import { Text } from "@/components/ui/text";
+import type { FileCategory, MediaFile } from "@/domain/work-orders/types/work-order-media";
+import {
+ canDeleteWorkOrderMedia,
+ formatMediaSizeLimit,
+ isWorkOrderFileTooLarge,
+ isWorkOrderMediaTypeAllowed,
+ workOrderMediaAcceptAttribute,
+} from "@/domain/work-orders/utils/work-order-media-limits";
+
+const FILETYPE_ICON: Record = {
+ image: ImageIcon,
+ video: Film,
+ pdf: FileText,
+ doc: FileIcon,
+ other: Paperclip,
+};
+
+type SlideOverExtrasTabProps = {
+ uploads: MediaFile[];
+ isReadOnly: boolean;
+ pending?: boolean;
+ onUpload: (files: File[]) => void;
+ onRemove: (id: string) => void;
+};
+
+export function SlideOverExtrasTab({
+ uploads,
+ isReadOnly,
+ pending = false,
+ onUpload,
+ onRemove,
+}: SlideOverExtrasTabProps) {
+ const inputRef = useRef(null);
+
+ const addFiles = (files: FileList | null) => {
+ if (isReadOnly || pending || !files || files.length === 0) return;
+ const accepted: File[] = [];
+ for (const file of Array.from(files)) {
+ if (!isWorkOrderMediaTypeAllowed(file)) {
+ toast.error(`"${file.name}" is not a supported type (JPG, PNG, MP4, MOV).`);
+ continue;
+ }
+ if (isWorkOrderFileTooLarge(file)) {
+ toast.error(`"${file.name}" exceeds the ${formatMediaSizeLimit()} limit.`);
+ continue;
+ }
+ accepted.push(file);
+ }
+ if (accepted.length > 0) {
+ onUpload(accepted);
+ }
+ };
+
+ return (
+
+
+
+
+ Extra Documents
+
+
+
+ {!isReadOnly && (
+ <>
+
inputRef.current?.click()}
+ onDragOver={(event) => event.preventDefault()}
+ onDrop={(event) => {
+ event.preventDefault();
+ addFiles(event.dataTransfer.files);
+ }}
+ className="flex w-full flex-col items-center justify-center gap-1.5 transition-colors hover:bg-[var(--color-bg-muted)] disabled:opacity-60"
+ style={{
+ minHeight: 100,
+ border: "1px dashed var(--color-border)",
+ borderRadius: 8,
+ }}
+ >
+
+
+ {pending ? "Uploading…" : "Upload documents, photos or videos"}
+
+
+ Accepted: JPG, PNG, MP4, MOV · Max {formatMediaSizeLimit()} per file
+
+
+
{
+ addFiles(event.target.files);
+ event.target.value = "";
+ }}
+ />
+ >
+ )}
+
+ {uploads.length === 0 ? (
+
+ No extra documents yet.
+
+ ) : (
+
+ {uploads.map((file) => {
+ const ItemIcon = FILETYPE_ICON[file.fileType];
+ const showDelete = !isReadOnly && canDeleteWorkOrderMedia(file);
+ return (
+
+
+
+ {file.name}
+
+ {showDelete && (
+
onRemove(file.id)}
+ className="rounded p-0.5 transition-opacity hover:opacity-70 disabled:opacity-50"
+ style={{ color: "var(--color-text-muted)" }}
+ >
+
+
+ )}
+
+ );
+ })}
+
+ )}
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-assigned-field.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-assigned-field.tsx
new file mode 100644
index 00000000..8d7bc8ba
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-assigned-field.tsx
@@ -0,0 +1,28 @@
+import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
+import { getDispatcherAvatarColor } from "@/domain/work-orders/utils/dispatcher-avatar-color";
+
+type SlideOverInfoTabAssignedFieldProps = {
+ dispatcher?: WorkOrderUser;
+ dispatcherName?: string;
+};
+
+export function SlideOverInfoTabAssignedField({
+ dispatcher,
+ dispatcherName,
+}: SlideOverInfoTabAssignedFieldProps) {
+ if (!dispatcher && !dispatcherName) {
+ return — ;
+ }
+
+ return (
+
+ {dispatcher != null && (
+
+ )}
+ {dispatcher?.name || dispatcherName}
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-display-view.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-display-view.tsx
new file mode 100644
index 00000000..ec9c3479
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-display-view.tsx
@@ -0,0 +1,92 @@
+import { SlideOverInfoRow } from "@/app/(protected)/workorders/_components/detail/slide-over-info-row";
+import { PmTypeIcon } from "@/app/(protected)/workorders/_components/list/table/pm-type-icons";
+import { WoTableTypeLabel } from "@/app/(protected)/workorders/_components/list/table/cells/wo-table-type-label";
+import type { VendorDropdownItem, WorkOrderUser } from "@/domain/work-orders/types/work-order";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
+import { formatApptRange, parseApptTime } from "@/domain/work-orders/utils/wizard-date-utils";
+import { DocBadge } from "./slide-over-doc-badge";
+import { SlideOverInfoTabAssignedField } from "./slide-over-info-tab-assigned-field";
+import { SlideOverInfoTabStatusField } from "./slide-over-info-tab-status-field";
+import { SlideOverInfoTabVendorField } from "./slide-over-info-tab-vendor-field";
+
+type SlideOverInfoTabDisplayViewProps = {
+ source: WorkOrderTableRow;
+ isCancelled: boolean;
+ dispatcher?: WorkOrderUser;
+ vendors: VendorDropdownItem[];
+};
+
+export function SlideOverInfoTabDisplayView({
+ source,
+ isCancelled,
+ dispatcher,
+ vendors,
+}: SlideOverInfoTabDisplayViewProps) {
+ const appt = parseApptTime(source.apptTime);
+ const apptDisplay = appt.start ? formatApptRange(appt.start, appt.end) : source.apptTime || "—";
+
+ return (
+
+
+ {source.woNumber || "—"}
+
+
+
+ {source.site || "—"}
+
+
+
+ {source.type ? (
+
+ ) : (
+ —
+ )}
+
+
+
+
+
+
+ {source.scheduleWeekOnly && source.targetWeek
+ ? `Week of ${source.targetWeek}`
+ : source.scheduledOn || "—"}
+
+
+
+ {source.dueDate || "—"}
+
+
+ {apptDisplay}
+
+
+ {source.pm ? (
+
+
+ {source.pm}
+
+ ) : (
+ —
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+ {source.pocName || "—"}
+ {source.pocPhone ? ` · ${source.pocPhone}` : ""}
+
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx
new file mode 100644
index 00000000..7eaf94f3
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx
@@ -0,0 +1,160 @@
+import { AssignedToCell } from "@/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell";
+import { ApptTimeCell } from "@/app/(protected)/workorders/_components/list/table/cells/appt-time-cell";
+import { DateCell } from "@/app/(protected)/workorders/_components/list/table/cells/date-cell";
+import { EditableWONumberCell } from "@/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell";
+import { PMTypeCell } from "@/app/(protected)/workorders/_components/list/table/cells/pm-type-cell";
+import { SiteCell } from "@/app/(protected)/workorders/_components/list/table/cells/site-cell";
+import { StatusCell } from "@/app/(protected)/workorders/_components/list/table/cells/status-cell";
+import { TypeCell } from "@/app/(protected)/workorders/_components/list/table/cells/type-cell";
+import { VendorCell } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-cell";
+import { SlideOverInfoRow } from "@/app/(protected)/workorders/_components/detail/slide-over-info-row";
+import type {
+ LocationOption,
+ VendorDropdownItem,
+ WorkOrderUser,
+} from "@/domain/work-orders/types/work-order";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+import type { WizardWOStatus } from "@/domain/work-orders/types/work-order-wizard";
+import { DocBadge } from "./slide-over-doc-badge";
+
+type SlideOverInfoTabEditViewProps = {
+ draft: WorkOrderTableRow;
+ users: WorkOrderUser[];
+ sites: LocationOption[];
+ vendors: VendorDropdownItem[];
+ pmTypes: string[];
+ allOrders: WorkOrderTableRow[];
+ onDraftChange: (patch: Partial) => void;
+ onDuplicateFound?: (existing: WorkOrderTableRow) => void;
+};
+
+export function SlideOverInfoTabEditView({
+ draft,
+ users,
+ sites,
+ vendors,
+ pmTypes,
+ allOrders,
+ onDraftChange,
+ onDuplicateFound,
+}: SlideOverInfoTabEditViewProps) {
+ return (
+
+
+ onDraftChange({ woNumber })}
+ onDuplicateFound={onDuplicateFound}
+ />
+
+
+
+ onDraftChange({
+ site: patch.site,
+ location: patch.location,
+ locationId: patch.locationId,
+ })
+ }
+ />
+
+
+ onDraftChange({ type })} />
+
+
+ onDraftChange({ dispatcherId: id, dispatcherName: name })}
+ />
+
+
+
+ onDraftChange({ scheduledOn, scheduleWeekOnly: false, isPastDue: false })
+ }
+ onSaveWeek={(weekStart) =>
+ onDraftChange({
+ scheduledOn: "",
+ targetWeek: weekStart,
+ scheduleWeekOnly: true,
+ isPastDue: false,
+ })
+ }
+ />
+
+
+ onDraftChange({ dueDate })} />
+
+
+ onDraftChange({ apptTime })}
+ />
+
+
+ onDraftChange({ pm })}
+ onSaveDetails={(details) =>
+ onDraftChange({
+ serviceNotes: details.serviceNotes,
+ extraServices: details.extraServices,
+ })
+ }
+ />
+
+
+
+ onDraftChange({
+ vendorId: patch.vendorId,
+ company: patch.company,
+ tech: patch.tech,
+ techPhone: patch.techPhone,
+ })
+ }
+ />
+
+
+ onDraftChange({ status })}
+ onOpenSlideOver={() => {}}
+ />
+
+
+
+
+
+
+ {draft.pocName || "—"}
+ {draft.pocPhone ? ` · ${draft.pocPhone}` : ""}
+
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-status-field.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-status-field.tsx
new file mode 100644
index 00000000..a01d741d
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-status-field.tsx
@@ -0,0 +1,26 @@
+import { WoTableStatusLabel } from "@/app/(protected)/workorders/_components/list/table/cells/wo-table-status-label";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+
+type SlideOverInfoTabStatusFieldProps = {
+ source: WorkOrderTableRow;
+};
+
+export function SlideOverInfoTabStatusField({ source }: SlideOverInfoTabStatusFieldProps) {
+ if (source.isPastDue) {
+ return (
+
+
+ Past Due
+
+ );
+ }
+
+ if (source.status) {
+ return ;
+ }
+
+ return — ;
+}
diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-vendor-field.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-vendor-field.tsx
new file mode 100644
index 00000000..389ff87e
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-vendor-field.tsx
@@ -0,0 +1,45 @@
+import { useState } from "react";
+import { VendorDialog } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog";
+import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+
+type SlideOverInfoTabVendorFieldProps = {
+ source: WorkOrderTableRow;
+ vendors: VendorDropdownItem[];
+};
+
+export function SlideOverInfoTabVendorField({ source, vendors }: SlideOverInfoTabVendorFieldProps) {
+ const [vendorDlg, setVendorDlg] = useState(false);
+
+ if (!source.tech && !source.company) {
+ return — ;
+ }
+
+ return (
+ <>
+ setVendorDlg(true)}
+ className="text-left transition-opacity hover:opacity-80"
+ >
+
+ {source.tech || source.company}
+
+ {Boolean(source.tech && source.company) && (
+
+ {source.company}
+
+ )}
+
+ setVendorDlg(false)}
+ />
+ >
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab.tsx
new file mode 100644
index 00000000..c8d9e09b
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab.tsx
@@ -0,0 +1,64 @@
+import type {
+ LocationOption,
+ VendorDropdownItem,
+ WorkOrderUser,
+} from "@/domain/work-orders/types/work-order";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+import { SlideOverInfoTabDisplayView } from "./slide-over-info-tab-display-view";
+import { SlideOverInfoTabEditView } from "./slide-over-info-tab-edit-view";
+
+type SlideOverInfoTabProps = {
+ row: WorkOrderTableRow;
+ draft: WorkOrderTableRow;
+ editing: boolean;
+ isCancelled: boolean;
+ users: WorkOrderUser[];
+ sites: LocationOption[];
+ vendors: VendorDropdownItem[];
+ pmTypes: string[];
+ allOrders: WorkOrderTableRow[];
+ onDraftChange: (patch: Partial) => void;
+ onDuplicateFound?: (existing: WorkOrderTableRow) => void;
+};
+
+export function SlideOverInfoTab({
+ row,
+ draft,
+ editing,
+ isCancelled,
+ users,
+ sites,
+ vendors,
+ pmTypes,
+ allOrders,
+ onDraftChange,
+ onDuplicateFound,
+}: SlideOverInfoTabProps) {
+ const isEditing = editing && !isCancelled;
+ const source = isEditing ? draft : row;
+ const dispatcher = users.find((u) => String(u.id) === source.dispatcherId);
+
+ if (isEditing) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/detail/use-slide-over-edit-state.ts b/src/app/(protected)/workorders/_components/detail/use-slide-over-edit-state.ts
new file mode 100644
index 00000000..ec74d498
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/use-slide-over-edit-state.ts
@@ -0,0 +1,162 @@
+import { useEffect, useMemo, useState } from "react";
+import type {
+ WorkOrderTablePatch,
+ WorkOrderTableRow,
+} from "@/domain/work-orders/types/work-order-table-row";
+import {
+ buildSlideOverPatch,
+ isSlideOverDraftDirty,
+} from "@/domain/work-orders/utils/slide-over-draft";
+import type { SlideOverSaveOptions } from "./work-order-slide-over";
+import type { SlideOverTab } from "./use-work-order-slide-over";
+
+type UseSlideOverEditStateArgs = {
+ row: WorkOrderTableRow | null;
+ editMode: boolean;
+ infoSource: WorkOrderTableRow | null | undefined;
+ onSave: (id: string | number, patch: WorkOrderTablePatch, options?: SlideOverSaveOptions) => void;
+ onClose: () => void;
+ closeDisabled: boolean;
+ setTab: (tab: SlideOverTab) => void;
+};
+
+export function useSlideOverEditState({
+ row,
+ editMode,
+ infoSource,
+ onSave,
+ onClose,
+ closeDisabled,
+ setTab,
+}: UseSlideOverEditStateArgs) {
+ const [editing, setEditing] = useState(false);
+ const [draft, setDraft] = useState(null);
+ /** Last confirmed baseline for dirty/patch — not overwritten by optimistic parent row. */
+ const [baseline, setBaseline] = useState(null);
+ const [showUnsaved, setShowUnsaved] = useState(false);
+ const [unsavedIntent, setUnsavedIntent] = useState<"close" | "cancel-edit" | null>(null);
+
+ // Reset draft/baseline only when the opened WO identity or editMode intent changes —
+ // not on every optimistic parent-row field update during a failed/in-flight save.
+ useEffect(() => {
+ if (!row) {
+ setDraft(null);
+ setBaseline(null);
+ setEditing(false);
+ setShowUnsaved(false);
+ setUnsavedIntent(null);
+ return;
+ }
+ setDraft(row);
+ setBaseline(row);
+ setEditing(editMode && (row.status as string) !== "Canceled");
+ setTab("info");
+ setShowUnsaved(false);
+ setUnsavedIntent(null);
+ // Intentionally identity-only: depending on `row` would absorb optimistic patches.
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- row.id / editMode gate
+ }, [row?.id, editMode]);
+
+ useEffect(() => {
+ if (!infoSource) return;
+ if (!editing) {
+ setDraft(infoSource);
+ setBaseline(infoSource);
+ return;
+ }
+ // While editing, freeze baseline so optimistic parent-row patches cannot clear dirty.
+ setDraft((prev) => {
+ if (prev && baseline && isSlideOverDraftDirty(prev, baseline)) return prev;
+ return infoSource;
+ });
+ }, [infoSource, editing, baseline]);
+
+ const activeDraft = draft ?? infoSource;
+ const dirtyBaseline = baseline ?? infoSource;
+ const isDirty = useMemo(() => {
+ if (!dirtyBaseline || !activeDraft || !editing) return false;
+ return isSlideOverDraftDirty(activeDraft, dirtyBaseline);
+ }, [activeDraft, dirtyBaseline, editing]);
+
+ const handleDraftChange = (patch: Partial) => {
+ setDraft((prev) => (prev ? { ...prev, ...patch } : prev));
+ };
+
+ const commit = () => {
+ if (!row || !activeDraft || !dirtyBaseline) return;
+ const patch = buildSlideOverPatch(activeDraft, dirtyBaseline);
+ if (Object.keys(patch).length === 0) {
+ setEditing(false);
+ setShowUnsaved(false);
+ return;
+ }
+ onSave(row.id, patch, {
+ onSuccess: () => {
+ setBaseline(activeDraft);
+ setEditing(false);
+ setShowUnsaved(false);
+ },
+ });
+ };
+
+ const discard = () => {
+ if (!dirtyBaseline) return;
+ setDraft(dirtyBaseline);
+ setEditing(false);
+ setShowUnsaved(false);
+ };
+
+ const requestClose = () => {
+ if (closeDisabled) return;
+ if (editing && isDirty) {
+ setUnsavedIntent("close");
+ setShowUnsaved(true);
+ return;
+ }
+ onClose();
+ };
+
+ const handleCancelEdit = () => {
+ if (isDirty) {
+ setUnsavedIntent("cancel-edit");
+ setShowUnsaved(true);
+ return;
+ }
+ if (dirtyBaseline) setDraft(dirtyBaseline);
+ setEditing(false);
+ };
+
+ const handleDiscard = () => {
+ const intent = unsavedIntent;
+ discard();
+ setUnsavedIntent(null);
+ // Close only when Discard was opened from panel close — never from cancel-edit.
+ // Skip when uploads block close (closeDisabled).
+ if (intent === "close" && !closeDisabled) {
+ onClose();
+ }
+ };
+
+ const startEditing = () => {
+ if (!infoSource) return;
+ setDraft(infoSource);
+ setBaseline(infoSource);
+ setEditing(true);
+ setTab("info");
+ };
+
+ return {
+ editing,
+ activeDraft,
+ isDirty,
+ showUnsaved,
+ setShowUnsaved,
+ handleDraftChange,
+ commit,
+ discard,
+ handleDiscard,
+ requestClose,
+ handleCancelEdit,
+ startEditing,
+ };
+}
diff --git a/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts b/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts
new file mode 100644
index 00000000..eb6e3e33
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts
@@ -0,0 +1,101 @@
+import { useMemo, useState } from "react";
+import type { MediaUploadRequest } from "@/app/(protected)/workorders/_components/media/media-uploader";
+import { useMediaUploadSession } from "@/app/(protected)/workorders/_hooks/use-media-upload-session";
+import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
+import { boardMediaItemsToMediaFiles } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
+import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail";
+import type {
+ useDeleteWorkOrderMedia,
+ useUpdateWorkOrderMediaCategory,
+ useUploadWorkOrderMedia,
+} from "@/domain/work-orders/use-cases/use-work-order-mutations";
+
+const COMPLETION_MEDIA_PURPOSES = new Set(["Before", "After", "Completion"]);
+
+type UseSlideOverMediaStateArgs = {
+ detailForRow?: WorkOrderBoardDetail;
+ uploadMediaMutation: ReturnType;
+ deleteMediaMutation: ReturnType;
+ categorizeMediaMutation: ReturnType;
+};
+
+export function useSlideOverMediaState({
+ detailForRow,
+ uploadMediaMutation,
+ deleteMediaMutation,
+ categorizeMediaMutation,
+}: UseSlideOverMediaStateArgs) {
+ const [mediaBatchPending, setMediaBatchPending] = useState(false);
+ const { uploads, startUploads, retryUpload, cancelUpload, removeLocal, isLocal } =
+ useMediaUploadSession(detailForRow, (input) => uploadMediaMutation.mutateAsync(input));
+
+ const completionMedia = useMemo(
+ () =>
+ uploads.filter(
+ (item) =>
+ COMPLETION_MEDIA_PURPOSES.has(item.purpose ?? "") ||
+ item.uploadStatus === "uploading" ||
+ item.uploadStatus === "failed",
+ ),
+ [uploads],
+ );
+
+ const extrasUploads = useMemo(
+ () =>
+ boardMediaItemsToMediaFiles(
+ (detailForRow?.media ?? []).filter((item) => item.category === "Extra"),
+ ),
+ [detailForRow?.media],
+ );
+
+ const hasLocalUploading = uploads.some((item) => item.uploadStatus === "uploading");
+ const mediaPending =
+ mediaBatchPending ||
+ hasLocalUploading ||
+ uploadMediaMutation.isPending ||
+ deleteMediaMutation.isPending ||
+ categorizeMediaMutation.isPending;
+
+ const workOrderVersion = detailForRow?.info.rowVersion?.trim() ?? "";
+
+ const uploadMediaRequests = (requests: MediaUploadRequest[]) => {
+ startUploads(requests);
+ };
+
+ const uploadExtraFiles = async (files: File[]) => {
+ setMediaBatchPending(true);
+ try {
+ for (const file of files) {
+ await uploadMediaMutation.mutateAsync({ file, category: "Extra" });
+ }
+ } finally {
+ setMediaBatchPending(false);
+ }
+ };
+
+ const categorizeMedia = (mediaId: string, category: UploadableMediaCategory) => {
+ if (!workOrderVersion) return;
+ categorizeMediaMutation.mutate({ mediaId, category, workOrderVersion });
+ };
+
+ const removeMedia = (id: string) => {
+ if (isLocal(id)) {
+ removeLocal(id);
+ return;
+ }
+ if (!workOrderVersion) return;
+ deleteMediaMutation.mutate({ mediaId: id, workOrderVersion });
+ };
+
+ return {
+ completionMedia,
+ extrasUploads,
+ mediaPending,
+ uploadMediaRequests,
+ uploadExtraFiles,
+ categorizeMedia,
+ retryUpload,
+ cancelUpload,
+ removeMedia,
+ };
+}
diff --git a/src/app/(protected)/workorders/_components/detail/use-work-order-slide-over.ts b/src/app/(protected)/workorders/_components/detail/use-work-order-slide-over.ts
new file mode 100644
index 00000000..9120bb5f
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/use-work-order-slide-over.ts
@@ -0,0 +1,160 @@
+import { useState } from "react";
+import { toast } from "react-toastify";
+import type {
+ WorkOrderTablePatch,
+ WorkOrderTableRow,
+} from "@/domain/work-orders/types/work-order-table-row";
+import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
+import {
+ useAddWorkOrderComment,
+ useDeleteWorkOrderMedia,
+ useUpdateWorkOrderComment,
+ useUpdateWorkOrderMediaCategory,
+ useUploadCompletionDoc,
+ useUploadWorkOrderMedia,
+} from "@/domain/work-orders/use-cases/use-work-order-mutations";
+import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits";
+import { todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
+import { useAuthContext } from "@/providers/auth-context";
+import { useSlideOverEditState } from "./use-slide-over-edit-state";
+import { useSlideOverMediaState } from "./use-slide-over-media-state";
+import type { SlideOverSaveOptions } from "./work-order-slide-over";
+
+export type SlideOverTab = "info" | "comments" | "audit" | "completion" | "extras";
+
+type UseWorkOrderSlideOverArgs = {
+ row: WorkOrderTableRow | null;
+ editMode: boolean;
+ onSave: (id: string | number, patch: WorkOrderTablePatch, options?: SlideOverSaveOptions) => void;
+ onClose: () => void;
+ saving?: boolean;
+};
+
+export function useWorkOrderSlideOver({
+ row,
+ editMode,
+ onSave,
+ onClose,
+ saving,
+}: UseWorkOrderSlideOverArgs) {
+ const { user } = useAuthContext();
+ const workOrderId = row?.id;
+ const {
+ data: detail,
+ isLoading,
+ isFetching,
+ error,
+ refetch,
+ } = useWorkOrderBoardDetail(workOrderId, Boolean(row));
+ const commentMutation = useAddWorkOrderComment(workOrderId ?? "");
+ const updateCommentMutation = useUpdateWorkOrderComment(workOrderId ?? "");
+ const uploadMediaMutation = useUploadWorkOrderMedia(workOrderId ?? "");
+ const categorizeMediaMutation = useUpdateWorkOrderMediaCategory(workOrderId ?? "");
+ const deleteMediaMutation = useDeleteWorkOrderMedia(workOrderId ?? "");
+ const uploadCompletionMutation = useUploadCompletionDoc(workOrderId ?? "");
+
+ const [tab, setTab] = useState("info");
+
+ // Prefer row until detail for this id arrives — avoids cross-WO bleed while loading.
+ const detailForRow =
+ detail && row && String(detail.info.id) === String(row.id) ? detail : undefined;
+ const infoSource = detailForRow?.info ?? row;
+ const isCancelled = (infoSource?.status as string) === "Canceled";
+ const isDocumentsReadOnly = isWorkOrderDocumentsReadOnly(infoSource?.status);
+
+ const {
+ completionMedia,
+ extrasUploads,
+ mediaPending,
+ uploadMediaRequests,
+ uploadExtraFiles,
+ categorizeMedia,
+ retryUpload,
+ cancelUpload,
+ removeMedia,
+ } = useSlideOverMediaState({
+ detailForRow,
+ uploadMediaMutation,
+ deleteMediaMutation,
+ categorizeMediaMutation,
+ });
+ const completionPending = uploadCompletionMutation.isPending;
+ const closeDisabled = Boolean(saving || mediaPending || completionPending);
+
+ const editState = useSlideOverEditState({
+ row,
+ editMode,
+ infoSource,
+ onSave,
+ onClose,
+ closeDisabled,
+ setTab,
+ });
+
+ const uploadCompletionPdf = (file: File) => {
+ const version = infoSource?.rowVersion;
+ if (!version || !row || !infoSource) {
+ toast.error("Missing work order version. Refresh and try again.");
+ return;
+ }
+ uploadCompletionMutation.mutate(
+ { file, workOrderVersion: version },
+ {
+ onSuccess: async () => {
+ // Mirror CompDoc finalize: ensure completedDate then mark docStatus Yes.
+ const patch: WorkOrderTablePatch = { docStatus: "Yes" };
+ if (!infoSource.completedDate) {
+ patch.completedDate = todayIso();
+ }
+ try {
+ await new Promise((resolve, reject) => {
+ onSave(row.id, patch, {
+ onSuccess: () => resolve(),
+ onError: (error) => reject(error),
+ });
+ });
+ } catch {
+ toast.error("Uploaded PDF but failed to update document status. Refresh and retry.");
+ }
+ },
+ },
+ );
+ };
+
+ const showDetailSkeleton = isLoading && !detailForRow;
+ const errorMessage =
+ error instanceof Error ? error.message : "Failed to load work order details.";
+
+ return {
+ user,
+ detail,
+ isLoading,
+ isFetching,
+ error,
+ refetch,
+ commentMutation,
+ updateCommentMutation,
+ tab,
+ setTab,
+ detailForRow,
+ infoSource,
+ isCancelled,
+ isDocumentsReadOnly,
+ completionMedia,
+ extrasUploads,
+ mediaPending,
+ completionPending,
+ closeDisabled,
+ uploadMediaRequests,
+ uploadExtraFiles,
+ categorizeMedia,
+ retryUpload,
+ cancelUpload,
+ removeMedia,
+ deleteMediaMutation,
+ uploadCompletionPdf,
+ showDetailSkeleton,
+ errorMessage,
+ ...editState,
+ };
+}
diff --git a/src/app/(protected)/workorders/_components/detail/work-order-activity-section.tsx b/src/app/(protected)/workorders/_components/detail/work-order-activity-section.tsx
new file mode 100644
index 00000000..7b72c393
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/work-order-activity-section.tsx
@@ -0,0 +1,101 @@
+import { useMemo, useState } from "react";
+import { Box, Paper, Tab, Tabs, TextField, Typography } from "@mui/material";
+import { CommentTimeline } from "@/components/common/comment-timeline";
+import { mapWorkOrderCommentsToTimeline } from "@/components/common/comment-timeline-mappers";
+import { Button } from "@/components/ui/button";
+import { AuditTimeline } from "./audit-timeline";
+import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order";
+
+type ActivityTab = "customer" | "internal" | "audit";
+
+type WorkOrderActivitySectionProps = {
+ wo: WorkOrderDetail;
+ onCommentSubmit: (
+ text: string,
+ commentType: "customer" | "internal",
+ options?: { onSuccess?: () => void },
+ ) => void;
+ isCommentPending: boolean;
+};
+
+export function WorkOrderActivitySection({
+ wo,
+ onCommentSubmit,
+ isCommentPending,
+}: WorkOrderActivitySectionProps) {
+ const [activeTab, setActiveTab] = useState("customer");
+ const [commentText, setCommentText] = useState("");
+
+ const customerComments = useMemo(
+ () => (wo.comments ?? []).filter((comment) => comment.commentType === "customer"),
+ [wo.comments],
+ );
+ const internalComments = useMemo(
+ () => (wo.comments ?? []).filter((comment) => comment.commentType === "internal"),
+ [wo.comments],
+ );
+ const auditLog = wo.auditLog ?? [];
+
+ const handleCommentSubmit = (event: React.FormEvent) => {
+ event.preventDefault();
+ if (!commentText.trim()) {
+ return;
+ }
+ onCommentSubmit(commentText.trim(), activeTab === "customer" ? "customer" : "internal", {
+ onSuccess: () => setCommentText(""),
+ });
+ };
+
+ return (
+
+
+ Activity
+
+ setActiveTab(value)}
+ className="mb-3"
+ >
+
+
+
+
+
+ {activeTab === "customer" && (
+
+ )}
+ {activeTab === "internal" && (
+
+ )}
+ {activeTab === "audit" && }
+
+ {activeTab === "internal" && (
+
+ setCommentText(event.target.value)}
+ placeholder="Type a comment..."
+ disabled={isCommentPending}
+ />
+
+ Send
+
+
+ )}
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/detail/work-order-assignee-menu.tsx b/src/app/(protected)/workorders/_components/detail/work-order-assignee-menu.tsx
new file mode 100644
index 00000000..fed7b761
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/work-order-assignee-menu.tsx
@@ -0,0 +1,56 @@
+import { Button, Menu, MenuItem } from "@mui/material";
+import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
+import { useMenuAnchor } from "@/hooks/use-menu-anchor";
+
+type WorkOrderAssigneeMenuProps = {
+ assignedTo: string;
+ users: WorkOrderUser[];
+ disabled?: boolean;
+ onAssign: (userId: string | number, userName: string) => void;
+};
+
+export function WorkOrderAssigneeMenu({
+ assignedTo,
+ users,
+ disabled = false,
+ onAssign,
+}: WorkOrderAssigneeMenuProps) {
+ const { anchorEl, open, setAnchor, close } = useMenuAnchor();
+
+ return (
+ <>
+ setAnchor(event.currentTarget)}
+ >
+ {disabled ? "Updating…" : assignedTo || "Unassigned"}
+
+
+ {
+ close();
+ onAssign("", "");
+ }}
+ >
+ Unassigned
+
+ {users.map((user) => (
+ {
+ close();
+ onAssign(user.id, user.name);
+ }}
+ >
+ {user.name}
+
+ ))}
+
+ >
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/detail/work-order-description-accordion.tsx b/src/app/(protected)/workorders/_components/detail/work-order-description-accordion.tsx
new file mode 100644
index 00000000..ec67beae
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/work-order-description-accordion.tsx
@@ -0,0 +1,20 @@
+import { Accordion, AccordionDetails, AccordionSummary, Typography } from "@mui/material";
+import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
+import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order";
+
+type WorkOrderDescriptionAccordionProps = {
+ wo: WorkOrderDetail;
+};
+
+export function WorkOrderDescriptionAccordion({ wo }: WorkOrderDescriptionAccordionProps) {
+ return (
+
+ }>
+ Description
+
+
+ {wo.description || "No description provided."}
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/detail/work-order-detail-sections.tsx b/src/app/(protected)/workorders/_components/detail/work-order-detail-sections.tsx
new file mode 100644
index 00000000..d1ba2d09
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/work-order-detail-sections.tsx
@@ -0,0 +1,49 @@
+import type { WorkOrderDetail, WorkOrderUser } from "@/domain/work-orders/types/work-order";
+import { WorkOrderActivitySection } from "./work-order-activity-section";
+import { WorkOrderDescriptionAccordion } from "./work-order-description-accordion";
+import { WorkOrderDetailsCard } from "./work-order-details-card";
+import { WorkOrderDispatchesAccordion } from "./work-order-dispatches-accordion";
+import { WorkOrderSimpleAccordions } from "./work-order-simple-accordions";
+
+type WorkOrderDetailsSectionProps = {
+ wo: WorkOrderDetail;
+ users: WorkOrderUser[];
+ onAssign: (userId: string | number, userName: string) => void;
+ isAssignPending?: boolean;
+ onDispatchClick: (dispatchId: string | number) => void;
+ onCommentSubmit: (
+ text: string,
+ commentType: "customer" | "internal",
+ options?: { onSuccess?: () => void },
+ ) => void;
+ isCommentPending: boolean;
+};
+
+export function WorkOrderDetailsSection({
+ wo,
+ users,
+ onAssign,
+ isAssignPending = false,
+ onDispatchClick,
+ onCommentSubmit,
+ isCommentPending,
+}: WorkOrderDetailsSectionProps) {
+ return (
+ <>
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/detail/work-order-details-card.tsx b/src/app/(protected)/workorders/_components/detail/work-order-details-card.tsx
new file mode 100644
index 00000000..fb332ce1
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/work-order-details-card.tsx
@@ -0,0 +1,45 @@
+import { Box, Paper, Typography } from "@mui/material";
+import { DetailField, DetailFieldGrid } from "@/components/common/detail-field";
+import { WorkOrderAssigneeMenu } from "./work-order-assignee-menu";
+import type { WorkOrderDetail, WorkOrderUser } from "@/domain/work-orders/types/work-order";
+
+type WorkOrderDetailsCardProps = {
+ wo: WorkOrderDetail;
+ users: WorkOrderUser[];
+ onAssign: (userId: string | number, userName: string) => void;
+ isAssignPending?: boolean;
+};
+
+export function WorkOrderDetailsCard({
+ wo,
+ users,
+ onAssign,
+ isAssignPending = false,
+}: WorkOrderDetailsCardProps) {
+ return (
+
+
+ Details
+
+
+
+
+
+
+
+
+ Assigned To
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/detail/work-order-dispatches-accordion.tsx b/src/app/(protected)/workorders/_components/detail/work-order-dispatches-accordion.tsx
new file mode 100644
index 00000000..30de5b9e
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/work-order-dispatches-accordion.tsx
@@ -0,0 +1,85 @@
+import {
+ Accordion,
+ AccordionDetails,
+ AccordionSummary,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
+ Typography,
+} from "@mui/material";
+import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
+import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order";
+import { formatDateTime, getWaitTimeTextClass, timeSince, waitTimeColor } from "@/lib/time-utils";
+
+type WorkOrderDispatchesAccordionProps = {
+ wo: WorkOrderDetail;
+ onDispatchClick: (dispatchId: string | number) => void;
+};
+
+export function WorkOrderDispatchesAccordion({
+ wo,
+ onDispatchClick,
+}: WorkOrderDispatchesAccordionProps) {
+ return (
+
+ }>
+ Dispatches ({wo.dispatches.length})
+
+
+ {wo.dispatches.length ? (
+
+
+
+
+ Dispatch #
+ Vendor
+ NTE
+ Status
+ Date
+ Wait
+
+
+
+ {wo.dispatches.map((dispatch) => {
+ const color = waitTimeColor(dispatch.dispatchedAt);
+ return (
+ onDispatchClick(dispatch.id)}
+ >
+ {dispatch.dispatchNumber || dispatch.poNumber}
+ {dispatch.vendorName}
+
+ {dispatch.nteAmount != null ? `$${dispatch.nteAmount.toFixed(2)}` : "—"}
+
+ {dispatch.status}
+
+ {dispatch.dispatchedAt ? formatDateTime(dispatch.dispatchedAt) : "—"}
+
+
+ {dispatch.status === "Sent" ? (
+
+ {timeSince(dispatch.dispatchedAt)}
+
+ ) : (
+ "—"
+ )}
+
+
+ );
+ })}
+
+
+
+ ) : (
+ No dispatches yet.
+ )}
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/detail/work-order-simple-accordions.tsx b/src/app/(protected)/workorders/_components/detail/work-order-simple-accordions.tsx
new file mode 100644
index 00000000..9c8b4214
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/work-order-simple-accordions.tsx
@@ -0,0 +1,61 @@
+import { Accordion, AccordionDetails, AccordionSummary, Box, Typography } from "@mui/material";
+import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
+import { DetailField, DetailFieldGrid } from "@/components/common/detail-field";
+import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order";
+import { formatDateTime } from "@/lib/time-utils";
+
+type WorkOrderSimpleAccordionsProps = {
+ wo: WorkOrderDetail;
+};
+
+export function WorkOrderSimpleAccordions({ wo }: WorkOrderSimpleAccordionsProps) {
+ return (
+ <>
+
+ }>
+ Classification
+
+
+
+
+
+
+
+
+
+
+
+ }>
+ Schedule
+
+
+
+
+
+
+
+
+
+
+
+
+ }>
+ Financial
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tab-nav.tsx b/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tab-nav.tsx
new file mode 100644
index 00000000..2efcac54
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tab-nav.tsx
@@ -0,0 +1,52 @@
+import type { SlideOverTab } from "./use-work-order-slide-over";
+
+const SLIDE_OVER_TABS: { id: SlideOverTab; label: string }[] = [
+ { id: "info", label: "Info" },
+ { id: "comments", label: "Comments" },
+ { id: "audit", label: "Audit Log" },
+ { id: "completion", label: "Completion Doc" },
+ { id: "extras", label: "Extra Docs" },
+];
+
+type WorkOrderSlideOverTabNavProps = {
+ tab: SlideOverTab;
+ saving?: boolean;
+ onTabChange: (tab: SlideOverTab) => void;
+};
+
+export function WorkOrderSlideOverTabNav({
+ tab,
+ saving,
+ onTabChange,
+}: WorkOrderSlideOverTabNavProps) {
+ return (
+
+ {SLIDE_OVER_TABS.map((item) => {
+ const active = tab === item.id;
+ return (
+ onTabChange(item.id)}
+ className="shrink-0 border-b-2 pb-2.5 text-[12px] transition-colors disabled:opacity-50"
+ style={{
+ borderBottomColor: active ? "var(--color-primary)" : "transparent",
+ color: active ? "var(--color-primary)" : "var(--color-text-muted)",
+ fontWeight: active ? 500 : 400,
+ }}
+ >
+ {item.label}
+
+ );
+ })}
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tab-panel.tsx b/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tab-panel.tsx
new file mode 100644
index 00000000..9f682b89
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tab-panel.tsx
@@ -0,0 +1,33 @@
+import type { ReactNode } from "react";
+import { QueryErrorPanel } from "@/components/ui/query-error-panel";
+import { SlideOverTabSkeleton } from "@/app/(protected)/workorders/_components/loading/slide-over-tab-skeleton";
+
+type SlideOverTabSkeletonVariant = "comments" | "audit" | "completion" | "extras";
+
+type WorkOrderSlideOverTabPanelProps = {
+ variant: SlideOverTabSkeletonVariant;
+ showSkeleton: boolean;
+ hasError: boolean;
+ errorMessage: string;
+ onRetry: () => void;
+ showContent: boolean;
+ children: ReactNode;
+};
+
+export function WorkOrderSlideOverTabPanel({
+ variant,
+ showSkeleton,
+ hasError,
+ errorMessage,
+ onRetry,
+ showContent,
+ children,
+}: WorkOrderSlideOverTabPanelProps) {
+ return (
+ <>
+ {showSkeleton && }
+ {hasError && }
+ {showContent && children}
+ >
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx b/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx
new file mode 100644
index 00000000..7b174a87
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx
@@ -0,0 +1,156 @@
+import { Box } from "@mui/material";
+import { SlideOverAuditTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-audit-tab";
+import { SlideOverCommentsTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-comments-tab";
+import { SlideOverCompletionTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab";
+import { SlideOverExtrasTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab";
+import { SlideOverInfoTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab";
+import type {
+ LocationOption,
+ VendorDropdownItem,
+ WorkOrderUser,
+} from "@/domain/work-orders/types/work-order";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+import type { useWorkOrderSlideOver, SlideOverTab } from "./use-work-order-slide-over";
+import { WorkOrderSlideOverTabPanel } from "./work-order-slide-over-tab-panel";
+
+type SlideOverState = ReturnType;
+
+type WorkOrderSlideOverTabsProps = {
+ tab: SlideOverTab;
+ state: SlideOverState;
+ saving?: boolean;
+ users: WorkOrderUser[];
+ sites: LocationOption[];
+ vendors: VendorDropdownItem[];
+ pmTypes: string[];
+ allOrders: WorkOrderTableRow[];
+ onDuplicateFound?: (existing: WorkOrderTableRow) => void;
+ onOpenCompletionDoc: () => void;
+};
+
+export function WorkOrderSlideOverTabs({
+ tab,
+ state,
+ saving,
+ users,
+ sites,
+ vendors,
+ pmTypes,
+ allOrders,
+ onDuplicateFound,
+ onOpenCompletionDoc,
+}: WorkOrderSlideOverTabsProps) {
+ const hasError = Boolean(state.error) && !state.detailForRow;
+ const showContent = Boolean(state.detailForRow || !state.isLoading);
+ const { infoSource, activeDraft } = state;
+
+ return (
+
+ {tab === "info" && infoSource != null && activeDraft != null && (
+
+ )}
+
+ {tab === "comments" && (
+ void state.refetch()}
+ showContent={state.detailForRow != null}
+ >
+ {state.detailForRow != null && (
+ state.commentMutation.mutate({ text }, options)}
+ onUpdateComment={(commentId, text, options) =>
+ state.updateCommentMutation.mutate({ commentId, text }, options)
+ }
+ />
+ )}
+
+ )}
+
+ {tab === "audit" && (
+ void state.refetch()}
+ showContent={state.detailForRow != null}
+ >
+ {state.detailForRow != null && (
+
+ )}
+
+ )}
+
+ {tab === "completion" && state.infoSource != null && (
+ void state.refetch()}
+ showContent={showContent}
+ >
+ {
+ state.uploadMediaRequests(requests);
+ }}
+ onCategorizeMedia={state.categorizeMedia}
+ onRemoveMedia={state.removeMedia}
+ onRetryMedia={state.retryUpload}
+ onCancelMedia={state.cancelUpload}
+ onUploadCompletionPdf={state.uploadCompletionPdf}
+ onOpenCompletionDoc={onOpenCompletionDoc}
+ />
+
+ )}
+
+ {tab === "extras" && (
+ void state.refetch()}
+ showContent={showContent}
+ >
+ {
+ void state.uploadExtraFiles(files);
+ }}
+ onRemove={state.removeMedia}
+ />
+
+ )}
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/detail/work-order-slide-over.tsx b/src/app/(protected)/workorders/_components/detail/work-order-slide-over.tsx
new file mode 100644
index 00000000..64d2e8f9
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/work-order-slide-over.tsx
@@ -0,0 +1,108 @@
+import { Box } from "@mui/material";
+import { SideSheet } from "@/components/ui/side-sheet";
+import { SlideOverHeader } from "@/app/(protected)/workorders/_components/detail/slide-over-header";
+import { SlideOverFooter } from "@/app/(protected)/workorders/_components/detail/slide-over-footer";
+import type {
+ LocationOption,
+ VendorDropdownItem,
+ WorkOrderUser,
+} from "@/domain/work-orders/types/work-order";
+import type {
+ WorkOrderTablePatch,
+ WorkOrderTableRow,
+} from "@/domain/work-orders/types/work-order-table-row";
+import { useWorkOrderSlideOver } from "./use-work-order-slide-over";
+import { WorkOrderSlideOverTabNav } from "./work-order-slide-over-tab-nav";
+import { WorkOrderSlideOverTabs } from "./work-order-slide-over-tabs";
+
+export type SlideOverSaveOptions = {
+ onSuccess?: () => void;
+ onError?: (error: Error) => void;
+};
+
+type WorkOrderSlideOverProps = {
+ row: WorkOrderTableRow | null;
+ editMode: boolean;
+ users: WorkOrderUser[];
+ sites: LocationOption[];
+ vendors: VendorDropdownItem[];
+ pmTypes: string[];
+ allOrders: WorkOrderTableRow[];
+ onSave: (id: string | number, patch: WorkOrderTablePatch, options?: SlideOverSaveOptions) => void;
+ onOpenCompletionDoc: (row: WorkOrderTableRow) => void;
+ onCancelWO: (row: WorkOrderTableRow) => void;
+ onDuplicateFound?: (existing: WorkOrderTableRow) => void;
+ onClose: () => void;
+ saving?: boolean;
+};
+
+export function WorkOrderSlideOver({
+ row,
+ editMode,
+ users,
+ sites,
+ vendors,
+ pmTypes,
+ allOrders,
+ onSave,
+ onOpenCompletionDoc,
+ onCancelWO,
+ onDuplicateFound,
+ onClose,
+ saving,
+}: WorkOrderSlideOverProps) {
+ const state = useWorkOrderSlideOver({ row, editMode, onSave, onClose, saving });
+ const { infoSource, activeDraft } = state;
+
+ return (
+
+ {row != null && infoSource != null && activeDraft != null && (
+
+
+
+
+
+ onOpenCompletionDoc(infoSource)}
+ />
+
+ onCancelWO(infoSource)}
+ onCancelEdit={state.handleCancelEdit}
+ onClose={state.requestClose}
+ onEdit={state.startEditing}
+ onSave={state.commit}
+ onDiscard={state.handleDiscard}
+ onKeepEditing={() => state.setShowUnsaved(false)}
+ />
+
+ )}
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/detail/work-order-status-menu.tsx b/src/app/(protected)/workorders/_components/detail/work-order-status-menu.tsx
new file mode 100644
index 00000000..15945501
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/work-order-status-menu.tsx
@@ -0,0 +1,50 @@
+import { Menu, MenuItem } from "@mui/material";
+import { WorkOrderStatusBadge } from "@/components/domain/status-badge";
+import { MenuAnchorButton } from "@/components/ui/menu-anchor-button";
+import { WORK_ORDER_STATUS_OPTIONS } from "@/domain/work-orders/types/work-order";
+import { useMenuAnchor } from "@/hooks/use-menu-anchor";
+
+type WorkOrderStatusMenuProps = {
+ status: string;
+ disabled?: boolean;
+ onStatusChange: (status: string) => void;
+};
+
+export function WorkOrderStatusMenu({
+ status,
+ disabled = false,
+ onStatusChange,
+}: WorkOrderStatusMenuProps) {
+ const { anchorEl, open, setAnchor, close } = useMenuAnchor();
+
+ return (
+ <>
+
+
+
+
+ {WORK_ORDER_STATUS_OPTIONS.map((option) => (
+ {
+ close();
+ if (option !== status) {
+ onStatusChange(option);
+ }
+ }}
+ >
+ {option}
+
+ ))}
+
+ >
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/list/duplicate-wo-number-dialog.tsx b/src/app/(protected)/workorders/_components/list/duplicate-wo-number-dialog.tsx
new file mode 100644
index 00000000..95decbe7
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/list/duplicate-wo-number-dialog.tsx
@@ -0,0 +1,45 @@
+import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material";
+import { Text } from "@/components/ui/text";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+
+type DuplicateWoNumberDialogProps = {
+ open: boolean;
+ existing: WorkOrderTableRow | null;
+ onDismiss: () => void;
+ onViewExisting: (row: WorkOrderTableRow) => void;
+};
+
+export function DuplicateWoNumberDialog({
+ open,
+ existing,
+ onDismiss,
+ onViewExisting,
+}: DuplicateWoNumberDialogProps) {
+ const woNumber = existing?.woNumber ?? "";
+
+ return (
+
+ Duplicate Work Order Number
+
+
+ Work order number {woNumber} is already assigned to another work order. View the existing
+ record or dismiss to keep editing.
+
+
+
+ Dismiss
+ {
+ if (existing != null) {
+ onViewExisting(existing);
+ }
+ }}
+ >
+ View existing
+
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input.ts b/src/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input.ts
index 53c2f381..0edec077 100644
--- a/src/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input.ts
+++ b/src/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input.ts
@@ -12,12 +12,15 @@ export function buildInlineDraftCreateInput(
const location = sites.find((s) => String(s.id) === String(draft.locationId));
const locationName = draft.location || draft.site || location?.name || "";
+ const hasWeekTarget = Boolean(draft.targetWeek?.trim());
+ const scheduleWeekOnly = draft.scheduleWeekOnly && (hasWeekTarget || Boolean(draft.scheduledOn));
+
return {
locationId: String(draft.locationId || ""),
locationName,
siteCode: location ? resolveSiteCode(location) : resolveSiteCode({ name: locationName }),
woNumber: draft.woNumber,
- type: draft.type,
+ type: draft.type || "PM",
assigneeId: draft.dispatcherId,
assigneeName: assignee?.name ?? "",
scheduledOn: draft.scheduledOn,
@@ -35,7 +38,7 @@ export function buildInlineDraftCreateInput(
pocName: draft.pocName,
pocPhone: draft.pocPhone,
pocNotes: draft.pocNotes,
- scheduleWeekOnly: draft.scheduleWeekOnly,
+ scheduleWeekOnly,
targetWeek: draft.targetWeek || "",
};
}
diff --git a/src/app/(protected)/workorders/_components/list/table/cells/comp-doc-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/comp-doc-cell.tsx
index 43f258c3..78a62e61 100644
--- a/src/app/(protected)/workorders/_components/list/table/cells/comp-doc-cell.tsx
+++ b/src/app/(protected)/workorders/_components/list/table/cells/comp-doc-cell.tsx
@@ -1,6 +1,7 @@
import { Ban, FileText, Image as ImageIcon } from "lucide-react";
import type { CompletionDocStatus } from "@/domain/work-orders/types/work-order-table-row";
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
+import { hasCompletionDocService } from "@/domain/work-orders/utils/completion-doc-service-gate";
function isMediaWO(type: WOType | ""): boolean {
return type === "Emergency" || type === "Reactive";
@@ -9,6 +10,8 @@ function isMediaWO(type: WOType | ""): boolean {
type CompDocCellProps = {
status: CompletionDocStatus;
woType: WOType | "";
+ /** Primary service (`pm`). Required before CompDoc can open. */
+ pm?: string;
mediaCount?: number;
/** Omit until completion-doc slice mounts CompDocDialog. */
onOpen?: () => void;
@@ -18,6 +21,7 @@ type CompDocCellProps = {
export function CompDocCell({
status,
woType,
+ pm = "",
mediaCount = 0,
onOpen,
onOpenMedia,
@@ -43,6 +47,20 @@ export function CompDocCell({
return null;
}
+ if (!hasCompletionDocService(pm)) {
+ return (
+
+
+ Select service
+
+ );
+ }
+
const generated = status === "Yes";
const notRequired = status === "NN";
const color = generated ? "var(--color-success)" : "var(--color-text-muted)";
diff --git a/src/app/(protected)/workorders/_components/list/table/cells/date-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/date-cell.tsx
index 417ffe22..6d78143e 100644
--- a/src/app/(protected)/workorders/_components/list/table/cells/date-cell.tsx
+++ b/src/app/(protected)/workorders/_components/list/table/cells/date-cell.tsx
@@ -15,6 +15,8 @@ type DateCellProps = {
targetWeek?: string;
onSaveWeek?: (weekStart: string) => void;
defaultMode?: "week" | "date";
+ /** When set, shows Clear in the popover so schedule can be removed (e.g. day-group → Unscheduled). */
+ onClear?: () => void;
};
export function DateCell({
@@ -26,6 +28,7 @@ export function DateCell({
targetWeek = "",
onSaveWeek,
defaultMode,
+ onClear,
}: DateCellProps) {
const [anchorEl, setAnchorEl] = useState(null);
const [cellMode, setCellMode] = useState<"week" | "date">(
@@ -132,6 +135,21 @@ export function DateCell({
}}
/>
)}
+ {Boolean(onClear) && (
+
+ {
+ onClear?.();
+ setAnchorEl(null);
+ }}
+ className="text-[11.5px] font-medium transition-colors"
+ style={{ color: "var(--color-text-muted)" }}
+ >
+ × Clear
+
+
+ )}
>
);
diff --git a/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell.tsx
index ce4b3462..83d4297c 100644
--- a/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell.tsx
+++ b/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell.tsx
@@ -1,43 +1,30 @@
import { useEffect, useState } from "react";
-
import { toast } from "react-toastify";
-
+import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
-
+import { findDuplicateWoNumberRow } from "@/domain/work-orders/utils/find-duplicate-wo-number-row";
import { HighlightText } from "./highlight-text";
type EditableWONumberCellProps = {
value: string;
-
rescheduleCount: number;
-
carriedOver?: number;
-
q?: string;
-
currentId: string | number;
-
allOrders: WorkOrderTableRow[];
-
onSave: (v: string) => void;
-
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
};
function CounterPill({ label, count }: { label: string; count: number }) {
- if (count < 2) {
- return null;
- }
-
+ if (count < 2) return null;
return (
@@ -46,89 +33,134 @@ function CounterPill({ label, count }: { label: string; count: number }) {
);
}
+type WoNumberEditorProps = {
+ draft: string;
+ invalid: boolean;
+ isChecking: boolean;
+ onDraftChange: (value: string) => void;
+ onCommit: () => void;
+ onCancel: () => void;
+ onBlur: () => void;
+};
+
+function WoNumberEditor({
+ draft,
+ invalid,
+ isChecking,
+ onDraftChange,
+ onCommit,
+ onCancel,
+ onBlur,
+}: WoNumberEditorProps) {
+ return (
+ onDraftChange(e.target.value.replace(/\D/g, "").slice(0, 11))}
+ onFocus={(e) => e.target.select()}
+ onClick={(e) => e.stopPropagation()}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ onCommit();
+ } else if (e.key === "Escape") {
+ e.preventDefault();
+ onCancel();
+ }
+ }}
+ onBlur={onBlur}
+ placeholder="12345678901"
+ aria-label="Work order number"
+ aria-invalid={invalid}
+ className="font-mono focus:outline-none"
+ style={{
+ fontSize: 13,
+ fontWeight: 700,
+ color: "var(--foreground)",
+ background: "var(--color-bg-surface)",
+ border: `1px solid ${invalid ? "var(--color-destructive)" : "var(--color-ring)"}`,
+ boxShadow: `0 0 0 1px ${invalid ? "var(--color-destructive)" : "var(--color-accent)"}`,
+ borderRadius: 4,
+ padding: "2px 4px",
+ width: 120,
+ }}
+ />
+ );
+}
+
+async function resolveDuplicateWoNumber(
+ draft: string,
+ allOrders: WorkOrderTableRow[],
+ currentId: string | number,
+) {
+ const local = findDuplicateWoNumberRow(draft, allOrders, currentId);
+ const remote = local ?? (await workOrdersApi.findByWoNumber(draft));
+ return remote && String(remote.id) !== String(currentId) ? remote : undefined;
+}
+
export function EditableWONumberCell({
value,
-
rescheduleCount,
-
carriedOver = 0,
-
q,
-
currentId,
-
allOrders,
-
onSave,
-
onDuplicateFound,
}: EditableWONumberCellProps) {
const [editing, setEditing] = useState(false);
-
const [draft, setDraft] = useState(value);
+ const [isChecking, setIsChecking] = useState(false);
useEffect(() => {
- if (!editing) {
- setDraft(value);
- }
+ if (!editing) setDraft(value);
}, [value, editing]);
- const findDuplicate = (d: string) =>
- allOrders.find((o) => o.woNumber === d && String(o.id) !== String(currentId));
-
const commit = () => {
- if (draft === value) {
+ if (draft === value || isChecking) {
setEditing(false);
-
return;
}
-
if (draft.length !== 11) {
toast("Work order number must be exactly 11 digits");
-
return;
}
-
- const duplicate = findDuplicate(draft);
-
- if (duplicate) {
- onDuplicateFound?.(duplicate);
-
- setDraft(value);
-
- setEditing(false);
-
- return;
- }
-
- onSave(draft);
-
- setEditing(false);
+ setIsChecking(true);
+ void (async () => {
+ try {
+ const duplicate = await resolveDuplicateWoNumber(draft, allOrders, currentId);
+ if (duplicate) {
+ if (onDuplicateFound) {
+ onDuplicateFound(duplicate);
+ } else {
+ toast.error(`WO #${duplicate.woNumber} is already assigned to another work order.`);
+ }
+ setDraft(value);
+ return;
+ }
+ onSave(draft);
+ } catch {
+ toast.error("Unable to verify the work order number. Please try again.");
+ } finally {
+ setIsChecking(false);
+ setEditing(false);
+ }
+ })();
};
const cancel = () => {
setDraft(value);
-
setEditing(false);
};
const handleBlur = () => {
+ if (isChecking) return;
if (draft !== value && draft.length === 11) {
- const duplicate = findDuplicate(draft);
-
- if (duplicate) {
- onDuplicateFound?.(duplicate);
-
- setDraft(value);
-
- setEditing(false);
-
- return;
- }
-
- onSave(draft);
+ commit();
+ return;
}
-
setEditing(false);
};
@@ -137,75 +169,34 @@ export function EditableWONumberCell({
return (
{editing ? (
- setDraft(e.target.value.replace(/\D/g, "").slice(0, 11))}
- onFocus={(e) => e.target.select()}
- onClick={(e) => e.stopPropagation()}
- onKeyDown={(e) => {
- if (e.key === "Enter") {
- e.preventDefault();
-
- commit();
- } else if (e.key === "Escape") {
- e.preventDefault();
-
- cancel();
- }
- }}
+
) : (
{
e.stopPropagation();
-
setEditing(true);
}}
className="inline-block cursor-text font-mono transition-colors hover:bg-[var(--color-bg-muted)]"
style={{
fontSize: 13,
-
fontWeight: 700,
-
color: value ? "var(--foreground)" : "var(--color-text-muted)",
-
borderRadius: 4,
-
padding: "2px 4px",
}}
>
{value ? : "-"}
)}
-
-
);
diff --git a/src/app/(protected)/workorders/_components/list/table/cells/site-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/site-cell.tsx
index 939b4ce4..89a703f6 100644
--- a/src/app/(protected)/workorders/_components/list/table/cells/site-cell.tsx
+++ b/src/app/(protected)/workorders/_components/list/table/cells/site-cell.tsx
@@ -17,6 +17,8 @@ type SiteCellProps = {
pocNotes?: string;
sites: LocationOption[];
q?: string;
+ /** Inline create: editable POC + Site/POC required to confirm the modal. */
+ createMode?: boolean;
onSave: (patch: SitePatch) => void;
};
@@ -31,6 +33,7 @@ export function SiteCell({
pocNotes,
sites,
q,
+ createMode = false,
onSave,
}: SiteCellProps) {
const [open, setOpen] = useState(false);
@@ -70,7 +73,15 @@ export function SiteCell({
)}
) : (
- —
+
+ {createMode ? (
+ <>
+ Site *
+ >
+ ) : (
+ "—"
+ )}
+
)}
@@ -83,6 +94,7 @@ export function SiteCell({
pocPhone={pocPhone}
pocNotes={pocNotes}
sites={sites}
+ createMode={createMode}
onSave={onSave}
/>
>
diff --git a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-fields.tsx b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-fields.tsx
index 145250c2..61ca8f78 100644
--- a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-fields.tsx
+++ b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-fields.tsx
@@ -9,37 +9,77 @@ type SiteDialogPocFieldsProps = {
pocName: string;
pocPhone: string;
pocNotes: string;
+ editable?: boolean;
+ showErrors?: boolean;
+ onPocNameChange?: (value: string) => void;
+ onPocPhoneChange?: (value: string) => void;
+ onPocNotesChange?: (value: string) => void;
};
-export function SiteDialogPocFields({ pocName, pocPhone, pocNotes }: SiteDialogPocFieldsProps) {
+export function SiteDialogPocFields({
+ pocName,
+ pocPhone,
+ pocNotes,
+ editable = false,
+ showErrors = false,
+ onPocNameChange,
+ onPocPhoneChange,
+ onPocNotesChange,
+}: SiteDialogPocFieldsProps) {
+ const nameMissing = showErrors && !pocName.trim();
+ const phoneMissing = showErrors && !pocPhone.trim();
+
return (
Point of Contact
- Shown from site/work order records. Board edit saves site code only.
+ {editable
+ ? "Auto-filled from records — edit if outdated"
+ : "Shown from site/work order records. Board edit saves site code only."}
diff --git a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog.tsx b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog.tsx
index ece94799..12c17523 100644
--- a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog.tsx
+++ b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog.tsx
@@ -1,30 +1,20 @@
-import { useEffect, useState, type MouseEvent } from "react";
+import type { MouseEvent } from "react";
import { MapPin } from "lucide-react";
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
import { Button } from "@/components/ui/button";
import { Text } from "@/components/ui/text";
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
-import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper";
-import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
-import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper";
import type { LocationOption } from "@/domain/work-orders/types/work-order";
import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label";
-import {
- extractLocationLabel,
- resolveLocationId,
-} from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-helpers";
import { SiteDialogDetailPanel } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-detail-panel";
import { SiteDialogPocFields } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-fields";
+import {
+ useSiteDialogState,
+ type SitePatch,
+} from "@/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-state";
-export type SitePatch = {
- site: string;
- location: string;
- locationId: string | number;
- pocName: string;
- pocPhone: string;
- pocNotes: string;
-};
+export type { SitePatch };
type SiteDialogProps = {
open: boolean;
@@ -35,6 +25,7 @@ type SiteDialogProps = {
pocPhone?: string;
pocNotes?: string;
sites: LocationOption[];
+ createMode?: boolean;
onSave: (patch: SitePatch) => void;
};
@@ -47,62 +38,26 @@ export function SiteDialog({
pocPhone = "",
pocNotes = "",
sites,
+ createMode = false,
onSave,
}: SiteDialogProps) {
- const [code, setCode] = useState(value);
- const [locId, setLocId] = useState(() => resolveLocationId(locationId, value, sites));
- const [pn, setPn] = useState(pocName);
- const [pp, setPp] = useState(pocPhone);
- const [notes, setNotes] = useState(pocNotes);
- const [pocFilledFor, setPocFilledFor] = useState("");
-
- const {
- data: locationDetail,
- isLoading: locationDetailLoading,
- isError: locationDetailError,
- } = useLocationDetail(open && locId ? locId : undefined);
-
- const selected = sites.find((s) => String(s.id) === locId);
- const addressPreview = locationDetail ? formatLocationAddressPreview(locationDetail) : "";
-
- useEffect(() => {
- if (!open) {
- return;
- }
- const resolved = resolveLocationId(locationId, value, sites);
- setCode(value);
- setLocId(resolved);
- setPn(pocName);
- setPp(pocPhone);
- setNotes(pocNotes);
- // Preserve existing WO POC; allow one auto-fill from site detail when POC is empty.
- setPocFilledFor(pocName.trim() || pocPhone.trim() ? resolved : "");
- }, [open, value, locationId, pocName, pocPhone, pocNotes, sites]);
-
- useEffect(() => {
- if (!open || !locId || !locationDetail || pocFilledFor === locId) {
- return;
- }
- setPn(locationDetail.contact ?? "");
- setPp(locationDetail.phone ?? "");
- setPocFilledFor(locId);
- }, [open, locId, locationDetail, pocFilledFor]);
-
- const handlePick = (id: string) => {
- const next = sites.find((s) => String(s.id) === id);
- setLocId(id);
- if (next) {
- setCode(resolveSiteCode(next));
- }
- if (id !== pocFilledFor) {
- setPocFilledFor("");
- }
- };
+ const s = useSiteDialogState({
+ open,
+ onOpenChange,
+ value,
+ locationId,
+ pocName,
+ pocPhone,
+ pocNotes,
+ sites,
+ createMode,
+ onSave,
+ });
return (
onOpenChange(false)}
+ onClose={s.close}
maxWidth={false}
slotProps={{
...dialogBlurBackdropSlotProps,
@@ -125,7 +80,7 @@ export function SiteDialog({
Site
+
+ Site is required
+
- {Boolean(locId) && (
+ {Boolean(s.locId) && (
)}
-
+
- onOpenChange(false)}
- >
+
Cancel
- {
- onSave({
- site: code || (selected ? resolveSiteCode(selected) : ""),
- location: selected ? extractLocationLabel(selected) : "",
- locationId: locId,
- pocName: pn,
- pocPhone: pp,
- pocNotes: notes,
- });
- onOpenChange(false);
- }}
- >
+
Save
diff --git a/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-state.ts b/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-state.ts
new file mode 100644
index 00000000..17d44464
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-state.ts
@@ -0,0 +1,142 @@
+import { useEffect, useState } from "react";
+import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper";
+import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
+import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper";
+import type { LocationOption } from "@/domain/work-orders/types/work-order";
+import {
+ extractLocationLabel,
+ resolveLocationId,
+} from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-helpers";
+
+export type SitePatch = {
+ site: string;
+ location: string;
+ locationId: string | number;
+ pocName: string;
+ pocPhone: string;
+ pocNotes: string;
+};
+
+type UseSiteDialogStateArgs = {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ value: string;
+ locationId: string | number;
+ pocName: string;
+ pocPhone: string;
+ pocNotes: string;
+ sites: LocationOption[];
+ createMode: boolean;
+ onSave: (patch: SitePatch) => void;
+};
+
+export function useSiteDialogState({
+ open,
+ onOpenChange,
+ value,
+ locationId,
+ pocName,
+ pocPhone,
+ pocNotes,
+ sites,
+ createMode,
+ onSave,
+}: UseSiteDialogStateArgs) {
+ const [code, setCode] = useState(value);
+ const [locId, setLocId] = useState(() => resolveLocationId(locationId, value, sites));
+ const [pn, setPn] = useState(pocName);
+ const [pp, setPp] = useState(pocPhone);
+ const [notes, setNotes] = useState(pocNotes);
+ const [pocFilledFor, setPocFilledFor] = useState("");
+ const [showErrors, setShowErrors] = useState(false);
+
+ const {
+ data: locationDetail,
+ isLoading: locationDetailLoading,
+ isError: locationDetailError,
+ } = useLocationDetail(open && locId ? locId : undefined);
+
+ const selected = sites.find((s) => String(s.id) === locId);
+ const addressPreview = locationDetail ? formatLocationAddressPreview(locationDetail) : "";
+ const siteMissing = !locId;
+ const pocMissing = !pn.trim() || !pp.trim();
+ const canConfirm = !siteMissing && (!createMode || !pocMissing);
+
+ useEffect(() => {
+ if (!open) {
+ return;
+ }
+ const resolved = resolveLocationId(locationId, value, sites);
+ setCode(value);
+ setLocId(resolved);
+ setPn(pocName);
+ setPp(pocPhone);
+ setNotes(pocNotes);
+ setShowErrors(false);
+ setPocFilledFor(pocName.trim() || pocPhone.trim() ? resolved : "");
+ }, [open, value, locationId, pocName, pocPhone, pocNotes, sites]);
+
+ useEffect(() => {
+ if (!open || !locId || !locationDetail || pocFilledFor === locId) {
+ return;
+ }
+ // Only autofill empty fields so typed createMode POC edits are not overwritten.
+ setPn((prev) => (prev.trim() ? prev : (locationDetail.contact ?? "")));
+ setPp((prev) => (prev.trim() ? prev : (locationDetail.phone ?? "")));
+ setPocFilledFor(locId);
+ }, [open, locId, locationDetail, pocFilledFor]);
+
+ const handlePick = (id: string) => {
+ const next = sites.find((s) => String(s.id) === id);
+ const siteChanged = id !== locId;
+ setLocId(id);
+ if (next) {
+ setCode(resolveSiteCode(next));
+ }
+ if (siteChanged) {
+ setPn("");
+ setPp("");
+ setPocFilledFor("");
+ } else if (id !== pocFilledFor) {
+ setPocFilledFor("");
+ }
+ };
+
+ const attemptSave = () => {
+ if (createMode && !canConfirm) {
+ setShowErrors(true);
+ return;
+ }
+ onSave({
+ site: code || (selected ? resolveSiteCode(selected) : ""),
+ location: selected ? extractLocationLabel(selected) : "",
+ locationId: locId,
+ pocName: pn,
+ pocPhone: pp,
+ pocNotes: notes,
+ });
+ onOpenChange(false);
+ };
+
+ return {
+ locId,
+ pn,
+ pp,
+ notes,
+ showErrors,
+ locationDetail,
+ locationDetailLoading,
+ locationDetailError,
+ selected,
+ addressPreview,
+ siteMissing,
+ canConfirm,
+ code,
+ handlePick,
+ attemptSave,
+ setPn,
+ setPp,
+ setNotes,
+ close: () => onOpenChange(false),
+ };
+}
diff --git a/src/app/(protected)/workorders/_components/list/table/inline-create-field.tsx b/src/app/(protected)/workorders/_components/list/table/inline-create-field.tsx
new file mode 100644
index 00000000..f7f8ce8a
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/list/table/inline-create-field.tsx
@@ -0,0 +1,32 @@
+import type { ReactNode } from "react";
+import { Text } from "@/components/ui/text";
+
+type InlineCreateFieldProps = {
+ showError?: boolean;
+ error?: string;
+ children: ReactNode;
+};
+
+/** Bordered create-cell wrapper; error ring + helper reveal after a failed Save attempt. */
+export function InlineCreateField({ showError = false, error, children }: InlineCreateFieldProps) {
+ return (
+
+
+ {children}
+
+
+ {error}
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/list/table/to-inline-draft-row.ts b/src/app/(protected)/workorders/_components/list/table/to-inline-draft-row.ts
index 1ec7ee46..1c4952ad 100644
--- a/src/app/(protected)/workorders/_components/list/table/to-inline-draft-row.ts
+++ b/src/app/(protected)/workorders/_components/list/table/to-inline-draft-row.ts
@@ -16,6 +16,7 @@ export function toInlineDraftRow(draft: WorkOrderTableDraft) {
location: draft.location,
dispatcherId: draft.dispatcherId,
scheduledOn: draft.scheduledOn,
+ completedDate: "",
dueDate: draft.dueDate,
pm: draft.pm,
serviceNotes: draft.serviceNotes,
diff --git a/src/app/(protected)/workorders/_components/list/table/use-wo-table-inline-create.ts b/src/app/(protected)/workorders/_components/list/table/use-wo-table-inline-create.ts
new file mode 100644
index 00000000..d6097cb4
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/list/table/use-wo-table-inline-create.ts
@@ -0,0 +1,58 @@
+import { useState } from "react";
+import { toast } from "react-toastify";
+import type { LocationOption, WorkOrderUser } from "@/domain/work-orders/types/work-order";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
+import { useCreateWorkOrderFromWizard } from "@/domain/work-orders/use-cases/use-create-work-order-from-wizard";
+import { findDuplicateWoNumberRow } from "@/domain/work-orders/utils/find-duplicate-wo-number-row";
+import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types";
+import { buildInlineDraftCreateInput } from "./build-inline-draft-create-input";
+
+type UseWoTableInlineCreateArgs = {
+ tableData: ReturnTypeOfUseWorkOrdersTableData;
+ users: WorkOrderUser[];
+ sites: LocationOption[];
+ onDuplicateFound: (row: WorkOrderTableRow) => void;
+};
+
+export function useWoTableInlineCreate({
+ tableData,
+ users,
+ sites,
+ onDuplicateFound,
+}: UseWoTableInlineCreateArgs) {
+ const [isCheckingInlineDuplicate, setIsCheckingInlineDuplicate] = useState(false);
+ const createMutation = useCreateWorkOrderFromWizard(() => {
+ tableData.cancelDraft();
+ });
+
+ const handleInlineSave = () => {
+ void (async () => {
+ const draftSnapshot = tableData.draft;
+ const woNumber = draftSnapshot.woNumber.trim();
+ if (woNumber) {
+ setIsCheckingInlineDuplicate(true);
+ try {
+ const duplicate =
+ findDuplicateWoNumberRow(woNumber, tableData.rows, "draft") ??
+ (await workOrdersApi.findByWoNumber(woNumber));
+ if (duplicate && String(duplicate.id) !== "draft") {
+ onDuplicateFound(duplicate);
+ return;
+ }
+ } catch {
+ toast.error("Unable to verify the work order number. Please try again.");
+ return;
+ } finally {
+ setIsCheckingInlineDuplicate(false);
+ }
+ }
+ createMutation.mutate(buildInlineDraftCreateInput(tableData.draft, users, sites));
+ })();
+ };
+
+ return {
+ handleInlineSave,
+ saving: createMutation.isPending || isCheckingInlineDuplicate,
+ };
+}
diff --git a/src/app/(protected)/workorders/_components/list/table/use-wo-table-row-handlers.ts b/src/app/(protected)/workorders/_components/list/table/use-wo-table-row-handlers.ts
index 59aca8f5..c9626b3e 100644
--- a/src/app/(protected)/workorders/_components/list/table/use-wo-table-row-handlers.ts
+++ b/src/app/(protected)/workorders/_components/list/table/use-wo-table-row-handlers.ts
@@ -19,6 +19,7 @@ type UseWoTableRowHandlersOptions = {
onOpenMedia: (row: WorkOrderTableRow) => void;
onPatch: (id: string | number, patch: WorkOrderTablePatch) => void;
onScheduleHighlight: (id: string | number | null) => void;
+ onDuplicateFound?: (existing: WorkOrderTableRow) => void;
};
export function useWoTableRowHandlers({
@@ -30,6 +31,7 @@ export function useWoTableRowHandlers({
onOpenMedia,
onPatch,
onScheduleHighlight,
+ onDuplicateFound,
}: UseWoTableRowHandlersOptions): WoTableRowHandlers {
const {
dragState: dragStateRef,
@@ -97,8 +99,7 @@ export function useWoTableRowHandlers({
});
},
onDuplicateFound: (existing: WorkOrderTableRow) => {
- tableData.setSelectedId(existing.id);
- onOpenSlideOver(existing, false);
+ onDuplicateFound?.(existing);
},
onOpenSlideOver: (row: WorkOrderTableRow) => onOpenSlideOver(row, false),
onPastDueAttempt: (id: string | number) => onScheduleHighlight(id),
@@ -138,6 +139,7 @@ export function useWoTableRowHandlers({
onOpenMedia,
onPatch,
onScheduleHighlight,
+ onDuplicateFound,
],
);
}
diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-cols.ts b/src/app/(protected)/workorders/_components/list/table/wo-table-cols.ts
index 837c8622..e9c4f1ee 100644
--- a/src/app/(protected)/workorders/_components/list/table/wo-table-cols.ts
+++ b/src/app/(protected)/workorders/_components/list/table/wo-table-cols.ts
@@ -12,8 +12,9 @@ export const WO_TABLE_COLS = [
{ key: "appt", label: "APPT TIME", w: 128 },
{ key: "status", label: "STATUS", w: 148 },
{ key: "doc", label: "COMP DOC", w: 84 },
+ { key: "completed", label: "COMPLETED DATE", w: 130 },
{ key: "actions", label: "", w: 100 },
] as const;
-export const WO_TABLE_MIN_WIDTH = 1504;
+export const WO_TABLE_MIN_WIDTH = 1634;
export const WO_TABLE_ROW_HEIGHT = 52;
diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-identity-cells.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-identity-cells.tsx
new file mode 100644
index 00000000..7526cdeb
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-identity-cells.tsx
@@ -0,0 +1,88 @@
+import type { RefObject } from "react";
+import type { LocationOption } from "@/domain/work-orders/types/work-order";
+import type {
+ WorkOrderTableDraft,
+ WorkOrderTableRow,
+} from "@/domain/work-orders/types/work-order-table-row";
+import { EditableWONumberCell } from "./cells/editable-wo-number-cell";
+import { SiteCell } from "./cells/site-cell";
+import { InlineCreateField } from "./inline-create-field";
+
+type WoTableInlineIdentityCellsProps = {
+ draft: WorkOrderTableDraft;
+ sites: LocationOption[];
+ sitePlace?: { city: string; state: string };
+ allRows: { id: string | number; woNumber: string }[];
+ siteCellRef: RefObject;
+ woCellRef: RefObject;
+ siteShowError: boolean;
+ siteError: string;
+ woShowError: boolean;
+ onPatch: (patch: Partial) => void;
+ onDuplicateFound?: (existing: WorkOrderTableRow) => void;
+};
+
+export function WoTableInlineIdentityCells({
+ draft,
+ sites,
+ sitePlace,
+ allRows,
+ siteCellRef,
+ woCellRef,
+ siteShowError,
+ siteError,
+ woShowError,
+ onPatch,
+ onDuplicateFound,
+}: WoTableInlineIdentityCellsProps) {
+ return (
+ <>
+
+
+
+ onPatch({
+ site: p.site,
+ location: p.location,
+ locationId: p.locationId,
+ pocName: p.pocName,
+ pocPhone: p.pocPhone,
+ pocNotes: p.pocNotes,
+ })
+ }
+ />
+
+
+
+
+
+ onPatch({ woNumber: v })}
+ onDuplicateFound={onDuplicateFound}
+ />
+
+
+ >
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-actions.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-actions.tsx
index 13b22c4c..cd09fd77 100644
--- a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-actions.tsx
+++ b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-actions.tsx
@@ -4,6 +4,7 @@ import { Check, X } from "lucide-react";
type WoTableInlineRowActionsProps = {
canSave: boolean;
saving: boolean;
+ saveHint: string;
onSave: () => void;
onCancel: () => void;
};
@@ -11,6 +12,7 @@ type WoTableInlineRowActionsProps = {
export function WoTableInlineRowActions({
canSave,
saving,
+ saveHint,
onSave,
onCancel,
}: WoTableInlineRowActionsProps) {
@@ -19,11 +21,14 @@ export function WoTableInlineRowActions({
{
if (canSave) {
(e.currentTarget as HTMLButtonElement).style.background = "var(--color-primary-tint)";
diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx
index 2357be2e..28b01db3 100644
--- a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx
+++ b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx
@@ -1,20 +1,23 @@
+import type { RefObject } from "react";
import type {
LocationOption,
VendorDropdownItem,
WorkOrderUser,
} from "@/domain/work-orders/types/work-order";
-import type { WorkOrderTableDraft } from "@/domain/work-orders/types/work-order-table-row";
+import type {
+ WorkOrderTableDraft,
+ WorkOrderTableRow,
+} from "@/domain/work-orders/types/work-order-table-row";
import { AssignedToCell } from "./cells/assigned-to-cell";
import { ApptTimeCell } from "./cells/appt-time-cell";
import { DateCell } from "./cells/date-cell";
import { DueDateCell } from "./cells/due-date-cell";
-import { EditableWONumberCell } from "./cells/editable-wo-number-cell";
import { PMTypeCell } from "./cells/pm-type-cell";
-import { SiteCell } from "./cells/site-cell";
import { StatusCell } from "./cells/status-cell";
import { TypeCell } from "./cells/type-cell";
import { VendorCell } from "./cells/vendor-cell";
import { toInlineDraftRow } from "./to-inline-draft-row";
+import { WoTableInlineIdentityCells } from "./wo-table-inline-identity-cells";
type WoTableInlineRowCellsProps = {
draft: WorkOrderTableDraft;
@@ -25,9 +28,23 @@ type WoTableInlineRowCellsProps = {
vendors: VendorDropdownItem[];
pmTypes: string[];
allRows: { id: string | number; woNumber: string }[];
+ siteCellRef: RefObject;
+ woCellRef: RefObject;
+ siteShowError: boolean;
+ siteError: string;
+ woShowError: boolean;
onPatch: (patch: Partial) => void;
+ onDuplicateFound?: (existing: WorkOrderTableRow) => void;
};
+function InlineMutedDashCell() {
+ return (
+
+ —
+
+ );
+}
+
export function WoTableInlineRowCells({
draft,
weekDefault,
@@ -37,45 +54,32 @@ export function WoTableInlineRowCells({
vendors,
pmTypes,
allRows,
+ siteCellRef,
+ woCellRef,
+ siteShowError,
+ siteError,
+ woShowError,
onPatch,
+ onDuplicateFound,
}: WoTableInlineRowCellsProps) {
const draftRow = toInlineDraftRow(draft);
+ const hasSchedule = Boolean(draft.scheduledOn) || Boolean(draft.targetWeek);
return (
<>
-
-
- onPatch({
- site: p.site,
- location: p.location,
- locationId: p.locationId,
- pocName: p.pocName,
- pocPhone: p.pocPhone,
- pocNotes: p.pocNotes,
- })
- }
- />
-
-
-
- onPatch({ woNumber: v })}
- />
-
+
onPatch({ type: t })} />
@@ -98,11 +102,15 @@ export function WoTableInlineRowCells({
allowWeekMode
targetWeek={draft.targetWeek}
defaultMode={weekDefault ? "week" : "date"}
- highlight={!draft.scheduledOn && !draft.targetWeek}
onSave={(date) => onPatch({ scheduledOn: date, scheduleWeekOnly: false, targetWeek: "" })}
onSaveWeek={(week) =>
onPatch({ scheduledOn: "", targetWeek: week, scheduleWeekOnly: true })
}
+ onClear={
+ hasSchedule
+ ? () => onPatch({ scheduledOn: "", targetWeek: "", scheduleWeekOnly: false })
+ : undefined
+ }
/>
@@ -170,9 +178,8 @@ export function WoTableInlineRowCells({
/>
-
- —
-
+
+
>
);
}
diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row.tsx
index 60bc03f4..679a0c3b 100644
--- a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row.tsx
+++ b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row.tsx
@@ -1,3 +1,4 @@
+import { useRef, useState, type RefObject } from "react";
import { Flag } from "lucide-react";
import type {
@@ -5,10 +6,17 @@ import type {
VendorDropdownItem,
WorkOrderUser,
} from "@/domain/work-orders/types/work-order";
-
-import type { WorkOrderTableDraft } from "@/domain/work-orders/types/work-order-table-row";
-
+import type {
+ WorkOrderTableDraft,
+ WorkOrderTableRow,
+} from "@/domain/work-orders/types/work-order-table-row";
import { UNSCHEDULED_DAY_KEY } from "@/domain/work-orders/types/work-order-table-row";
+import {
+ draftHasScheduleDate,
+ getInlineCreateMissing,
+ inlineCreateSaveHint,
+ inlineCreateSiteError,
+} from "@/domain/work-orders/utils/get-inline-create-missing";
import { WO_TABLE_ROW_HEIGHT } from "./wo-table-cols";
import { WoTableInlineRowCells } from "./wo-table-inline-row-cells";
@@ -16,72 +24,88 @@ import { WoTableInlineRowActions } from "./wo-table-inline-row-actions";
type WoTableInlineRowProps = {
draft: WorkOrderTableDraft;
-
draftDayKey: string;
-
users: WorkOrderUser[];
-
sites: LocationOption[];
-
sitePlace?: { city: string; state: string };
-
vendors: VendorDropdownItem[];
-
pmTypes: string[];
-
allRows: { id: string | number; woNumber: string }[];
-
saving?: boolean;
-
onChange: (draft: WorkOrderTableDraft) => void;
-
onSave: () => void;
-
onCancel: () => void;
+ onDuplicateFound?: (existing: WorkOrderTableRow) => void;
};
+const PRE_SCHEDULE = new Set(["Incomplete", "Pending", ""]);
+
+function withScheduleStatusSync(next: WorkOrderTableDraft): WorkOrderTableDraft {
+ const dated = draftHasScheduleDate(next);
+ if (dated && PRE_SCHEDULE.has(next.status)) {
+ return { ...next, status: "Scheduled" };
+ }
+ if (!dated && next.status === "Scheduled") {
+ return { ...next, status: "Pending" };
+ }
+ return next;
+}
+
export function WoTableInlineRow({
draft,
-
draftDayKey,
-
users,
-
sites,
-
sitePlace,
-
vendors,
-
pmTypes,
-
allRows,
-
saving = false,
-
onChange,
-
onSave,
-
onCancel,
+ onDuplicateFound,
}: WoTableInlineRowProps) {
const weekDefault = draftDayKey === UNSCHEDULED_DAY_KEY;
+ const missing = getInlineCreateMissing(draft);
+ const canSave = !saving && missing.length === 0;
+ const [showErrors, setShowErrors] = useState(false);
+ const siteCellRef = useRef(null);
+ const woCellRef = useRef(null);
- const canSave =
- !saving && (weekDefault ? !!draft.targetWeek || !!draft.scheduledOn : !!draft.scheduledOn);
+ const siteCellMissing = missing.some((f) => f === "site" || f === "pocName" || f === "pocPhone");
+ const siteShowError = showErrors && siteCellMissing;
+ const woShowError = showErrors && missing.includes("woNumber");
- const patch = (p: Partial) => onChange({ ...draft, ...p });
+ const patch = (p: Partial) =>
+ onChange(withScheduleStatusSync({ ...draft, ...p }));
+
+ const focusCell = (r: RefObject) =>
+ r.current?.querySelector("input, button, [tabindex]")?.focus();
+
+ const attemptSave = () => {
+ if (saving) {
+ return;
+ }
+ if (canSave) {
+ onSave();
+ return;
+ }
+ setShowErrors(true);
+ if (siteCellMissing) {
+ focusCell(siteCellRef);
+ } else if (missing.includes("woNumber")) {
+ focusCell(woCellRef);
+ }
+ };
return (
diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx
index 0ae6032b..7974f536 100644
--- a/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx
+++ b/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx
@@ -1,7 +1,9 @@
+import type { CSSProperties } from "react";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { ApptTimeCell } from "./cells/appt-time-cell";
import { CompDocCell } from "./cells/comp-doc-cell";
+import { DateCell } from "./cells/date-cell";
import { PMTypeCell } from "./cells/pm-type-cell";
import { StatusCell } from "./cells/status-cell";
import { VendorCell } from "./cells/vendor-cell";
@@ -12,7 +14,7 @@ type WoTableRowServiceCellsProps = {
vendors: VendorDropdownItem[];
pmTypes: string[];
search?: string;
- mutationCellStyle?: React.CSSProperties;
+ mutationCellStyle?: CSSProperties;
handlers: WoTableRowHandlers;
onPatchRow: (patch: Partial) => void;
};
@@ -95,11 +97,23 @@ export function WoTableRowServiceCells({
handlers.onOpenCompDoc!(row) : undefined}
onOpenMedia={() => handlers.onOpenMedia(row)}
/>
+ e.stopPropagation()}
+ >
+ onPatchRow({ completedDate: date })}
+ />
+
>
);
}
diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table.tsx
index 94e019a7..c733ce89 100644
--- a/src/app/(protected)/workorders/_components/list/table/wo-table.tsx
+++ b/src/app/(protected)/workorders/_components/list/table/wo-table.tsx
@@ -9,11 +9,11 @@ import type {
WorkOrderTablePatch,
WorkOrderTableRow,
} from "@/domain/work-orders/types/work-order-table-row";
-import { useCreateWorkOrderFromWizard } from "@/domain/work-orders/use-cases/use-create-work-order-from-wizard";
+import { DuplicateWoNumberDialog } from "@/app/(protected)/workorders/_components/list/duplicate-wo-number-dialog";
import { WoTableSkeleton } from "@/app/(protected)/workorders/_components/loading/wo-table-skeleton";
import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types";
-import { buildInlineDraftCreateInput } from "./build-inline-draft-create-input";
import { buildSitePlaceLookups, resolveSitePlace } from "./site-place-lookup";
+import { useWoTableInlineCreate } from "./use-wo-table-inline-create";
import { useWoTableRowHandlers } from "./use-wo-table-row-handlers";
import { WoTableBody } from "./wo-table-body";
import { WoTableHead } from "./wo-table-head";
@@ -55,10 +55,14 @@ export function WoTable({
onPatch,
}: WoTableProps) {
const [dueDateHighlightId, setDueDateHighlightId] = useState(null);
+ const [duplicateRow, setDuplicateRow] = useState(null);
const { data: locationSites = [] } = useLocationSites();
const sitePlaceLookups = useMemo(() => buildSitePlaceLookups(locationSites), [locationSites]);
- const createMutation = useCreateWorkOrderFromWizard(() => {
- tableData.cancelDraft();
+ const { handleInlineSave, saving } = useWoTableInlineCreate({
+ tableData,
+ users,
+ sites,
+ onDuplicateFound: setDuplicateRow,
});
const handlers = useWoTableRowHandlers({
@@ -70,6 +74,7 @@ export function WoTable({
onOpenMedia,
onPatch,
onScheduleHighlight: setDueDateHighlightId,
+ onDuplicateFound: setDuplicateRow,
});
const renderRow = (row: WorkOrderTableRow, forceScheduleHighlight = false) => (
@@ -107,12 +112,11 @@ export function WoTable({
vendors={vendors}
pmTypes={pmTypes}
allRows={tableData.rows}
- saving={createMutation.isPending}
+ saving={saving}
onChange={tableData.setDraft}
- onSave={() =>
- createMutation.mutate(buildInlineDraftCreateInput(tableData.draft, users, sites))
- }
+ onSave={handleInlineSave}
onCancel={tableData.cancelDraft}
+ onDuplicateFound={setDuplicateRow}
/>
);
@@ -151,21 +155,33 @@ export function WoTable({
}
return (
-
-
+
+
+
+
+ setDuplicateRow(null)}
+ onViewExisting={(row) => {
+ setDuplicateRow(null);
+ onOpenSlideOver(row, false);
}}
- >
-
-
-
-
+ />
+ >
);
}
diff --git a/src/app/(protected)/workorders/_components/list/work-order-cancel-dialog.tsx b/src/app/(protected)/workorders/_components/list/work-order-cancel-dialog.tsx
new file mode 100644
index 00000000..e17a262a
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/list/work-order-cancel-dialog.tsx
@@ -0,0 +1,50 @@
+import {
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogContentText,
+ DialogTitle,
+} from "@mui/material";
+import { Button } from "@/components/ui/button";
+import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+
+type WorkOrderCancelDialogProps = {
+ row: WorkOrderTableRow | null;
+ isPending: boolean;
+ onDialogClose: (() => void) | undefined;
+ onKeepWO: () => void;
+ onConfirm: () => void;
+};
+
+export function WorkOrderCancelDialog({
+ row,
+ isPending,
+ onDialogClose,
+ onKeepWO,
+ onConfirm,
+}: WorkOrderCancelDialogProps) {
+ return (
+
+ Cancel work order?
+
+
+ Cancel WO #{row?.woNumber}? The record will be kept with status Canceled.
+
+
+
+
+ Keep WO
+
+
+ Cancel WO
+
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/list/work-orders-list-overlays.tsx b/src/app/(protected)/workorders/_components/list/work-orders-list-overlays.tsx
new file mode 100644
index 00000000..a38542d6
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/list/work-orders-list-overlays.tsx
@@ -0,0 +1,90 @@
+import { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog";
+import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal";
+import { WorkOrdersAdvancedSearchSheet } from "@/app/(protected)/workorders/_components/list/advanced-search/work-orders-advanced-search-sheet";
+import { NewWoWizard } from "@/app/(protected)/workorders/_components/wizard/new-wo-wizard";
+import type { CompDocData, CompDocSaveStatus } from "@/domain/work-orders/types/completion-doc";
+import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters";
+import type { LocationOption } from "@/domain/work-orders/types/work-order-common";
+import type { WorkOrderUser } from "@/domain/work-orders/types/work-order-list";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+
+type WorkOrdersListOverlaysProps = {
+ wizardOpen: boolean;
+ onWizardOpenChange: (open: boolean) => void;
+ users: WorkOrderUser[];
+ sites: LocationOption[];
+ pmTypes: string[];
+ vendorTechs: string[];
+ advOpen: boolean;
+ onAdvOpenChange: (open: boolean) => void;
+ advInitial: WorkOrderAdvancedFilters;
+ onApplyAdvanced: (next: WorkOrderAdvancedFilters) => void;
+ onClearAdvanced: () => void;
+ activeDocRow: WorkOrderTableRow | null;
+ onDocOpenChange: (open: boolean) => void;
+ completionDocs: Record;
+ onSaveCompDoc: (data: CompDocData, status: CompDocSaveStatus) => void | Promise;
+ onDateCompletedChange: (date: string) => void | Promise;
+ activeMediaRow: WorkOrderTableRow | null;
+ onMediaOpenChange: (open: boolean) => void;
+};
+
+export function WorkOrdersListOverlays({
+ wizardOpen,
+ onWizardOpenChange,
+ users,
+ sites,
+ pmTypes,
+ vendorTechs,
+ advOpen,
+ onAdvOpenChange,
+ advInitial,
+ onApplyAdvanced,
+ onClearAdvanced,
+ activeDocRow,
+ onDocOpenChange,
+ completionDocs,
+ onSaveCompDoc,
+ onDateCompletedChange,
+ activeMediaRow,
+ onMediaOpenChange,
+}: WorkOrdersListOverlaysProps) {
+ return (
+ <>
+
+
+
+
+ {activeDocRow != null && (
+
+ )}
+
+ {activeMediaRow != null && (
+
+ )}
+ >
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/list/work-orders-list-wizard.tsx b/src/app/(protected)/workorders/_components/list/work-orders-list-wizard.tsx
new file mode 100644
index 00000000..c9ab1c6c
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/list/work-orders-list-wizard.tsx
@@ -0,0 +1,32 @@
+import { NewWoWizard } from "@/app/(protected)/workorders/_components/wizard/new-wo-wizard";
+import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+
+type WorkOrdersListWizardProps = {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ users: WorkOrderUser[];
+ rows: WorkOrderTableRow[];
+ onViewExisting: (row: WorkOrderTableRow) => void;
+};
+
+export function WorkOrdersListWizard({
+ open,
+ onOpenChange,
+ users,
+ rows,
+ onViewExisting,
+}: WorkOrdersListWizardProps) {
+ return (
+ {
+ onOpenChange(false);
+ onViewExisting(row);
+ }}
+ />
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/media/media-upload-modal.tsx b/src/app/(protected)/workorders/_components/media/media-upload-modal.tsx
index 779bdb28..ba84fa1c 100644
--- a/src/app/(protected)/workorders/_components/media/media-upload-modal.tsx
+++ b/src/app/(protected)/workorders/_components/media/media-upload-modal.tsx
@@ -1,32 +1,63 @@
import { type MouseEvent } from "react";
-import { Dialog, DialogContent, DialogTitle } from "@mui/material";
+import { Dialog, DialogContent, DialogTitle, Skeleton, Stack } from "@mui/material";
import { Image as ImageIcon } from "lucide-react";
+import { toast } from "react-toastify";
import { Button } from "@/components/ui/button";
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
+import { LoadingRegion } from "@/components/ui/loading-region";
import { Text } from "@/components/ui/text";
-import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
+import { MediaUploader } from "@/app/(protected)/workorders/_components/media/media-uploader";
+import { useMediaUploadSession } from "@/app/(protected)/workorders/_hooks/use-media-upload-session";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
-import { MediaUploader } from "./media-uploader";
+import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
+import {
+ useDeleteWorkOrderMedia,
+ useUpdateWorkOrderMediaCategory,
+ useUploadWorkOrderMedia,
+} from "@/domain/work-orders/use-cases/use-work-order-mutations";
+import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits";
+import { guardDialogClose } from "@/lib/dialog-guard";
type MediaUploadModalProps = {
row: WorkOrderTableRow;
open: boolean;
onOpenChange: (open: boolean) => void;
- uploads: MediaFile[];
- onChange: (next: MediaFile[]) => void;
};
-export function MediaUploadModal({
- row,
- open,
- onOpenChange,
- uploads,
- onChange,
-}: MediaUploadModalProps) {
+function resolveWorkOrderVersion(
+ detailRowVersion: string | null | undefined,
+ rowVersion: string | null | undefined,
+): string | null {
+ const version = detailRowVersion?.trim() || rowVersion?.trim() || "";
+ return version || null;
+}
+
+export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalProps) {
+ const { data: detail, isLoading } = useWorkOrderBoardDetail(row.id, open);
+ const uploadMutation = useUploadWorkOrderMedia(row.id);
+ const categorizeMutation = useUpdateWorkOrderMediaCategory(row.id);
+ const deleteMutation = useDeleteWorkOrderMedia(row.id);
+ const isReadOnly = isWorkOrderDocumentsReadOnly(detail?.info.status ?? row.status);
+ const { uploads, startUploads, retryUpload, cancelUpload, removeLocal, isLocal } =
+ useMediaUploadSession(detail, (input) => uploadMutation.mutateAsync(input));
+
+ const pending =
+ uploadMutation.isPending || deleteMutation.isPending || categorizeMutation.isPending;
+ const handleClose = guardDialogClose(pending, () => onOpenChange(false));
+
+ const requireVersion = (): string | null => {
+ const version = resolveWorkOrderVersion(detail?.info.rowVersion, row.rowVersion);
+ if (!version) {
+ toast.error("Missing work order version. Refresh and try again.");
+ return null;
+ }
+ return version;
+ };
+
return (
onOpenChange(false)}
+ onClose={handleClose}
maxWidth={false}
slotProps={{
...dialogBlurBackdropSlotProps,
@@ -45,11 +76,42 @@ export function MediaUploadModal({
#{row.woNumber}
- Upload photos and videos for this work order.
+ Upload photos and videos for this work order, then assign Before, After, or Extra.
-
+ {isLoading && !detail ? (
+
+
+
+
+
+
+
+ ) : (
+ {
+ if (isLocal(id)) {
+ removeLocal(id);
+ return;
+ }
+ const workOrderVersion = requireVersion();
+ if (!workOrderVersion) return;
+ deleteMutation.mutate({ mediaId: id, workOrderVersion });
+ }}
+ onCategorize={(id, category) => {
+ const workOrderVersion = requireVersion();
+ if (!workOrderVersion) return;
+ categorizeMutation.mutate({ mediaId: id, category, workOrderVersion });
+ }}
+ onRetry={retryUpload}
+ onCancel={cancelUpload}
+ readOnly={isReadOnly}
+ pending={pending}
+ />
+ )}
@@ -57,6 +119,7 @@ export function MediaUploadModal({
variant="outline"
size="default"
className="text-[12px]"
+ disabled={pending}
onClick={() => onOpenChange(false)}
>
Close
diff --git a/src/app/(protected)/workorders/_components/media/media-uploader-dropzone.tsx b/src/app/(protected)/workorders/_components/media/media-uploader-dropzone.tsx
new file mode 100644
index 00000000..e6b3aa19
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/media/media-uploader-dropzone.tsx
@@ -0,0 +1,60 @@
+import type { RefObject } from "react";
+import { Upload } from "lucide-react";
+import {
+ formatMediaSizeLimit,
+ workOrderMediaAcceptAttribute,
+} from "@/domain/work-orders/utils/work-order-media-limits";
+
+type MediaUploaderDropzoneProps = {
+ inputRef: RefObject
;
+ pending: boolean;
+ onFilesSelected: (files: FileList | null) => void;
+};
+
+export function MediaUploaderDropzone({
+ inputRef,
+ pending,
+ onFilesSelected,
+}: MediaUploaderDropzoneProps) {
+ return (
+ <>
+ inputRef.current?.click()}
+ onDragOver={(e) => e.preventDefault()}
+ onDrop={(e) => {
+ e.preventDefault();
+ onFilesSelected(e.dataTransfer.files);
+ }}
+ className="flex w-full flex-col items-center justify-center gap-1.5 transition-colors hover:bg-[var(--color-bg-muted)] disabled:opacity-60"
+ style={{
+ minHeight: 120,
+ border: "1px dashed var(--color-border)",
+ borderRadius: 8,
+ }}
+ >
+
+
+ {pending ? "Uploading…" : "Drag files here or click to browse"}
+
+
+ Accepted: JPG, PNG, MP4, MOV · Max {formatMediaSizeLimit()} per file · categorize after
+ upload
+
+
+ {
+ onFilesSelected(e.target.files);
+ e.target.value = "";
+ }}
+ />
+ >
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/media/media-uploader-grid-item-chrome.tsx b/src/app/(protected)/workorders/_components/media/media-uploader-grid-item-chrome.tsx
new file mode 100644
index 00000000..1399c90a
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/media/media-uploader-grid-item-chrome.tsx
@@ -0,0 +1,113 @@
+import { RotateCcw, X } from "lucide-react";
+import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
+import { canDeleteWorkOrderMedia } from "@/domain/work-orders/utils/work-order-media-limits";
+import { MediaUploaderGridItemPreview } from "./media-uploader-grid-item-preview";
+
+type MediaUploaderGridItemChromeProps = {
+ file: MediaFile;
+ readOnly: boolean;
+ pending: boolean;
+ onRemove: (id: string) => void;
+ onRetry?: (id: string) => void;
+ onCancel?: (id: string) => void;
+};
+
+export function MediaUploaderGridItemChrome({
+ file,
+ readOnly,
+ pending,
+ onRemove,
+ onRetry,
+ onCancel,
+}: MediaUploaderGridItemChromeProps) {
+ const uploading = file.uploadStatus === "uploading";
+ const failed = file.uploadStatus === "failed";
+ const showDelete = !readOnly && canDeleteWorkOrderMedia(file);
+
+ return (
+
+
+ {Boolean(uploading) && (
+
+ )}
+ {Boolean(uploading && onCancel) && (
+
onCancel?.(file.id)}
+ title="Cancel upload"
+ className="absolute flex items-center justify-center"
+ style={{
+ top: 4,
+ right: 4,
+ width: 18,
+ height: 18,
+ borderRadius: 4,
+ background: "var(--color-bg-surface)",
+ border: "1px solid var(--color-border)",
+ color: "var(--color-text-muted)",
+ }}
+ >
+
+
+ )}
+ {Boolean(failed && onRetry) && (
+
onRetry?.(file.id)}
+ title="Retry upload"
+ className="absolute flex items-center justify-center"
+ style={{
+ top: 4,
+ left: 4,
+ width: 18,
+ height: 18,
+ borderRadius: 4,
+ background: "var(--color-bg-surface)",
+ border: "1px solid var(--color-border)",
+ color: "var(--color-text-muted)",
+ }}
+ >
+
+
+ )}
+ {Boolean(showDelete && !uploading) && (
+
onRemove(file.id)}
+ title="Remove"
+ disabled={pending}
+ className="absolute flex items-center justify-center transition-opacity hover:opacity-80 disabled:opacity-50"
+ style={{
+ top: 4,
+ right: 4,
+ width: 18,
+ height: 18,
+ borderRadius: 4,
+ background: "var(--color-bg-surface)",
+ border: "1px solid var(--color-border)",
+ color: "var(--color-text-muted)",
+ }}
+ >
+
+
+ )}
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/media/media-uploader-grid-item-meta.tsx b/src/app/(protected)/workorders/_components/media/media-uploader-grid-item-meta.tsx
new file mode 100644
index 00000000..8b42c0f4
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/media/media-uploader-grid-item-meta.tsx
@@ -0,0 +1,67 @@
+import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
+import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
+
+const PURPOSE_OPTIONS: UploadableMediaCategory[] = ["Before", "After", "Extra"];
+
+type MediaUploaderGridItemMetaProps = {
+ file: MediaFile;
+ readOnly: boolean;
+ pending: boolean;
+ onCategorize?: (id: string, category: UploadableMediaCategory) => void;
+};
+
+export function MediaUploaderGridItemMeta({
+ file,
+ readOnly,
+ pending,
+ onCategorize,
+}: MediaUploaderGridItemMetaProps) {
+ const failed = file.uploadStatus === "failed";
+ const needsCategory =
+ !readOnly &&
+ !file.isLegacy &&
+ file.uploadStatus !== "uploading" &&
+ file.uploadStatus !== "failed" &&
+ Boolean(onCategorize);
+
+ return (
+
+
+ {file.name}
+
+ {Boolean(failed) && (
+
+ {file.errorMessage || "Upload failed"}
+
+ )}
+ {needsCategory ? (
+
{
+ const value = event.target.value as UploadableMediaCategory;
+ if (value) onCategorize?.(file.id, value);
+ }}
+ className="w-full rounded border px-1 py-0.5 text-[10px]"
+ style={{
+ borderColor: "var(--color-border)",
+ color: "var(--color-text-primary)",
+ background: "var(--color-bg-surface)",
+ }}
+ >
+ Assign purpose…
+ {PURPOSE_OPTIONS.map((value) => (
+
+ {value}
+
+ ))}
+
+ ) : (
+
+ {file.purpose || (file.isLegacy ? "Legacy" : "")}
+
+ )}
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/media/media-uploader-grid-item-preview.tsx b/src/app/(protected)/workorders/_components/media/media-uploader-grid-item-preview.tsx
new file mode 100644
index 00000000..3634fc1f
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/media/media-uploader-grid-item-preview.tsx
@@ -0,0 +1,52 @@
+import {
+ File as FileIcon,
+ FileText,
+ Film,
+ Image as ImageIcon,
+ Paperclip,
+ Play,
+} from "lucide-react";
+import type { FileCategory, MediaFile } from "@/domain/work-orders/types/work-order-media";
+
+const FILETYPE_ICON: Record = {
+ image: ImageIcon,
+ video: Film,
+ pdf: FileText,
+ doc: FileIcon,
+ other: Paperclip,
+};
+
+type MediaUploaderGridItemPreviewProps = {
+ file: MediaFile;
+};
+
+export function MediaUploaderGridItemPreview({ file }: MediaUploaderGridItemPreviewProps) {
+ const TypeIcon = FILETYPE_ICON[file.fileType];
+
+ if (file.fileType === "image") {
+ return (
+
+ );
+ }
+
+ if (file.fileType === "video") {
+ return (
+ <>
+
+
+
+
+ >
+ );
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/media/media-uploader-grid-item.tsx b/src/app/(protected)/workorders/_components/media/media-uploader-grid-item.tsx
new file mode 100644
index 00000000..81943b68
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/media/media-uploader-grid-item.tsx
@@ -0,0 +1,46 @@
+import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
+import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
+import { MediaUploaderGridItemChrome } from "./media-uploader-grid-item-chrome";
+import { MediaUploaderGridItemMeta } from "./media-uploader-grid-item-meta";
+
+type MediaUploaderGridItemProps = {
+ file: MediaFile;
+ readOnly: boolean;
+ pending: boolean;
+ onRemove: (id: string) => void;
+ onCategorize?: (id: string, category: UploadableMediaCategory) => void;
+ onRetry?: (id: string) => void;
+ onCancel?: (id: string) => void;
+};
+
+export function MediaUploaderGridItem({
+ file,
+ readOnly,
+ pending,
+ onRemove,
+ onCategorize,
+ onRetry,
+ onCancel,
+}: MediaUploaderGridItemProps) {
+ return (
+
+
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/media/media-uploader-grid.tsx b/src/app/(protected)/workorders/_components/media/media-uploader-grid.tsx
new file mode 100644
index 00000000..052bd161
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/media/media-uploader-grid.tsx
@@ -0,0 +1,42 @@
+import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
+import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
+import { MediaUploaderGridItem } from "./media-uploader-grid-item";
+
+type MediaUploaderGridProps = {
+ uploads: MediaFile[];
+ readOnly: boolean;
+ pending: boolean;
+ onRemove: (id: string) => void;
+ onCategorize?: (id: string, category: UploadableMediaCategory) => void;
+ onRetry?: (id: string) => void;
+ onCancel?: (id: string) => void;
+};
+
+export function MediaUploaderGrid({
+ uploads,
+ readOnly,
+ pending,
+ onRemove,
+ onCategorize,
+ onRetry,
+ onCancel,
+}: MediaUploaderGridProps) {
+ if (uploads.length === 0) return null;
+
+ return (
+
+ {uploads.map((file) => (
+
+ ))}
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/media/media-uploader.tsx b/src/app/(protected)/workorders/_components/media/media-uploader.tsx
index bd7fcc9a..6c7544f6 100644
--- a/src/app/(protected)/workorders/_components/media/media-uploader.tsx
+++ b/src/app/(protected)/workorders/_components/media/media-uploader.tsx
@@ -1,143 +1,107 @@
-import { useRef } from "react";
+import { useRef } from "react";
+import { toast } from "react-toastify";
+import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
+import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
import {
- File as FileIcon,
- FileText,
- Film,
- Image as ImageIcon,
- Paperclip,
- Play,
- Upload,
- X,
-} from "lucide-react";
-import type { FileCategory, MediaFile } from "@/domain/work-orders/types/work-order-media";
+ canDeleteWorkOrderMedia,
+ formatMediaSizeLimit,
+ isWorkOrderFileTooLarge,
+ isWorkOrderMediaTypeAllowed,
+} from "@/domain/work-orders/utils/work-order-media-limits";
import { makeMediaFile } from "@/domain/work-orders/utils/work-order-media-utils";
+import { MediaUploaderDropzone } from "./media-uploader-dropzone";
+import { MediaUploaderGrid } from "./media-uploader-grid";
-const FILETYPE_ICON: Record = {
- image: ImageIcon,
- video: Film,
- pdf: FileText,
- doc: FileIcon,
- other: Paperclip,
+export type MediaUploadRequest = {
+ localId: string;
+ file: File;
+ onProgress: (percent: number) => void;
};
type MediaUploaderProps = {
uploads: MediaFile[];
- onChange: (next: MediaFile[]) => void;
+ /** Upload-then-categorize: files only ÔÇö category assigned after persist. */
+ onUpload?: (requests: MediaUploadRequest[]) => void;
+ onRemove?: (id: string) => void;
+ onCategorize?: (id: string, category: UploadableMediaCategory) => void;
+ onRetry?: (localId: string) => void;
+ onCancel?: (localId: string) => void;
+ /** Legacy local-only mode (table modal fallback). */
+ onChange?: (next: MediaFile[]) => void;
+ readOnly?: boolean;
+ pending?: boolean;
};
-export function MediaUploader({ uploads, onChange }: MediaUploaderProps) {
+export function MediaUploader({
+ uploads,
+ onUpload,
+ onRemove,
+ onCategorize,
+ onRetry,
+ onCancel,
+ onChange,
+ readOnly = false,
+ pending = false,
+}: MediaUploaderProps) {
const inputRef = useRef(null);
const addFiles = (files: FileList | null) => {
- if (!files || files.length === 0) return;
- onChange([...uploads, ...Array.from(files).map(makeMediaFile)]);
+ if (readOnly || pending || !files || files.length === 0) return;
+
+ const accepted: File[] = [];
+ for (const file of Array.from(files)) {
+ if (!isWorkOrderMediaTypeAllowed(file)) {
+ toast.error(`"${file.name}" is not a supported type (JPG, PNG, MP4, MOV).`);
+ continue;
+ }
+ if (isWorkOrderFileTooLarge(file)) {
+ toast.error(`"${file.name}" exceeds the ${formatMediaSizeLimit()} limit.`);
+ continue;
+ }
+ accepted.push(file);
+ }
+ if (accepted.length === 0) return;
+
+ if (onUpload) {
+ const requests: MediaUploadRequest[] = accepted.map((file) => ({
+ localId: `local-${crypto.randomUUID()}`,
+ file,
+ onProgress: () => undefined,
+ }));
+ onUpload(requests);
+ return;
+ }
+
+ if (onChange) {
+ onChange([...uploads, ...accepted.map(makeMediaFile)]);
+ }
};
- const remove = (id: string) => onChange(uploads.filter((u) => u.id !== id));
+ const remove = (id: string) => {
+ if (readOnly || pending) return;
+ const target = uploads.find((u) => u.id === id);
+ if (target && !canDeleteWorkOrderMedia(target) && target.uploadStatus !== "failed") return;
+ if (onRemove) {
+ onRemove(id);
+ return;
+ }
+ onChange?.(uploads.filter((u) => u.id !== id));
+ };
return (
-
inputRef.current?.click()}
- onDragOver={(e) => e.preventDefault()}
- onDrop={(e) => {
- e.preventDefault();
- addFiles(e.dataTransfer.files);
- }}
- className="flex w-full flex-col items-center justify-center gap-1.5 transition-colors hover:bg-[var(--color-bg-muted)]"
- style={{
- minHeight: 120,
- border: "1px dashed var(--color-border)",
- borderRadius: 8,
- }}
- >
-
-
- Drag files here or click to browse
-
-
- Accepted: JPG, PNG, MP4, MOV · Max 50MB per file
-
-
-
addFiles(e.target.files)}
- />
- {uploads.length > 0 && (
-
- {uploads.map((u) => {
- const TypeIcon = FILETYPE_ICON[u.fileType];
- return (
-
-
- {u.fileType === "image" ? (
-
- ) : u.fileType === "video" ? (
- <>
-
-
-
-
- >
- ) : (
-
-
-
- )}
-
remove(u.id)}
- title="Remove"
- className="absolute flex items-center justify-center transition-opacity hover:opacity-80"
- style={{
- top: 4,
- right: 4,
- width: 18,
- height: 18,
- borderRadius: 4,
- background: "var(--color-bg-surface)",
- border: "1px solid var(--color-border)",
- color: "var(--color-text-muted)",
- }}
- >
-
-
-
-
-
- );
- })}
-
+ {!readOnly && (
+
)}
+
);
}
diff --git a/src/app/(protected)/workorders/_components/wizard/new-wo-wizard.tsx b/src/app/(protected)/workorders/_components/wizard/new-wo-wizard.tsx
index 6643256d..7b7d5326 100644
--- a/src/app/(protected)/workorders/_components/wizard/new-wo-wizard.tsx
+++ b/src/app/(protected)/workorders/_components/wizard/new-wo-wizard.tsx
@@ -1,38 +1,23 @@
-import { useEffect, useMemo, useState } from "react";
import { Dialog } from "@mui/material";
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
import { Text } from "@/components/ui/text";
-import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
-import { isWoWizardMockEnabled } from "@/domain/work-orders/mocks/is-wo-wizard-mock-enabled";
-import {
- getMockWoWizardLocationDetail,
- MOCK_WO_WIZARD_LOCATIONS,
- MOCK_WO_WIZARD_SERVICES,
- MOCK_WO_WIZARD_USERS,
- MOCK_WO_WIZARD_VENDORS,
-} from "@/domain/work-orders/mocks/wo-wizard-mock-data";
+import { DuplicateWoNumberDialog } from "@/app/(protected)/workorders/_components/list/duplicate-wo-number-dialog";
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
-import {
- EMPTY_WIZARD_DRAFT,
- WIZARD_STEPS,
- type WorkOrderWizardDraft,
-} from "@/domain/work-orders/types/work-order-wizard";
-import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
-import {
- useWorkOrderLocations,
- useWorkOrderVendors,
-} from "@/domain/work-orders/use-cases/use-work-order-detail";
-import { useCreateWorkOrderFromWizard } from "@/domain/work-orders/use-cases/use-create-work-order-from-wizard";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+import { WIZARD_STEPS } from "@/domain/work-orders/types/work-order-wizard";
import { WizardFooter } from "@/app/(protected)/workorders/_components/wizard/wizard-footer";
import { WizardSidebar } from "@/app/(protected)/workorders/_components/wizard/wizard-sidebar";
import { WizardStepLocationService } from "@/app/(protected)/workorders/_components/wizard/wizard-step-location-service";
import { WizardStepTypeSchedule } from "@/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule";
import { WizardStepVendorTime } from "@/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time";
+import { useNewWoWizardState } from "@/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-state";
type NewWoWizardProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
users: WorkOrderUser[];
+ existingRows?: WorkOrderTableRow[];
+ onViewExistingWo?: (row: WorkOrderTableRow) => void;
};
function WizardStepHeader({ step }: { step: number }) {
@@ -75,152 +60,99 @@ function WizardStepHeader({ step }: { step: number }) {
);
}
-export function NewWoWizard({ open, onOpenChange, users }: NewWoWizardProps) {
- const mockFallback = isWoWizardMockEnabled();
- const [step, setStep] = useState(1);
- const [draft, setDraft] = useState({
- ...EMPTY_WIZARD_DRAFT,
- status: "Incomplete",
- });
- const [pocAutoFilledFor, setPocAutoFilledFor] = useState("");
-
- const { data: apiLocations = [] } = useWorkOrderLocations();
- const { data: apiProblems = [] } = useDropdownOptionsByCategory("Problem");
- const isMockLocation = draft.locationId.startsWith("mock-");
- const { data: apiLocationDetail } = useLocationDetail(
- isMockLocation ? undefined : draft.locationId || undefined,
- );
- const mockLocationDetail = isMockLocation
- ? getMockWoWizardLocationDetail(draft.locationId)
- : undefined;
- const locationDetail = mockLocationDetail ?? apiLocationDetail;
- const { data: apiVendors = [] } = useWorkOrderVendors(
- undefined,
- isMockLocation ? undefined : locationDetail?.zipCode,
- );
- const createMutation = useCreateWorkOrderFromWizard(() => onOpenChange(false));
-
- const wizardUsers = useMemo(() => {
- if (users.length > 0) {
- return users;
- }
- return mockFallback ? MOCK_WO_WIZARD_USERS : [];
- }, [mockFallback, users]);
- const locations =
- apiLocations.length > 0 ? apiLocations : mockFallback ? MOCK_WO_WIZARD_LOCATIONS : [];
- const problems =
- apiProblems.length > 0 ? apiProblems : mockFallback ? MOCK_WO_WIZARD_SERVICES : [];
- const vendors = apiVendors.length > 0 ? apiVendors : mockFallback ? MOCK_WO_WIZARD_VENDORS : [];
-
- useEffect(() => {
- if (open) {
- setStep(1);
- setDraft({ ...EMPTY_WIZARD_DRAFT, status: "Incomplete" });
- setPocAutoFilledFor("");
- }
- }, [open]);
-
- useEffect(() => {
- if (!draft.locationId || !locationDetail) {
- return;
- }
- if (pocAutoFilledFor === draft.locationId) {
- return;
- }
- setDraft((current) => ({
- ...current,
- pocName: locationDetail.contact ?? current.pocName,
- pocPhone: locationDetail.phone ?? current.pocPhone,
- }));
- setPocAutoFilledFor(draft.locationId);
- }, [draft.locationId, locationDetail, pocAutoFilledFor]);
-
- const setField = (
- key: K,
- value: WorkOrderWizardDraft[K],
- ) => {
- setDraft((current) => ({ ...current, [key]: value }));
- };
-
- const patchDraft = (patch: Partial) => {
- setDraft((current) => ({ ...current, ...patch }));
- if (patch.locationId !== undefined && patch.locationId !== pocAutoFilledFor) {
- setPocAutoFilledFor("");
- }
- };
-
- const handleCreate = () => createMutation.mutate(draft);
+export function NewWoWizard({
+ open,
+ onOpenChange,
+ users,
+ existingRows = [],
+ onViewExistingWo,
+}: NewWoWizardProps) {
+ const wizard = useNewWoWizardState({ open, users, existingRows, onOpenChange });
return (
- onOpenChange(false)}
- maxWidth={false}
- slotProps={{
- ...dialogBlurBackdropSlotProps,
- paper: {
- className: "flex flex-col overflow-hidden p-0",
- style: {
- borderRadius: 12,
- width: 768,
- maxWidth: 768,
- height: 680,
- maxHeight: 680,
+ <>
+ onOpenChange(false)}
+ maxWidth={false}
+ slotProps={{
+ ...dialogBlurBackdropSlotProps,
+ paper: {
+ className: "flex flex-col overflow-hidden p-0",
+ style: {
+ borderRadius: 12,
+ width: 768,
+ maxWidth: 768,
+ height: 680,
+ maxHeight: 680,
+ },
},
- },
- }}
- >
- Guided setup for creating a new work order
+ }}
+ >
+ Guided setup for creating a new work order
-
-
+
+
-
-
+
+
- {step === 1 && (
-
- )}
+ {wizard.step === 1 && (
+
+ )}
- {step === 2 && (
- ({ id: p.id, value: p.value }))}
- onUpdate={setField}
- onPatch={patchDraft}
- />
- )}
+ {wizard.step === 2 && (
+ ({ id: p.id, value: p.value }))}
+ onUpdate={wizard.setField}
+ onPatch={wizard.patchDraft}
+ />
+ )}
- {step === 3 && (
-
- )}
+ {wizard.step === 3 && (
+
+ )}
+
-
-
onOpenChange(false)}
- onBack={() => setStep((s) => s - 1)}
- onContinue={() => setStep((s) => s + 1)}
- onCreate={handleCreate}
- isCreating={createMutation.isPending}
+ onOpenChange(false)}
+ onBack={() => wizard.setStep((s) => s - 1)}
+ onContinue={() => wizard.setStep((s) => s + 1)}
+ onCreate={wizard.handleCreate}
+ isCreating={wizard.isCreating}
+ />
+
+
+ wizard.setDuplicateRow(null)}
+ onViewExisting={(row) => {
+ wizard.setDuplicateRow(null);
+ onOpenChange(false);
+ onViewExistingWo?.(row);
+ }}
/>
-
+ >
);
}
diff --git a/src/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-state.ts b/src/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-state.ts
new file mode 100644
index 00000000..748fb3c5
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-state.ts
@@ -0,0 +1,136 @@
+import { useEffect, useMemo, useState } from "react";
+import { isWoWizardMockEnabled } from "@/domain/work-orders/mocks/is-wo-wizard-mock-enabled";
+import {
+ getMockWoWizardLocationDetail,
+ MOCK_WO_WIZARD_LOCATIONS,
+ MOCK_WO_WIZARD_SERVICES,
+ MOCK_WO_WIZARD_USERS,
+ MOCK_WO_WIZARD_VENDORS,
+} from "@/domain/work-orders/mocks/wo-wizard-mock-data";
+import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+import {
+ EMPTY_WIZARD_DRAFT,
+ type WorkOrderWizardDraft,
+} from "@/domain/work-orders/types/work-order-wizard";
+import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
+import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
+import {
+ useWorkOrderLocations,
+ useWorkOrderVendors,
+} from "@/domain/work-orders/use-cases/use-work-order-detail";
+import { useCreateWorkOrderFromWizard } from "@/domain/work-orders/use-cases/use-create-work-order-from-wizard";
+import { useWizardDuplicateActions } from "./use-wizard-duplicate-actions";
+
+type UseNewWoWizardStateArgs = {
+ open: boolean;
+ users: WorkOrderUser[];
+ existingRows: WorkOrderTableRow[];
+ onOpenChange: (open: boolean) => void;
+};
+
+export function useNewWoWizardState({
+ open,
+ users,
+ existingRows,
+ onOpenChange,
+}: UseNewWoWizardStateArgs) {
+ const mockFallback = isWoWizardMockEnabled();
+ const [step, setStep] = useState(1);
+ const [draft, setDraft] = useState({
+ ...EMPTY_WIZARD_DRAFT,
+ status: "Incomplete",
+ });
+ const [pocAutoFilledFor, setPocAutoFilledFor] = useState("");
+
+ const { data: apiLocations = [] } = useWorkOrderLocations();
+ const { data: apiProblems = [] } = useDropdownOptionsByCategory("Problem");
+ const isMockLocation = draft.locationId.startsWith("mock-");
+ const { data: apiLocationDetail } = useLocationDetail(
+ isMockLocation ? undefined : draft.locationId || undefined,
+ );
+ const mockLocationDetail = isMockLocation
+ ? getMockWoWizardLocationDetail(draft.locationId)
+ : undefined;
+ const locationDetail = mockLocationDetail ?? apiLocationDetail;
+ const { data: apiVendors = [] } = useWorkOrderVendors(
+ undefined,
+ isMockLocation ? undefined : locationDetail?.zipCode,
+ );
+ const createMutation = useCreateWorkOrderFromWizard(() => onOpenChange(false));
+ const { duplicateRow, setDuplicateRow, handleDuplicateFound, handleCreate, isCreating } =
+ useWizardDuplicateActions({
+ open,
+ draft,
+ existingRows,
+ createMutation,
+ });
+
+ const wizardUsers = useMemo(() => {
+ if (users.length > 0) {
+ return users;
+ }
+ return mockFallback ? MOCK_WO_WIZARD_USERS : [];
+ }, [mockFallback, users]);
+ const locations =
+ apiLocations.length > 0 ? apiLocations : mockFallback ? MOCK_WO_WIZARD_LOCATIONS : [];
+ const problems =
+ apiProblems.length > 0 ? apiProblems : mockFallback ? MOCK_WO_WIZARD_SERVICES : [];
+ const vendors = apiVendors.length > 0 ? apiVendors : mockFallback ? MOCK_WO_WIZARD_VENDORS : [];
+
+ useEffect(() => {
+ if (open) {
+ setStep(1);
+ setDraft({ ...EMPTY_WIZARD_DRAFT, status: "Incomplete" });
+ setPocAutoFilledFor("");
+ setDuplicateRow(null);
+ }
+ }, [open, setDuplicateRow]);
+
+ useEffect(() => {
+ if (!draft.locationId || !locationDetail) {
+ return;
+ }
+ if (pocAutoFilledFor === draft.locationId) {
+ return;
+ }
+ setDraft((current) => ({
+ ...current,
+ pocName: locationDetail.contact ?? current.pocName,
+ pocPhone: locationDetail.phone ?? current.pocPhone,
+ }));
+ setPocAutoFilledFor(draft.locationId);
+ }, [draft.locationId, locationDetail, pocAutoFilledFor]);
+
+ const setField = (
+ key: K,
+ value: WorkOrderWizardDraft[K],
+ ) => {
+ setDraft((current) => ({ ...current, [key]: value }));
+ };
+
+ const patchDraft = (patch: Partial) => {
+ setDraft((current) => ({ ...current, ...patch }));
+ if (patch.locationId !== undefined && patch.locationId !== pocAutoFilledFor) {
+ setPocAutoFilledFor("");
+ }
+ };
+
+ return {
+ step,
+ setStep,
+ draft,
+ duplicateRow,
+ setDuplicateRow,
+ wizardUsers,
+ locations,
+ problems,
+ vendors,
+ locationDetail,
+ setField,
+ patchDraft,
+ handleDuplicateFound,
+ handleCreate,
+ isCreating,
+ };
+}
diff --git a/src/app/(protected)/workorders/_components/wizard/use-wizard-duplicate-actions.ts b/src/app/(protected)/workorders/_components/wizard/use-wizard-duplicate-actions.ts
new file mode 100644
index 00000000..0f015d2c
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/wizard/use-wizard-duplicate-actions.ts
@@ -0,0 +1,100 @@
+import { useEffect, useRef, useState } from "react";
+import { toast } from "react-toastify";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
+import { findDuplicateWoNumberRow } from "@/domain/work-orders/utils/find-duplicate-wo-number-row";
+import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
+
+type UseWizardDuplicateActionsArgs = {
+ open: boolean;
+ draft: WorkOrderWizardDraft;
+ existingRows: WorkOrderTableRow[];
+ createMutation: {
+ mutate: (draft: WorkOrderWizardDraft) => void;
+ isPending: boolean;
+ };
+};
+
+export function useWizardDuplicateActions({
+ open,
+ draft,
+ existingRows,
+ createMutation,
+}: UseWizardDuplicateActionsArgs) {
+ const [duplicateRow, setDuplicateRow] = useState(null);
+ const [isCheckingDuplicate, setIsCheckingDuplicate] = useState(false);
+ const openRef = useRef(open);
+ const draftRef = useRef(draft);
+
+ useEffect(() => {
+ openRef.current = open;
+ }, [open]);
+
+ useEffect(() => {
+ draftRef.current = draft;
+ }, [draft]);
+
+ const resolveDuplicate = async (woNumber: string) => {
+ const localDuplicate = findDuplicateWoNumberRow(woNumber, existingRows);
+ if (localDuplicate) {
+ return localDuplicate;
+ }
+
+ // The loaded board rows are only an immediate UX hint; the unfiltered API lookup is authoritative.
+ return workOrdersApi.findByWoNumber(woNumber);
+ };
+
+ const handleDuplicateFound = (woNumber: string) => {
+ void (async () => {
+ try {
+ const duplicate = await resolveDuplicate(woNumber);
+ // Ignore stale lookups after dismiss or after the user edits the WO#.
+ if (!openRef.current || woNumber !== draftRef.current.woNumber) {
+ return;
+ }
+ if (duplicate) {
+ setDuplicateRow(duplicate);
+ }
+ } catch {
+ if (!openRef.current) {
+ return;
+ }
+ toast.error("Unable to verify the work order number. Please try again.");
+ }
+ })();
+ };
+
+ const handleCreate = () => {
+ const woNumberAtSubmit = draft.woNumber;
+ void (async () => {
+ setIsCheckingDuplicate(true);
+ try {
+ const duplicate = await resolveDuplicate(woNumberAtSubmit);
+ // Ignore after dismiss or if the WO# changed while the request was in flight.
+ if (!openRef.current || woNumberAtSubmit !== draftRef.current.woNumber) {
+ return;
+ }
+ if (duplicate) {
+ setDuplicateRow(duplicate);
+ return;
+ }
+ createMutation.mutate(draftRef.current);
+ } catch {
+ if (!openRef.current) {
+ return;
+ }
+ toast.error("Unable to verify the work order number. Please try again.");
+ } finally {
+ setIsCheckingDuplicate(false);
+ }
+ })();
+ };
+
+ return {
+ duplicateRow,
+ setDuplicateRow,
+ handleDuplicateFound,
+ handleCreate,
+ isCreating: createMutation.isPending || isCheckingDuplicate,
+ };
+}
diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule.tsx
index 51c6f0d3..7f35c2e7 100644
--- a/src/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule.tsx
+++ b/src/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule.tsx
@@ -117,6 +117,7 @@ type WizardStepTypeScheduleProps = {
users: WorkOrderUser[];
onUpdate: (key: K, value: WorkOrderWizardDraft[K]) => void;
onPatch: (patch: Partial) => void;
+ onDuplicateWoNumber?: (woNumber: string) => void;
};
export function WizardStepTypeSchedule({
@@ -124,6 +125,7 @@ export function WizardStepTypeSchedule({
users,
onUpdate,
onPatch,
+ onDuplicateWoNumber,
}: WizardStepTypeScheduleProps) {
return (
@@ -138,6 +140,7 @@ export function WizardStepTypeSchedule({
type="text"
value={draft.woNumber}
onChange={(e) => onUpdate("woNumber", e.target.value)}
+ onBlur={() => onDuplicateWoNumber?.(draft.woNumber)}
placeholder="e.g. 24818"
maxLength={20}
className={DLG_INPUT_CLS}
diff --git a/src/app/(protected)/workorders/_hooks/create-comp-doc-dialog-actions.ts b/src/app/(protected)/workorders/_hooks/create-comp-doc-dialog-actions.ts
new file mode 100644
index 00000000..343f9a8f
--- /dev/null
+++ b/src/app/(protected)/workorders/_hooks/create-comp-doc-dialog-actions.ts
@@ -0,0 +1,170 @@
+import type { QueryClient } from "@tanstack/react-query";
+import { toast } from "react-toastify";
+import { showCompDocDraftSavedToast } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-draft-toast";
+import { locationsApi } from "@/domain/locations/api/locations-api";
+import type { Location } from "@/domain/locations/types/location";
+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 { buildDocHtml, printDoc } from "@/domain/work-orders/utils/completion-doc-html";
+import { fmtDocNow } from "@/domain/work-orders/utils/completion-doc-state";
+import { queryKeys } from "@/infra/query-key/query-key";
+
+type CompDocSite = { street: string; fullAddress: string };
+
+export function buildCompDocAddressLine(site: CompDocSite): string {
+ if (site.street.trim()) return site.street.trim();
+ return site.fullAddress !== "—" ? site.fullAddress : "";
+}
+
+function locationUpdatePayload(location: Location, address: string): Location {
+ return {
+ ...location,
+ address,
+ };
+}
+
+type CreateCompDocDialogActionsParams = {
+ row: WorkOrderTableRow;
+ notes: string;
+ generatedAt: string;
+ editDateCompleted: string;
+ editAddress: string;
+ docState: DocState;
+ docFields: DocFields;
+ site: CompDocSite;
+ locId: string;
+ isMockLocation: boolean;
+ locationDetail: Location | undefined;
+ queryClient: QueryClient;
+ onSave: (data: CompDocData, status: CompDocSaveStatus) => void | Promise
;
+ onDateCompletedChange?: (date: string) => void | Promise;
+ setDocState: (state: DocState) => void;
+ setGeneratedAt: (value: string) => void;
+};
+
+export function createCompDocDialogActions({
+ row,
+ notes,
+ generatedAt,
+ editDateCompleted,
+ editAddress,
+ docState,
+ docFields,
+ site,
+ locId,
+ isMockLocation,
+ locationDetail,
+ queryClient,
+ onSave,
+ onDateCompletedChange,
+ setDocState,
+ setGeneratedAt,
+}: CreateCompDocDialogActionsParams) {
+ async function persist(extra: Partial, status: CompDocSaveStatus): Promise {
+ await onSave({ notes, generatedAt, ...extra }, status);
+ }
+
+ async function syncCompletedDateIfChanged(): Promise {
+ if (editDateCompleted !== (row.completedDate || "")) {
+ await onDateCompletedChange?.(editDateCompleted);
+ }
+ }
+
+ async function syncAddressIfChanged(): Promise {
+ const next = editAddress.trim();
+ const current = buildCompDocAddressLine(site);
+ if (next === current) return;
+ if (isMockLocation || !locId) return;
+ if (!locationDetail) {
+ throw new Error("Location details are still loading. Try saving again.");
+ }
+
+ await locationsApi.update(locId, locationUpdatePayload(locationDetail, next));
+ void queryClient.invalidateQueries({ queryKey: queryKeys.locations.detail(locId) });
+ void queryClient.invalidateQueries({ queryKey: queryKeys.locations.all });
+ void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
+ }
+
+ function refreshAuthoritativeState() {
+ void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
+ if (locId && !isMockLocation) {
+ void queryClient.invalidateQueries({ queryKey: queryKeys.locations.detail(locId) });
+ void queryClient.invalidateQueries({ queryKey: queryKeys.locations.all });
+ }
+ }
+
+ async function handleMarkNotRequired() {
+ const previous = docState;
+ setDocState("not-required");
+ try {
+ await persist({}, "NN");
+ } catch (error) {
+ setDocState(previous);
+ refreshAuthoritativeState();
+ toast.error(
+ error instanceof Error ? error.message : "Failed to mark document as not required",
+ );
+ }
+ }
+
+ async function handleUndo() {
+ const previous = docState;
+ setDocState("draft");
+ try {
+ await persist({}, "No");
+ } catch (error) {
+ setDocState(previous);
+ refreshAuthoritativeState();
+ toast.error(error instanceof Error ? error.message : "Failed to undo not-required");
+ }
+ }
+
+ async function handleSaveDraft() {
+ try {
+ await syncCompletedDateIfChanged();
+ await syncAddressIfChanged();
+ await persist({}, "No");
+ showCompDocDraftSavedToast();
+ } catch (error) {
+ refreshAuthoritativeState();
+ toast.error(error instanceof Error ? error.message : "Failed to save draft");
+ }
+ }
+
+ async function handleGenerate() {
+ if (!editDateCompleted.trim()) {
+ toast.error("Date Work Completed is required to generate the document.");
+ return;
+ }
+ const ts = fmtDocNow();
+ try {
+ // Prerequisites first; only mark Generated after completedDate/address persist.
+ await syncCompletedDateIfChanged();
+ await syncAddressIfChanged();
+ await persist({ generatedAt: ts }, "Yes");
+ setGeneratedAt(ts);
+ setDocState("generated");
+ } catch (error) {
+ // Keep Draft UI; refresh so any partially persisted date/address is authoritative.
+ refreshAuthoritativeState();
+ toast.error(error instanceof Error ? error.message : "Failed to generate document");
+ }
+ }
+
+ function handleDownloadPdf() {
+ printDoc(buildDocHtml(row, { notes, generatedAt }, docFields));
+ }
+
+ return {
+ handleMarkNotRequired,
+ handleUndo,
+ handleSaveDraft,
+ handleGenerate,
+ handleDownloadPdf,
+ };
+}
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..ad5c470b
--- /dev/null
+++ b/src/app/(protected)/workorders/_hooks/use-comp-doc-dialog.ts
@@ -0,0 +1,157 @@
+import { useEffect, useMemo, useRef, useState } from "react";
+import { useQueryClient } from "@tanstack/react-query";
+import {
+ buildCompDocAddressLine,
+ createCompDocDialogActions,
+} from "@/app/(protected)/workorders/_hooks/create-comp-doc-dialog-actions";
+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 { resolveCompDocSite } from "@/domain/work-orders/utils/completion-doc-site";
+import { DOC_STATE_CFG, docStateOf } from "@/domain/work-orders/utils/completion-doc-state";
+
+type UseCompDocDialogParams = {
+ row: WorkOrderTableRow;
+ open: boolean;
+ data?: CompDocData;
+ onSave: (data: CompDocData, status: CompDocSaveStatus) => void | Promise;
+ onDateCompletedChange?: (date: string) => void | Promise;
+};
+
+export function useCompDocDialog({
+ row,
+ open,
+ data,
+ onSave,
+ onDateCompletedChange,
+}: UseCompDocDialogParams) {
+ const queryClient = useQueryClient();
+ 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("");
+ const [editAddress, setEditAddress] = useState("");
+ const seedKeyRef = useRef(null);
+ const addressSeededRef = useRef(false);
+ const addressTouchedRef = useRef(false);
+
+ // Seed once per open/WO — row/detail refetches must not wipe in-progress edits.
+ // Address waits for authoritative locationDetail (when a real location id exists)
+ // and still accepts that first response without overwriting later user edits.
+ useEffect(() => {
+ if (!open) {
+ seedKeyRef.current = null;
+ addressSeededRef.current = false;
+ addressTouchedRef.current = false;
+ return;
+ }
+
+ const key = String(row.id);
+ if (seedKeyRef.current !== key) {
+ seedKeyRef.current = key;
+ addressSeededRef.current = false;
+ addressTouchedRef.current = false;
+ setNotes(data?.notes ?? "");
+ setDocState(docStateOf(row.docStatus));
+ setGeneratedAt(data?.generatedAt ?? "");
+ setEditDateCompleted(row.completedDate || "");
+ }
+
+ if (addressTouchedRef.current || addressSeededRef.current) {
+ return;
+ }
+
+ const awaitingLocation = Boolean(locId) && !isMockLocation && !locationDetail;
+ if (awaitingLocation) {
+ return;
+ }
+
+ setEditAddress(buildCompDocAddressLine(resolveCompDocSite(row, locationDetail)));
+ addressSeededRef.current = true;
+ }, [open, row, data?.notes, data?.generatedAt, locationDetail, locId, isMockLocation]);
+
+ const handleAddressChange = (value: string) => {
+ addressTouchedRef.current = true;
+ setEditAddress(value);
+ };
+
+ const amazonRep = row.pocName || "";
+ const seaRep = row.tech || "";
+ const cfg = DOC_STATE_CFG[docState];
+ const isNotRequired = docState === "not-required";
+ const isGenerated = docState === "generated";
+
+ const effectiveStreet = editAddress.trim() || site.street;
+ const docFields: DocFields = {
+ street: effectiveStreet,
+ city: site.city,
+ state: site.state,
+ zip: site.zip,
+ dateCompleted: editDateCompleted,
+ amazonRep,
+ seaRep,
+ };
+
+ const previewSite = useMemo(
+ () => ({
+ ...site,
+ street: effectiveStreet,
+ fullAddress:
+ [effectiveStreet, site.label, site.zip].filter(Boolean).join(", ") ||
+ effectiveStreet ||
+ "—",
+ }),
+ [site, effectiveStreet],
+ );
+
+ const actions = createCompDocDialogActions({
+ row,
+ notes,
+ generatedAt,
+ editDateCompleted,
+ editAddress,
+ docState,
+ docFields,
+ site,
+ locId,
+ isMockLocation,
+ locationDetail,
+ queryClient,
+ onSave,
+ onDateCompletedChange,
+ setDocState,
+ setGeneratedAt,
+ });
+
+ return {
+ site: previewSite,
+ notes,
+ setNotes,
+ docState,
+ generatedAt,
+ editAddress,
+ setEditAddress: handleAddressChange,
+ editDateCompleted,
+ setEditDateCompleted,
+ amazonRep,
+ seaRep,
+ cfg,
+ isNotRequired,
+ isGenerated,
+ ...actions,
+ };
+}
diff --git a/src/app/(protected)/workorders/_hooks/use-media-upload-session.ts b/src/app/(protected)/workorders/_hooks/use-media-upload-session.ts
new file mode 100644
index 00000000..ae2ff50c
--- /dev/null
+++ b/src/app/(protected)/workorders/_hooks/use-media-upload-session.ts
@@ -0,0 +1,199 @@
+import { useEffect, useMemo, useRef, useState } from "react";
+import type { MediaUploadRequest } from "@/app/(protected)/workorders/_components/media/media-uploader";
+import { boardMediaItemsToMediaFiles } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
+import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
+import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail";
+import { classifyUpload } from "@/domain/work-orders/utils/work-order-media-utils";
+
+type LocalUpload = MediaFile & { sourceFile?: File; signal?: AbortSignal };
+
+type UploadMutate = (input: {
+ file: File;
+ signal?: AbortSignal;
+ onProgress?: (percent: number) => void;
+}) => Promise;
+
+type SessionUploadRequest = {
+ localId: string;
+ file: File;
+ signal: AbortSignal;
+};
+
+function toLocalUpload(request: SessionUploadRequest): LocalUpload {
+ const classified = classifyUpload(request.file);
+ return {
+ id: request.localId,
+ name: request.file.name,
+ fileType: classified.fileType,
+ url: URL.createObjectURL(request.file),
+ category: classified.category,
+ purpose: null,
+ progress: 0,
+ uploadStatus: "uploading" as const,
+ sourceFile: request.file,
+ signal: request.signal,
+ };
+}
+
+function isSameAttempt(item: LocalUpload, request: SessionUploadRequest) {
+ return item.id === request.localId && item.signal === request.signal;
+}
+
+function revokeBlobUrl(url: string) {
+ if (url.startsWith("blob:")) {
+ URL.revokeObjectURL(url);
+ }
+}
+
+export function useMediaUploadSession(
+ detail: WorkOrderBoardDetail | undefined,
+ uploadMutateAsync: UploadMutate,
+) {
+ const [localUploads, setLocalUploads] = useState([]);
+ const controllersRef = useRef(new Map());
+ const workOrderId = detail?.info.id;
+ const previousWorkOrderIdRef = useRef(workOrderId);
+
+ // Drop local tiles and abort in-flight uploads when the bound WO changes.
+ // Skip the first bind (undefined  id) so an early upload is not cleared.
+ useEffect(() => {
+ if (previousWorkOrderIdRef.current === workOrderId) return;
+ const hadPrevious = previousWorkOrderIdRef.current != null;
+ previousWorkOrderIdRef.current = workOrderId;
+ if (!hadPrevious) return;
+
+ for (const controller of controllersRef.current.values()) {
+ controller.abort();
+ }
+ controllersRef.current.clear();
+ setLocalUploads((prev) => {
+ for (const item of prev) {
+ revokeBlobUrl(item.url);
+ }
+ return [];
+ });
+ }, [workOrderId]);
+
+ const serverUploads = useMemo(
+ () => boardMediaItemsToMediaFiles(detail?.media ?? []),
+ [detail?.media],
+ );
+
+ const uploads = useMemo(() => {
+ const serverIds = new Set(serverUploads.map((item) => item.id));
+ const pendingLocals = localUploads.filter(
+ (item) => item.uploadStatus === "uploading" || item.uploadStatus === "failed",
+ );
+ return [...serverUploads, ...pendingLocals.filter((item) => !serverIds.has(item.id))];
+ }, [serverUploads, localUploads]);
+
+ function clearController(localId: string) {
+ controllersRef.current.delete(localId);
+ }
+
+ function replaceController(localId: string): AbortController {
+ controllersRef.current.get(localId)?.abort();
+ const controller = new AbortController();
+ controllersRef.current.set(localId, controller);
+ return controller;
+ }
+
+ function startUploads(requests: MediaUploadRequest[]) {
+ const sessionRequests: SessionUploadRequest[] = requests.map((request) => ({
+ localId: request.localId,
+ file: request.file,
+ signal: replaceController(request.localId).signal,
+ }));
+
+ setLocalUploads((prev) => {
+ const next = [...prev];
+ for (const request of sessionRequests) {
+ const entry = toLocalUpload(request);
+ const index = next.findIndex((item) => item.id === request.localId);
+ if (index >= 0) {
+ const previous = next[index];
+ if (previous) {
+ revokeBlobUrl(previous.url);
+ }
+ next[index] = entry;
+ } else {
+ next.push(entry);
+ }
+ }
+ return next;
+ });
+
+ for (const request of sessionRequests) {
+ void uploadMutateAsync({
+ file: request.file,
+ signal: request.signal,
+ onProgress: (percent) => {
+ setLocalUploads((prev) =>
+ prev.map((item) =>
+ isSameAttempt(item, request) ? { ...item, progress: percent } : item,
+ ),
+ );
+ },
+ })
+ .then(() => {
+ clearController(request.localId);
+ setLocalUploads((prev) => prev.filter((item) => !isSameAttempt(item, request)));
+ })
+ .catch((error: Error) => {
+ if (error.name === "AbortError") {
+ clearController(request.localId);
+ setLocalUploads((prev) => prev.filter((item) => !isSameAttempt(item, request)));
+ return;
+ }
+ // Keep controller entry only while retryable; replaceController on retry.
+ setLocalUploads((prev) =>
+ prev.map((item) =>
+ isSameAttempt(item, request)
+ ? {
+ ...item,
+ uploadStatus: "failed",
+ errorMessage: error.message || "Upload failed",
+ progress: undefined,
+ }
+ : item,
+ ),
+ );
+ });
+ }
+ }
+
+ function retryUpload(localId: string) {
+ const target = localUploads.find((item) => item.id === localId);
+ if (!target?.sourceFile) return;
+ startUploads([
+ {
+ localId,
+ file: target.sourceFile,
+ onProgress: () => undefined,
+ },
+ ]);
+ }
+
+ function cancelUpload(localId: string) {
+ controllersRef.current.get(localId)?.abort();
+ clearController(localId);
+ setLocalUploads((prev) => {
+ const target = prev.find((item) => item.id === localId);
+ if (target) {
+ revokeBlobUrl(target.url);
+ }
+ return prev.filter((item) => item.id !== localId);
+ });
+ }
+
+ function removeLocal(id: string) {
+ clearController(id);
+ setLocalUploads((prev) => prev.filter((item) => item.id !== id));
+ }
+
+ function isLocal(id: string) {
+ return localUploads.some((item) => item.id === id);
+ }
+
+ return { uploads, startUploads, retryUpload, cancelUpload, removeLocal, isLocal };
+}
diff --git a/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts
new file mode 100644
index 00000000..b2963c11
--- /dev/null
+++ b/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts
@@ -0,0 +1,131 @@
+import { useMemo, useState } from "react";
+import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
+import type { CompDocData, CompDocSaveStatus } from "@/domain/work-orders/types/completion-doc";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+import {
+ useWorkOrderLocations,
+ useWorkOrderVendors,
+} from "@/domain/work-orders/use-cases/use-work-order-detail";
+import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
+import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
+import { guardDialogClose } from "@/lib/dialog-guard";
+import { useWorkOrdersListFilters } from "./use-work-orders-list-filters";
+import { useWorkOrdersTableData } from "./use-work-orders-table-data";
+
+export function useWorkOrdersListPage() {
+ const [wizardOpen, setWizardOpen] = useState(false);
+ const [slideOverRow, setSlideOverRow] = useState(null);
+ const [slideOverEdit, setSlideOverEdit] = useState(false);
+ const [confirmCancel, setConfirmCancel] = useState(null);
+ const [docRow, setDocRow] = useState(null);
+ const [mediaRow, setMediaRow] = useState(null);
+ const [completionDocs, setCompletionDocs] = useState>({});
+ const filters = useWorkOrdersListFilters();
+ const tableData = useWorkOrdersTableData(filters);
+ const {
+ patchField,
+ patchFieldAsync,
+ isPending: isPatchPending,
+ } = useWorkOrderTableMutations({
+ onPatch: tableData.patchRow,
+ clearPatch: tableData.clearRowPatch,
+ getRow: (id) => tableData.rows.find((row) => String(row.id) === String(id)),
+ });
+
+ const { data: users = [], isLoading: usersLoading } = useWorkOrderUsers();
+ const { data: locations = [] } = useWorkOrderLocations();
+ const { data: vendors = [] } = useWorkOrderVendors();
+ const { data: problemOptions = [] } = useDropdownOptionsByCategory("Problem");
+
+ const pmTypes = useMemo(
+ () => problemOptions.map((option) => option.value).filter(Boolean),
+ [problemOptions],
+ );
+
+ const vendorTechs = useMemo(
+ () => [...new Set(vendors.map((vendor) => vendor.companyName).filter(Boolean))],
+ [vendors],
+ );
+
+ const activeSlideOverRow = useMemo(() => {
+ if (!slideOverRow) return null;
+ return tableData.rows.find((row) => row.id === slideOverRow.id) ?? slideOverRow;
+ }, [slideOverRow, tableData.rows]);
+
+ const activeDocRow = useMemo(() => {
+ if (!docRow) return null;
+ return tableData.rows.find((row) => row.id === docRow.id) ?? docRow;
+ }, [docRow, tableData.rows]);
+
+ const activeMediaRow = useMemo(() => {
+ if (!mediaRow) return null;
+ return tableData.rows.find((row) => row.id === mediaRow.id) ?? mediaRow;
+ }, [mediaRow, tableData.rows]);
+
+ const handleOpenSlideOver = (row: WorkOrderTableRow, edit = false) => {
+ setSlideOverRow(row);
+ setSlideOverEdit(edit);
+ };
+
+ const handleCloseSlideOver = () => {
+ setSlideOverRow(null);
+ setSlideOverEdit(false);
+ tableData.setSelectedId(null);
+ };
+
+ const handleConfirmCancel = () => {
+ if (!confirmCancel) return;
+ patchField(confirmCancel.id, { status: "Canceled" });
+ setConfirmCancel(null);
+ handleCloseSlideOver();
+ };
+
+ const cancelDialogClose = guardDialogClose(isPatchPending, () => setConfirmCancel(null));
+
+ const saveCompletionDoc = async (
+ row: WorkOrderTableRow,
+ data: CompDocData,
+ status: CompDocSaveStatus,
+ ) => {
+ // Persist PM comments + generatedAt in session state (no dedicated BE field yet)
+ // before patching board docStatus — Save draft / Generate / NN all share this path.
+ const nextData: CompDocData = {
+ notes: data.notes,
+ generatedAt: data.generatedAt,
+ };
+ setCompletionDocs((prev) => ({ ...prev, [String(row.id)]: nextData }));
+ await patchFieldAsync(row.id, { docStatus: status });
+ };
+
+ return {
+ wizardOpen,
+ setWizardOpen,
+ slideOverEdit,
+ confirmCancel,
+ setConfirmCancel,
+ docRow,
+ setDocRow,
+ mediaRow,
+ setMediaRow,
+ completionDocs,
+ filters,
+ tableData,
+ patchField,
+ patchFieldAsync,
+ isPatchPending,
+ users,
+ usersLoading,
+ locations,
+ vendors,
+ pmTypes,
+ vendorTechs,
+ activeSlideOverRow,
+ activeDocRow,
+ activeMediaRow,
+ handleOpenSlideOver,
+ handleCloseSlideOver,
+ handleConfirmCancel,
+ cancelDialogClose,
+ saveCompletionDoc,
+ };
+}
diff --git a/src/app/(protected)/workorders/_hooks/use-work-orders-table-ui-state.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-table-ui-state.ts
index ad0797e9..82cd07c6 100644
--- a/src/app/(protected)/workorders/_hooks/use-work-orders-table-ui-state.ts
+++ b/src/app/(protected)/workorders/_hooks/use-work-orders-table-ui-state.ts
@@ -49,12 +49,16 @@ export function useWorkOrdersTableUiState(weekMonday: string) {
const startDayDraft = useCallback(
(key: string, scheduledIso?: string) => {
setDraftDayKey(key);
+ // Unscheduled "+ Add WO" starts with no schedule (SH-109); day groups prefill the day.
+ if (key === UNSCHEDULED_DAY_KEY) {
+ setDraft({ ...EMPTY_TABLE_DRAFT });
+ return;
+ }
const scheduledOn = resolveDraftScheduledOn(key, weekMonday, scheduledIso);
setDraft({
...EMPTY_TABLE_DRAFT,
scheduledOn,
- scheduleWeekOnly: key === UNSCHEDULED_DAY_KEY,
- targetWeek: key === UNSCHEDULED_DAY_KEY ? weekMonday : "",
+ status: scheduledOn ? "Scheduled" : "Incomplete",
});
},
[weekMonday],
diff --git a/src/app/(protected)/workorders/index.tsx b/src/app/(protected)/workorders/index.tsx
index 8e0173e6..9036de10 100644
--- a/src/app/(protected)/workorders/index.tsx
+++ b/src/app/(protected)/workorders/index.tsx
@@ -1,64 +1,23 @@
-import { useMemo, useState } from "react";
import { Box } from "@mui/material";
-import { useNavigate } from "react-router";
import { PageHeader } from "@/components/ui/page-header";
import { QueryErrorPanel } from "@/components/ui/query-error-panel";
+import { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog";
+import { WorkOrderSlideOver } from "@/app/(protected)/workorders/_components/detail/work-order-slide-over";
import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal";
import { WorkOrdersAdvancedSearchSheet } from "@/app/(protected)/workorders/_components/list/advanced-search/work-orders-advanced-search-sheet";
import { WorkOrdersAdvancedFiltersBanner } from "@/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner";
import { WorkOrdersFilterBar } from "@/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar";
import { WorkOrdersListHeader } from "@/app/(protected)/workorders/_components/list/work-orders-list-header";
+import { WorkOrderCancelDialog } from "@/app/(protected)/workorders/_components/list/work-order-cancel-dialog";
+import { WorkOrdersListWizard } from "@/app/(protected)/workorders/_components/list/work-orders-list-wizard";
import { WoTable } from "@/app/(protected)/workorders/_components/list/table/wo-table";
-import { NewWoWizard } from "@/app/(protected)/workorders/_components/wizard/new-wo-wizard";
-import { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters";
-import { useWorkOrdersTableData } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-data";
-import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
-import {
- useWorkOrderLocations,
- useWorkOrderVendors,
-} from "@/domain/work-orders/use-cases/use-work-order-detail";
-import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
-import { EMPTY_MEDIA, type MediaFile } from "@/domain/work-orders/types/work-order-media";
-import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
-import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
+import { useWorkOrdersListPage } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-page";
import { useAuthContext } from "@/providers/auth-context";
export default function WorkOrdersListPage() {
- const navigate = useNavigate();
const { user } = useAuthContext();
- const [wizardOpen, setWizardOpen] = useState(false);
- const [mediaRow, setMediaRow] = useState(null);
- const [mediaFiles, setMediaFiles] = useState>({});
- const filters = useWorkOrdersListFilters();
- const tableData = useWorkOrdersTableData(filters);
- const { patchField } = useWorkOrderTableMutations({
- onPatch: tableData.patchRow,
- clearPatch: tableData.clearRowPatch,
- getRow: (id) => tableData.rows.find((row) => String(row.id) === String(id)),
- });
-
- const { data: users = [], isLoading: usersLoading } = useWorkOrderUsers();
- const { data: locations = [] } = useWorkOrderLocations();
- const { data: vendors = [] } = useWorkOrderVendors();
- const { data: problemOptions = [] } = useDropdownOptionsByCategory("Problem");
-
- const pmTypes = useMemo(
- () => problemOptions.map((option) => option.value).filter(Boolean),
- [problemOptions],
- );
-
- const vendorTechs = useMemo(
- () => [...new Set(vendors.map((vendor) => vendor.companyName).filter(Boolean))],
- [vendors],
- );
-
- const totalCount = tableData.totalCount;
- const scopeTotalCount = tableData.scopeTotalCount;
-
- const activeMediaRow = useMemo(() => {
- if (!mediaRow) return null;
- return tableData.rows.find((row) => row.id === mediaRow.id) ?? mediaRow;
- }, [mediaRow, tableData.rows]);
+ const page = useWorkOrdersListPage();
+ const { filters, tableData, activeDocRow } = page;
return (
@@ -71,7 +30,7 @@ export default function WorkOrdersListPage() {
search={filters.search}
onSearchChange={filters.handleSearchChange}
onOpenAdvancedFilters={() => filters.setAdvOpen(true)}
- onNewWorkOrder={() => setWizardOpen(true)}
+ onNewWorkOrder={() => page.setWizardOpen(true)}
activeFilterCount={filters.activeFilterCount}
isFetching={tableData.isFetching && !tableData.isLoading}
/>
@@ -85,19 +44,19 @@ export default function WorkOrdersListPage() {
onDispatcherChange={filters.setDispatcherIds}
typeFilter={filters.typeFilter}
onTypeChange={filters.setTypeFilter}
- users={users}
- usersLoading={usersLoading}
+ users={page.users}
+ usersLoading={page.usersLoading}
currentUserId={user?.id}
- filteredCount={totalCount}
- scopeTotalCount={scopeTotalCount}
+ filteredCount={tableData.totalCount}
+ scopeTotalCount={tableData.scopeTotalCount}
/>
{filters.advApplied != null && (
@@ -118,20 +77,26 @@ export default function WorkOrdersListPage() {
{
- tableData.setSelectedId(row.id);
- navigate(edit ? `/workorders/edit/${row.id}` : `/workorders/${row.id}`);
- }}
- onOpenMedia={setMediaRow}
+ onOpenSlideOver={page.handleOpenSlideOver}
+ onOpenCompDoc={page.setDocRow}
+ onOpenMedia={page.setMediaRow}
onClearSearch={() => filters.handleSearchChange("")}
onOpenAdvancedFilters={() => filters.setAdvOpen(true)}
- onPatch={patchField}
+ onPatch={page.patchField}
+ />
+
+ page.handleOpenSlideOver(row, false)}
/>
-
+ page.patchField(id, patch, options)}
+ onOpenCompletionDoc={page.setDocRow}
+ onCancelWO={page.setConfirmCancel}
+ onDuplicateFound={(existing) => page.handleOpenSlideOver(existing, false)}
+ saving={page.isPatchPending}
+ onClose={page.handleCloseSlideOver}
+ />
- {activeMediaRow != null && (
+ page.setConfirmCancel(null)}
+ onConfirm={page.handleConfirmCancel}
+ />
+
+ {page.activeMediaRow != null && (
{
- if (!open) setMediaRow(null);
+ if (!open) page.setMediaRow(null);
}}
- uploads={mediaFiles[String(activeMediaRow.id)] ?? EMPTY_MEDIA}
- onChange={(files) => {
- setMediaFiles((prev) => ({ ...prev, [String(activeMediaRow.id)]: files }));
- patchField(activeMediaRow.id, { mediaCount: files.length });
+ />
+ )}
+
+ {activeDocRow != null && (
+ {
+ if (!open) page.setDocRow(null);
+ }}
+ data={page.completionDocs[String(activeDocRow.id)]}
+ onSave={(data, status) => page.saveCompletionDoc(activeDocRow, data, status)}
+ onDateCompletedChange={async (date) => {
+ await page.patchFieldAsync(activeDocRow.id, { completedDate: date });
}}
/>
)}
diff --git a/src/domain/work-orders/api/work-order-board-documents-api.ts b/src/domain/work-orders/api/work-order-board-documents-api.ts
index 0560785e..97ca0d2b 100644
--- a/src/domain/work-orders/api/work-order-board-documents-api.ts
+++ b/src/domain/work-orders/api/work-order-board-documents-api.ts
@@ -115,12 +115,46 @@ export const workOrderBoardDocumentsApi = {
uploadMedia: async (
workOrderId: string | number,
file: File,
- category: UploadableMediaCategory,
+ category?: UploadableMediaCategory,
+ options?: { signal?: AbortSignal; onProgress?: (percent: number) => void },
): Promise => {
const formData = buildMediaFormData(file, category);
const response = await api.post(API_PATHS.workOrder.media(workOrderId), {
body: formData,
throwHttpErrors: false,
+ signal: options?.signal,
+ onUploadProgress: options?.onProgress
+ ? (progress) => {
+ options.onProgress?.(Math.round(progress.percent * 100));
+ }
+ : undefined,
+ });
+ const data = await readResponseJson(response);
+ if (response.ok) {
+ return mapBoardDetailMediaItem(handleApiResponse(data) ?? data);
+ }
+ throwDocumentHttpError(response, data);
+ },
+
+ updateMediaCategory: async (
+ workOrderId: string | number,
+ mediaId: string | number,
+ category: UploadableMediaCategory,
+ workOrderVersion: string,
+ ): Promise => {
+ if (!workOrderVersion) {
+ throw new BoardPatchValidationError(
+ "WorkOrderVersionRequired",
+ "workOrderVersion is required to update media category.",
+ );
+ }
+
+ const formData = new FormData();
+ formData.append("category", category);
+ formData.append("workOrderVersion", workOrderVersion);
+ const response = await api.patch(API_PATHS.workOrder.mediaItem(workOrderId, mediaId), {
+ body: formData,
+ throwHttpErrors: false,
});
const data = await readResponseJson(response);
if (response.ok) {
@@ -129,7 +163,18 @@ export const workOrderBoardDocumentsApi = {
throwDocumentHttpError(response, data);
},
- deleteMedia: async (workOrderId: string | number, mediaId: string | number): Promise => {
+ deleteMedia: async (
+ workOrderId: string | number,
+ mediaId: string | number,
+ workOrderVersion: string,
+ ): Promise => {
+ if (!workOrderVersion) {
+ throw new BoardPatchValidationError(
+ "WorkOrderVersionRequired",
+ "workOrderVersion is required to delete media.",
+ );
+ }
+
const numericId = typeof mediaId === "number" ? mediaId : Number(mediaId);
if (!Number.isFinite(numericId) || numericId <= 0) {
throw new BoardPatchValidationError(
@@ -139,6 +184,7 @@ export const workOrderBoardDocumentsApi = {
}
const response = await api.delete(API_PATHS.workOrder.mediaItem(workOrderId, numericId), {
+ searchParams: { workOrderVersion },
throwHttpErrors: false,
});
diff --git a/src/domain/work-orders/api/work-orders-api.ts b/src/domain/work-orders/api/work-orders-api.ts
index 7b0da992..ad9a93d1 100644
--- a/src/domain/work-orders/api/work-orders-api.ts
+++ b/src/domain/work-orders/api/work-orders-api.ts
@@ -191,6 +191,48 @@ export const workOrdersApi = {
};
},
+ findByWoNumber: async (woNumber: string): Promise => {
+ const normalized = woNumber.trim();
+ // Search every non-empty WO# — backend advanced search accepts 1-character queries, and
+ // wizard/create allow single-digit numbers (SH-189 must not bypass remote uniqueness).
+ if (!normalized) {
+ return undefined;
+ }
+ // Safety cap — if hit before exhausting results, fail closed (do not treat as unique).
+ const safetyMaxPages = 50;
+
+ // `board/search` is fuzzy and paginated (0-based); exhaust pages for an exact WO#.
+ const pageSize = 100;
+ let page = 0;
+ let scanned = 0;
+ let totalCount = Number.POSITIVE_INFINITY;
+
+ while (scanned < totalCount) {
+ if (page >= safetyMaxPages) {
+ throw new Error(
+ "Unable to verify the work order number: search result set exceeded the safety page limit.",
+ );
+ }
+ const result = await workOrdersApi.searchBoard({
+ search: normalized,
+ page,
+ pageSize,
+ });
+ const exact = result.items.find((row) => row.woNumber === normalized);
+ if (exact) {
+ return exact;
+ }
+ scanned += result.items.length;
+ totalCount = result.totalCount;
+ if (result.items.length === 0) {
+ break;
+ }
+ page += 1;
+ }
+
+ return undefined;
+ },
+
getListByLocation: async (
locationId: string | number,
search = "",
diff --git a/src/domain/work-orders/mappers/assign-wizard-schedule-fields.ts b/src/domain/work-orders/mappers/assign-wizard-schedule-fields.ts
index 3a6c656d..c47021a3 100644
--- a/src/domain/work-orders/mappers/assign-wizard-schedule-fields.ts
+++ b/src/domain/work-orders/mappers/assign-wizard-schedule-fields.ts
@@ -14,11 +14,11 @@ export function assignWizardScheduleFields(
const isRange = draft.schedMode === "range";
if (!isRange && draft.scheduleWeekOnly) {
- payload.scheduleWeekOnly = true;
-
const week = draft.targetWeek?.trim() || (draft.scheduledOn ? mondayOf(draft.scheduledOn) : "");
+ // Only emit week-mode flags when a concrete week exists (SH-109: undated create).
if (week) {
+ payload.scheduleWeekOnly = true;
payload.targetWeek = week;
}
} else if (!isRange && draft.scheduledOn) {
diff --git a/src/domain/work-orders/mappers/doc-status-mapper.ts b/src/domain/work-orders/mappers/doc-status-mapper.ts
new file mode 100644
index 00000000..90c9c016
--- /dev/null
+++ b/src/domain/work-orders/mappers/doc-status-mapper.ts
@@ -0,0 +1,28 @@
+import type { CompletionDocStatus } from "@/domain/work-orders/types/work-order-table-row";
+
+/** UI uses `NN`; board/API enum uses `NotNeeded` (also numeric 3). */
+export function mapDocStatusToApi(value: string): string {
+ if (value === "NN") {
+ return "NotNeeded";
+ }
+ return value;
+}
+
+export function readDocStatusFromRecord(record: Record): CompletionDocStatus {
+ const rawValue =
+ record.docStatus ?? record.DocStatus ?? record.completionDoc ?? record.CompletionDoc;
+ if (typeof rawValue === "number") {
+ if (rawValue === 1) return "Yes";
+ if (rawValue === 2) return "No";
+ if (rawValue === 3) return "NN";
+ }
+ if (typeof rawValue === "string") {
+ if (rawValue === "Yes" || rawValue === "No" || rawValue === "NN") {
+ return rawValue;
+ }
+ if (rawValue === "NotNeeded") {
+ return "NN";
+ }
+ }
+ return "";
+}
diff --git a/src/domain/work-orders/mappers/expand-scalar-board-ops.ts b/src/domain/work-orders/mappers/expand-scalar-board-ops.ts
index 332c677d..6a67dd5d 100644
--- a/src/domain/work-orders/mappers/expand-scalar-board-ops.ts
+++ b/src/domain/work-orders/mappers/expand-scalar-board-ops.ts
@@ -1,17 +1,13 @@
import type { WorkOrderBoardEditableField } from "@/domain/work-orders/types/work-order-board";
import type { WorkOrderTablePatch } from "@/domain/work-orders/types/work-order-table-row";
+import { mapDocStatusToApi } from "@/domain/work-orders/mappers/doc-status-mapper";
export type BoardFieldOp = {
field: WorkOrderBoardEditableField;
value: string;
};
-export function mapDocStatusToApi(value: string): string {
- if (value === "NN") {
- return "NotNeeded";
- }
- return value;
-}
+export { mapDocStatusToApi };
type ScalarBoardOpRule = {
key: keyof WorkOrderTablePatch;
@@ -30,6 +26,7 @@ const SCALAR_BOARD_OP_RULES: ScalarBoardOpRule[] = [
{ key: "vendorId", field: "vendorId", toValue: (value) => String(value) },
{ key: "apptTime", field: "apptTime", toValue: (value) => value as string },
{ key: "docStatus", field: "docStatus", toValue: (value) => mapDocStatusToApi(value as string) },
+ { key: "completedDate", field: "completedDate", toValue: (value) => value as string },
{ key: "pm", field: "pm", toValue: (value) => value as string },
{ key: "extraServices", field: "extraServices", toValue: (value) => JSON.stringify(value) },
{ key: "serviceNotes", field: "serviceNotes", toValue: (value) => value as string },
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-board-detail-mapper.ts b/src/domain/work-orders/mappers/work-order-board-detail-mapper.ts
index 3b513279..4612756d 100644
--- a/src/domain/work-orders/mappers/work-order-board-detail-mapper.ts
+++ b/src/domain/work-orders/mappers/work-order-board-detail-mapper.ts
@@ -147,9 +147,11 @@ export function mapBoardDetailAuditEntry(raw: unknown, index: number): WorkOrder
export type UploadableMediaCategory = "Before" | "After" | "Extra";
-export function buildMediaFormData(file: File, category: UploadableMediaCategory): FormData {
+export function buildMediaFormData(file: File, category?: UploadableMediaCategory): FormData {
const form = new FormData();
- form.append("category", category);
+ if (category) {
+ form.append("category", category);
+ }
form.append("file", file);
return form;
}
@@ -257,7 +259,9 @@ export function boardMediaItemsToMediaFiles(items: WorkOrderBoardMediaItem[]): M
fileType: classifyFileName(item.fileName, item.url),
url: item.url,
category: boardCategoryToMediaCategory(item.category),
+ purpose: item.category,
isLegacy: item.isLegacy || Number(item.id) <= 0,
+ uploadStatus: "done" as const,
}));
}
diff --git a/src/domain/work-orders/mappers/work-order-mapper.ts b/src/domain/work-orders/mappers/work-order-mapper.ts
index f0091b55..259c5344 100644
--- a/src/domain/work-orders/mappers/work-order-mapper.ts
+++ b/src/domain/work-orders/mappers/work-order-mapper.ts
@@ -1,3 +1,4 @@
+import { readDocStatusFromRecord } from "@/domain/work-orders/mappers/doc-status-mapper";
import { parseSiteCodeFromLocationName } from "@/domain/work-orders/mappers/work-order-board-mapper";
import type {
EmployeeOption,
@@ -14,6 +15,8 @@ import type { WorkOrderStatus } from "@/domain/work-orders/types/work-order-comm
import { WORK_ORDER_STATUS_OPTIONS } from "@/domain/work-orders/types/work-order-common";
import { toDateInputValue } from "@/lib/time-utils";
+export { mapDocStatusToApi } from "@/domain/work-orders/mappers/doc-status-mapper";
+
function asRecord(raw: unknown): Record {
return typeof raw === "object" && raw !== null ? (raw as Record) : {};
}
@@ -214,6 +217,7 @@ export { mapWorkOrderTableRow, mapWorkOrderTableRows } from "./work-order-table-
export function mapWorkOrderComment(raw: unknown): WorkOrderComment {
const item = asRecord(raw);
+ const authorId = readString(item, "authorId", "AuthorId") || null;
const mentionsRaw = item.mentions ?? item.Mentions;
const mentions = Array.isArray(mentionsRaw)
? mentionsRaw
@@ -226,7 +230,8 @@ export function mapWorkOrderComment(raw: unknown): WorkOrderComment {
createdDate: readString(item, "createdDate", "CreatedDate"),
commenttext: readString(item, "commenttext", "Commenttext", "text", "Text"),
commentType: readString(item, "commentType", "CommentType"),
- userName: readString(item, "userName", "UserName"),
+ userName: readString(item, "userName", "UserName", "authorName", "AuthorName"),
+ authorId,
commenter: readString(item, "commenter", "Commenter") || null,
recordType: readString(item, "recordType", "RecordType") || null,
mentions,
@@ -309,6 +314,8 @@ 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 is a board field — persist via PATCH …/board, not EditWorkorder.
+ docStatus: readDocStatusFromRecord(wo),
source: readString(wo, "source", "Source"),
...(contactIds !== undefined ? { contactIds } : {}),
...(categoryIds !== undefined ? { categoryIds } : {}),
@@ -342,6 +349,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,
diff --git a/src/domain/work-orders/mappers/work-order-table-row-mapper.ts b/src/domain/work-orders/mappers/work-order-table-row-mapper.ts
index a12162c2..af886e9b 100644
--- a/src/domain/work-orders/mappers/work-order-table-row-mapper.ts
+++ b/src/domain/work-orders/mappers/work-order-table-row-mapper.ts
@@ -237,6 +237,7 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
dispatcherId,
dispatcherName,
scheduledOn,
+ completedDate: toDateInputValue(readString(item, "completedDate", "CompletedDate")),
targetWeek: toDateInputValue(readString(item, "targetWeek", "TargetWeek")),
dayGroup,
dayLabel,
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 e4c2399c..8504fa09 100644
--- a/src/domain/work-orders/mocks/wo-wizard-mock-data.ts
+++ b/src/domain/work-orders/mocks/wo-wizard-mock-data.ts
@@ -153,6 +153,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-board-patch.ts b/src/domain/work-orders/types/work-order-board-patch.ts
new file mode 100644
index 00000000..29f0edd2
--- /dev/null
+++ b/src/domain/work-orders/types/work-order-board-patch.ts
@@ -0,0 +1,8 @@
+/** Payload for PATCH /workorders/{id}/board (one field per request). */
+export interface WorkOrderBoardPatchPayload {
+ field: string;
+ value?: string | null;
+ workOrderVersion: string;
+ dispatchVersion?: string | null;
+ primaryDispatchId?: number | null;
+}
diff --git a/src/domain/work-orders/types/work-order-board.ts b/src/domain/work-orders/types/work-order-board.ts
index bce4cac1..2bdadc4a 100644
--- a/src/domain/work-orders/types/work-order-board.ts
+++ b/src/domain/work-orders/types/work-order-board.ts
@@ -116,6 +116,7 @@ export type WorkOrderBoardEditableField =
| "apptDate"
| "apptTime"
| "docStatus"
+ | "completedDate"
| "pm"
| "serviceNotes"
| "extraServices"
@@ -146,6 +147,8 @@ export interface WorkOrderBoardRowApi {
flagColor?: string | null;
dueDate?: string | null;
scheduledDate?: string | null;
+ /** ISO date when work was completed; optional on list rows until BE confirms. */
+ completedDate?: string | null;
targetWeek?: string | null;
scheduleWeekOnly?: boolean | null;
dayGroup?: string | null;
diff --git a/src/domain/work-orders/types/work-order-detail.ts b/src/domain/work-orders/types/work-order-detail.ts
index d3d2fa87..0eb2b780 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;
@@ -51,6 +52,8 @@ export interface WorkOrderDetail {
vendorNTE: string;
scheduledDate: string;
completedDate: string;
+ /** Completion-document status (API DocStatus); persisted via board PATCH. */
+ docStatus: CompletionDocStatus;
source: string;
/** Present when GetById returns contacts; used to re-send on edit so backend does not wipe. */
contactIds?: number[];
@@ -72,6 +75,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;
@@ -99,6 +104,7 @@ export function createDefaultWorkOrderFormValues(): WorkOrderFormValues {
dueDate: "",
scheduledDate: "",
completedDate: "",
+ docStatus: "",
assignedTo: "",
description: "",
po: "",
diff --git a/src/domain/work-orders/types/work-order-media.ts b/src/domain/work-orders/types/work-order-media.ts
index 68fd8faf..a5909850 100644
--- a/src/domain/work-orders/types/work-order-media.ts
+++ b/src/domain/work-orders/types/work-order-media.ts
@@ -9,8 +9,14 @@ export interface MediaFile {
fileType: FileCategory;
url: string;
category: MediaCategory;
+ /** Board purpose label (Before/After/Extra/Completion). Null/undefined = needs categorization. */
+ purpose?: "Before" | "After" | "Extra" | "Completion" | null;
/** True when media is a legacy Before/After/Completion column (not deletable). */
isLegacy?: boolean;
+ /** 0–100 while uploading; undefined when idle/complete. */
+ progress?: number;
+ uploadStatus?: "uploading" | "failed" | "done";
+ errorMessage?: string;
}
export const MEDIA_CATEGORIES: MediaCategory[] = [
diff --git a/src/domain/work-orders/types/work-order-table-row.ts b/src/domain/work-orders/types/work-order-table-row.ts
index 4bcb4b60..ca435096 100644
--- a/src/domain/work-orders/types/work-order-table-row.ts
+++ b/src/domain/work-orders/types/work-order-table-row.ts
@@ -12,6 +12,8 @@ export interface WorkOrderTableRow {
dispatcherId: string;
dispatcherName: string;
scheduledOn: string;
+ /** Work completed date (API CompletedDate); distinct from scheduledOn. */
+ completedDate: string;
targetWeek: string;
dayGroup: string;
dayLabel: string;
diff --git a/src/domain/work-orders/use-cases/use-work-order-mutations.ts b/src/domain/work-orders/use-cases/use-work-order-mutations.ts
index e795c290..156cdbde 100644
--- a/src/domain/work-orders/use-cases/use-work-order-mutations.ts
+++ b/src/domain/work-orders/use-cases/use-work-order-mutations.ts
@@ -51,7 +51,20 @@ export interface UpdateWorkOrderCommentInput {
export interface UploadWorkOrderMediaInput {
file: File;
+ category?: UploadableMediaCategory;
+ signal?: AbortSignal;
+ onProgress?: (percent: number) => void;
+}
+
+export interface UpdateWorkOrderMediaCategoryInput {
+ mediaId: string | number;
category: UploadableMediaCategory;
+ workOrderVersion: string;
+}
+
+export interface DeleteWorkOrderMediaInput {
+ mediaId: string | number;
+ workOrderVersion: string;
}
export interface UploadCompletionDocInput {
@@ -192,12 +205,12 @@ export function useUploadWorkOrderMedia(
const queryClient = useQueryClient();
return useMutation({
- mutationFn: ({ file, category }: UploadWorkOrderMediaInput) =>
- workOrdersApi.uploadMedia(workOrderId, file, category),
+ mutationFn: ({ file, category, signal, onProgress }: UploadWorkOrderMediaInput) =>
+ workOrdersApi.uploadMedia(workOrderId, file, category, { signal, onProgress }),
onSuccess: () => {
invalidateBoardDetail(queryClient, workOrderId);
invalidateWorkOrders(queryClient);
- toast.success("File uploaded");
+ toast.success("File uploaded — assign a category");
},
onError: (error: Error) => {
toast.error(error.message || "Failed to upload file");
@@ -205,13 +218,33 @@ export function useUploadWorkOrderMedia(
});
}
-export function useDeleteWorkOrderMedia(
+export function useUpdateWorkOrderMediaCategory(
workOrderId: string | number,
-): UseMutationResult {
+): UseMutationResult {
const queryClient = useQueryClient();
return useMutation({
- mutationFn: (mediaId: string | number) => workOrdersApi.deleteMedia(workOrderId, mediaId),
+ mutationFn: ({ mediaId, category, workOrderVersion }: UpdateWorkOrderMediaCategoryInput) =>
+ workOrdersApi.updateMediaCategory(workOrderId, mediaId, category, workOrderVersion),
+ onSuccess: () => {
+ invalidateBoardDetail(queryClient, workOrderId);
+ invalidateWorkOrders(queryClient);
+ toast.success("Category saved");
+ },
+ onError: (error: Error) => {
+ toast.error(error.message || "Failed to update category");
+ },
+ });
+}
+
+export function useDeleteWorkOrderMedia(
+ workOrderId: string | number,
+): UseMutationResult {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: ({ mediaId, workOrderVersion }: DeleteWorkOrderMediaInput) =>
+ workOrdersApi.deleteMedia(workOrderId, mediaId, workOrderVersion),
onSuccess: () => {
invalidateBoardDetail(queryClient, workOrderId);
invalidateWorkOrders(queryClient);
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 e0026c86..c915a569 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";
@@ -18,6 +19,7 @@ import type {
} from "@/domain/work-orders/types/work-order-table-row";
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 };
@@ -28,6 +30,65 @@ export type WorkOrderTablePatchHandlers = {
getRow?: (id: string | number) => WorkOrderTableRow | undefined;
};
+type PatchResult =
+ { kind: "local"; patch: WorkOrderTablePatch } | { kind: "board"; row: WorkOrderTableRow };
+
+async function applyBoardFieldOps(
+ id: string | number,
+ patch: WorkOrderTablePatch,
+ getRow: ((id: string | number) => WorkOrderTableRow | undefined) | undefined,
+): Promise {
+ const ops = expandTablePatchToBoardFieldOps(patch);
+ if (ops.length === 0) {
+ throw new BoardPatchValidationError(
+ "NoPersistableFields",
+ "These changes cannot be saved on the board. Refresh and edit a supported field.",
+ );
+ }
+
+ const current = getRow?.(id);
+ if (!current?.rowVersion) {
+ throw new Error("Missing work order version for board patch. Refresh and retry.");
+ }
+
+ let latest = current;
+ let completedOps = 0;
+ for (const op of ops) {
+ try {
+ latest = await workOrdersApi.patchBoardField(id, buildBoardPatchPayload(op, latest));
+ completedOps += 1;
+ } catch (error) {
+ if (completedOps > 0) {
+ const field = op.field;
+ const partial = new Error(
+ `Schedule update partially saved (${completedOps}/${ops.length} fields). Failed on "${field}": ${
+ error instanceof Error ? error.message : "Unknown error"
+ }`,
+ );
+ (partial as Error & { partialRow?: WorkOrderTableRow }).partialRow = latest;
+ throw partial;
+ }
+ throw error;
+ }
+ }
+
+ return latest;
+}
+
+/** Board fields (including completedDate) use PATCH …/board — never legacy EditWorkorder. */
+export async function runWorkOrderTablePatch(
+ id: string | number,
+ patch: WorkOrderTablePatch,
+ getRow: ((id: string | number) => WorkOrderTableRow | undefined) | undefined,
+): Promise {
+ if (isLocalOnlyTablePatch(patch)) {
+ return { kind: "local", patch };
+ }
+
+ const boardRow = await applyBoardFieldOps(id, patch, getRow);
+ return { kind: "board", row: boardRow };
+}
+
export function useWorkOrderTableMutations(
onPatchOrHandlers:
((id: string | number, patch: WorkOrderTablePatch) => void) | WorkOrderTablePatchHandlers,
@@ -44,53 +105,15 @@ export function useWorkOrderTableMutations(
const { onPatch, clearPatch, getRow } = handlers;
const queryClient = useQueryClient();
+ const enqueuePatch = useMemo(() => createWorkOrderPatchQueue(), []);
const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
};
const patchMutation = useMutation({
- mutationFn: async ({ id, patch }: { id: string | number; patch: WorkOrderTablePatch }) => {
- if (isLocalOnlyTablePatch(patch)) {
- return { kind: "local" as const, patch };
- }
-
- const ops = expandTablePatchToBoardFieldOps(patch);
- if (ops.length === 0) {
- throw new BoardPatchValidationError(
- "NoPersistableFields",
- "These changes cannot be saved on the board. Refresh and edit a supported field.",
- );
- }
-
- const current = getRow?.(id);
- if (!current?.rowVersion) {
- throw new Error("Missing work order version for board patch. Refresh and retry.");
- }
-
- let latest = current;
- let completedOps = 0;
- for (const op of ops) {
- try {
- latest = await workOrdersApi.patchBoardField(id, buildBoardPatchPayload(op, latest));
- completedOps += 1;
- } catch (error) {
- if (completedOps > 0) {
- const field = op.field;
- const partial = new Error(
- `Schedule update partially saved (${completedOps}/${ops.length} fields). Failed on "${field}": ${
- error instanceof Error ? error.message : "Unknown error"
- }`,
- );
- (partial as Error & { partialRow?: WorkOrderTableRow }).partialRow = latest;
- throw partial;
- }
- throw error;
- }
- }
-
- return { kind: "board" as const, row: latest };
- },
+ mutationFn: ({ id, patch }: { id: string | number; patch: WorkOrderTablePatch }) =>
+ enqueuePatch(id, () => runWorkOrderTablePatch(id, patch, getRow)),
onMutate: async ({ id, patch }) => {
onPatch(id, patch);
},
@@ -123,12 +146,29 @@ export function useWorkOrderTableMutations(
},
});
- const patchField = (id: string | number, patch: WorkOrderTablePatch) => {
- patchMutation.mutate({ id, patch });
+ const patchField = (
+ id: string | number,
+ patch: WorkOrderTablePatch,
+ options?: {
+ onSuccess?: () => void;
+ onError?: (error: Error) => void;
+ },
+ ) => {
+ patchMutation.mutate(
+ { id, patch },
+ {
+ onSuccess: () => options?.onSuccess?.(),
+ onError: (error) => options?.onError?.(error),
+ },
+ );
};
+ const patchFieldAsync = (id: string | number, patch: WorkOrderTablePatch) =>
+ patchMutation.mutateAsync({ id, patch });
+
return {
patchField,
+ patchFieldAsync,
isPending: patchMutation.isPending,
};
}
diff --git a/src/domain/work-orders/utils/completion-doc-service-gate.ts b/src/domain/work-orders/utils/completion-doc-service-gate.ts
new file mode 100644
index 00000000..a92c0baf
--- /dev/null
+++ b/src/domain/work-orders/utils/completion-doc-service-gate.ts
@@ -0,0 +1,4 @@
+/** CompDoc is gated on a selected primary service (`pm`). */
+export function hasCompletionDocService(pm: string | null | undefined): boolean {
+ return Boolean(pm?.trim());
+}
diff --git a/src/domain/work-orders/utils/completion-doc-service-title.ts b/src/domain/work-orders/utils/completion-doc-service-title.ts
index 6df69e70..decf73f9 100644
--- a/src/domain/work-orders/utils/completion-doc-service-title.ts
+++ b/src/domain/work-orders/utils/completion-doc-service-title.ts
@@ -17,15 +17,12 @@ const SERVICE_PHRASE: Record = {
"Assess Window Leaks": "Window Leak Assessment",
};
-const FREQ_CYCLE = ["13 Week", "26 Week", "52 Week"];
-
-function hashNum(s: string, mod: number): number {
- let h = 0;
- for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
- return h % mod;
-}
-
-export function serviceTitle(row: Pick): string {
- const phrase = SERVICE_PHRASE[row.pm] ?? row.pm;
- return `Amer-Building ${phrase} — ${FREQ_CYCLE[hashNum(row.woNumber, FREQ_CYCLE.length)]}`;
+/**
+ * Title derived only from the selected service (`pm`).
+ * Never fabricates maintenance-cycle metadata from the work-order number.
+ */
+export function serviceTitle(row: Pick): string {
+ const service = row.pm.trim();
+ const phrase = SERVICE_PHRASE[service] ?? service;
+ return `Amer-Building ${phrase}`;
}
diff --git a/src/domain/work-orders/utils/find-duplicate-wo-number-row.ts b/src/domain/work-orders/utils/find-duplicate-wo-number-row.ts
new file mode 100644
index 00000000..0b4a0d52
--- /dev/null
+++ b/src/domain/work-orders/utils/find-duplicate-wo-number-row.ts
@@ -0,0 +1,16 @@
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+
+export function findDuplicateWoNumberRow(
+ woNumber: string,
+ rows: WorkOrderTableRow[],
+ excludeId?: string | number,
+): WorkOrderTableRow | undefined {
+ const normalized = woNumber.trim();
+ if (normalized === "") {
+ return undefined;
+ }
+
+ return rows.find(
+ (row) => row.woNumber === normalized && String(row.id) !== String(excludeId ?? ""),
+ );
+}
diff --git a/src/domain/work-orders/utils/get-inline-create-missing.ts b/src/domain/work-orders/utils/get-inline-create-missing.ts
new file mode 100644
index 00000000..a732e656
--- /dev/null
+++ b/src/domain/work-orders/utils/get-inline-create-missing.ts
@@ -0,0 +1,62 @@
+/** Required fields for board inline create (Schedule On is optional — SH-109). */
+export type InlineCreateReqField = "site" | "woNumber" | "pocName" | "pocPhone";
+
+export const INLINE_CREATE_REQ_LABELS: Record = {
+ site: "Site",
+ woNumber: "WO #",
+ pocName: "POC name",
+ pocPhone: "POC phone",
+};
+
+export type InlineCreateDraftFields = {
+ site?: string;
+ locationId?: string | number;
+ woNumber?: string;
+ pocName?: string;
+ pocPhone?: string;
+};
+
+export function getInlineCreateMissing(d: InlineCreateDraftFields): InlineCreateReqField[] {
+ const miss: InlineCreateReqField[] = [];
+ const hasSite = Boolean(d.site?.trim()) || Boolean(String(d.locationId ?? "").trim());
+ if (!hasSite) {
+ miss.push("site");
+ }
+ if (!d.woNumber?.trim()) {
+ miss.push("woNumber");
+ }
+ if (!d.pocName?.trim()) {
+ miss.push("pocName");
+ }
+ if (!d.pocPhone?.trim()) {
+ miss.push("pocPhone");
+ }
+ return miss;
+}
+
+/** Tooltip for Save: "Missing: Site, WO #" or "Save" when ready. */
+export function inlineCreateSaveHint(miss: InlineCreateReqField[]): string {
+ if (miss.length === 0) {
+ return "Save";
+ }
+ return `Missing: ${miss.map((m) => INLINE_CREATE_REQ_LABELS[m]).join(", ")}`;
+}
+
+export function inlineCreateSiteError(miss: InlineCreateReqField[]): string {
+ if (miss.includes("site")) {
+ return "Site is required";
+ }
+ return "Contact name and phone are required";
+}
+
+/** Concrete calendar day/range — week-only does not count (design hasScheduleDate). */
+export function draftHasScheduleDate(d: {
+ scheduledOn?: string;
+ scheduleWeekOnly?: boolean;
+ schedMode?: string;
+}): boolean {
+ if (d.scheduleWeekOnly || d.schedMode === "week") {
+ return false;
+ }
+ return Boolean(d.scheduledOn?.trim());
+}
diff --git a/src/domain/work-orders/utils/run-sequential-uploads.ts b/src/domain/work-orders/utils/run-sequential-uploads.ts
new file mode 100644
index 00000000..e641a944
--- /dev/null
+++ b/src/domain/work-orders/utils/run-sequential-uploads.ts
@@ -0,0 +1,24 @@
+/**
+ * Runs file uploads one-by-one while holding an aggregate pending flag true
+ * until every file has settled (success or failure). Avoids relying on a single
+ * TanStack Mutation `isPending`, which can flip false when a later small file
+ * finishes before an earlier large one.
+ */
+export async function runSequentialUploads(
+ files: readonly TFile[],
+ upload: (file: TFile) => Promise,
+ setPending: (pending: boolean) => void,
+): Promise {
+ if (files.length === 0) {
+ return;
+ }
+
+ setPending(true);
+ try {
+ for (const file of files) {
+ await upload(file);
+ }
+ } finally {
+ setPending(false);
+ }
+}
diff --git a/src/domain/work-orders/utils/slide-over-draft.ts b/src/domain/work-orders/utils/slide-over-draft.ts
index 9c8d897d..2b4a3226 100644
--- a/src/domain/work-orders/utils/slide-over-draft.ts
+++ b/src/domain/work-orders/utils/slide-over-draft.ts
@@ -18,13 +18,13 @@ export const SLIDE_OVER_EDIT_KEYS = [
"targetWeek",
"scheduleWeekOnly",
"dueDate",
+ "completedDate",
"apptTime",
"pm",
"serviceNotes",
"extraServices",
"vendorId",
- "company",
- "tech",
+ // company/tech are display companions; board persists vendor via vendorId only
"status",
] as const satisfies ReadonlyArray;
diff --git a/src/domain/work-orders/utils/work-order-media-limits.ts b/src/domain/work-orders/utils/work-order-media-limits.ts
index 4c513763..d79f45be 100644
--- a/src/domain/work-orders/utils/work-order-media-limits.ts
+++ b/src/domain/work-orders/utils/work-order-media-limits.ts
@@ -1,6 +1,10 @@
/** Backend RequestSizeLimit for media and completion-doc uploads. */
export const MAX_WORK_ORDER_MEDIA_BYTES = 30 * 1024 * 1024;
+const ALLOWED_MIME_TYPES = new Set(["image/jpeg", "image/png", "video/mp4", "video/quicktime"]);
+
+const ALLOWED_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".mp4", ".mov"]);
+
export function isWorkOrderFileTooLarge(
file: File,
maxBytes = MAX_WORK_ORDER_MEDIA_BYTES,
@@ -8,6 +12,19 @@ export function isWorkOrderFileTooLarge(
return file.size > maxBytes;
}
+export function isWorkOrderMediaTypeAllowed(file: File): boolean {
+ const mimeOk = Boolean(file.type) && ALLOWED_MIME_TYPES.has(file.type.toLowerCase());
+ const name = file.name.toLowerCase();
+ const dot = name.lastIndexOf(".");
+ const ext = dot >= 0 ? name.slice(dot) : "";
+ const extOk = ALLOWED_EXTENSIONS.has(ext);
+ return mimeOk || extOk;
+}
+
+export function workOrderMediaAcceptAttribute(): string {
+ return "image/jpeg,image/png,video/mp4,video/quicktime,.jpg,.jpeg,.png,.mp4,.mov";
+}
+
export function formatMediaSizeLimit(maxBytes = MAX_WORK_ORDER_MEDIA_BYTES): string {
return `${Math.round(maxBytes / (1024 * 1024))} MB`;
}
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/app/(protected)/workorders/build-inline-draft-create-input.test.ts b/src/test/app/(protected)/workorders/build-inline-draft-create-input.test.ts
new file mode 100644
index 00000000..ccccd7d3
--- /dev/null
+++ b/src/test/app/(protected)/workorders/build-inline-draft-create-input.test.ts
@@ -0,0 +1,55 @@
+import { describe, expect, it } from "vitest";
+import { buildInlineDraftCreateInput } from "@/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input";
+import { mapWizardDraftToBoardCreatePayload } from "@/domain/work-orders/mappers/work-order-board-mapper";
+import { EMPTY_TABLE_DRAFT } from "@/domain/work-orders/types/work-order-table-row";
+
+describe("buildInlineDraftCreateInput", () => {
+ it("defaults type to PM and omits schedule flags when undated", () => {
+ const draft = {
+ ...EMPTY_TABLE_DRAFT,
+ site: "BK5",
+ locationId: "9",
+ woNumber: "25001",
+ pocName: "Jane",
+ pocPhone: "555-0100",
+ scheduleWeekOnly: true,
+ targetWeek: "",
+ scheduledOn: "",
+ };
+
+ const input = buildInlineDraftCreateInput(draft, [], [{ id: 9, name: "BK5 · Dallas, TX" }]);
+
+ expect(input.type).toBe("PM");
+ expect(input.scheduleWeekOnly).toBe(false);
+ expect(input.scheduledOn).toBe("");
+ expect(input.targetWeek).toBe("");
+
+ const payload = mapWizardDraftToBoardCreatePayload(input);
+ expect(payload.scheduledDate).toBeUndefined();
+ expect(payload.targetWeek).toBeUndefined();
+ expect(payload.scheduleWeekOnly).toBeUndefined();
+ expect(payload.woNumber).toBe("25001");
+ expect(payload.pocName).toBe("Jane");
+ });
+
+ it("keeps week-only schedule when targetWeek is set", () => {
+ const draft = {
+ ...EMPTY_TABLE_DRAFT,
+ site: "BK5",
+ locationId: "9",
+ woNumber: "25002",
+ type: "Reactive" as const,
+ pocName: "Jane",
+ pocPhone: "555-0100",
+ scheduleWeekOnly: true,
+ targetWeek: "2026-06-22",
+ };
+
+ const input = buildInlineDraftCreateInput(draft, [], [{ id: 9, name: "BK5 · Dallas, TX" }]);
+ const payload = mapWizardDraftToBoardCreatePayload(input);
+
+ expect(payload.scheduleWeekOnly).toBe(true);
+ expect(payload.targetWeek).toBe("2026-06-22");
+ expect(payload.scheduledDate).toBeUndefined();
+ });
+});
diff --git a/src/test/app/(protected)/workorders/comp-doc-cell-service-gate.test.tsx b/src/test/app/(protected)/workorders/comp-doc-cell-service-gate.test.tsx
new file mode 100644
index 00000000..f5bf9847
--- /dev/null
+++ b/src/test/app/(protected)/workorders/comp-doc-cell-service-gate.test.tsx
@@ -0,0 +1,29 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import { CompDocCell } from "@/app/(protected)/workorders/_components/list/table/cells/comp-doc-cell";
+
+describe("CompDocCell service gate", () => {
+ it("shows a disabled placeholder when no service is selected", () => {
+ const onOpen = vi.fn();
+ render( );
+
+ expect(screen.getByText("Select service")).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: /draft|view doc|n\/n/i })).not.toBeInTheDocument();
+ });
+
+ it("opens CompDoc when a service is selected", () => {
+ const onOpen = vi.fn();
+ render(
+ ,
+ );
+
+ screen.getByRole("button", { name: /draft/i }).click();
+ expect(onOpen).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/test/app/(protected)/workorders/comp-doc-dialog-completion-contract.test.tsx b/src/test/app/(protected)/workorders/comp-doc-dialog-completion-contract.test.tsx
new file mode 100644
index 00000000..ff9d6ef2
--- /dev/null
+++ b/src/test/app/(protected)/workorders/comp-doc-dialog-completion-contract.test.tsx
@@ -0,0 +1,229 @@
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+
+vi.mock("@/domain/locations/use-cases/use-location-detail", () => ({
+ useLocationDetail: () => ({ data: undefined }),
+}));
+
+vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
+ useWorkOrderBoardDetail: () => ({ data: undefined, isLoading: false }),
+}));
+
+vi.mock("@/domain/work-orders/use-cases/use-work-order-mutations", () => ({
+ useUploadCompletionDoc: () => ({ mutate: vi.fn(), isPending: false }),
+}));
+
+vi.mock("react-toastify", () => ({
+ toast: { error: vi.fn(), success: vi.fn() },
+}));
+
+vi.mock("@/app/(protected)/workorders/_components/comp-doc/comp-doc-draft-toast", () => ({
+ showCompDocDraftSavedToast: vi.fn(),
+}));
+
+const SCHEDULED_ON = "2026-06-01";
+
+function baseRow(overrides: Partial = {}): WorkOrderTableRow {
+ return {
+ id: 42,
+ woNumber: "WO-42",
+ site: "SEA1",
+ type: "PM",
+ dispatcherId: "u1",
+ dispatcherName: "Dispatcher",
+ scheduledOn: SCHEDULED_ON,
+ completedDate: "",
+ targetWeek: "",
+ dayGroup: "",
+ dayLabel: "",
+ location: "Seattle",
+ locationId: "mock-loc-1",
+ pm: "HVAC",
+ serviceNotes: "",
+ extraServices: [],
+ vendorId: "",
+ company: "Vendor Co",
+ tech: "Tech One",
+ techPhone: "",
+ apptStart: "",
+ apptEnd: "",
+ apptTime: "",
+ status: "Scheduled",
+ docStatus: "No",
+ pocName: "Amazon Rep",
+ pocPhone: "",
+ pocNotes: "",
+ dueDate: "",
+ rescheduleCount: 0,
+ carriedOver: 0,
+ originalWeek: "",
+ originalDate: "",
+ isPastDue: false,
+ scheduleWeekOnly: false,
+ mediaCount: 0,
+ title: "PM — SEA1",
+ priority: "Sev 3",
+ primaryDispatchId: null,
+ rowVersion: "",
+ dispatchRowVersion: "",
+ flagColor: null,
+ ...overrides,
+ };
+}
+
+describe("CompDocDialog Save Draft / Generate completion contract", () => {
+ const onSave = vi.fn();
+ const onDateCompletedChange = vi.fn();
+
+ beforeEach(() => {
+ onSave.mockReset();
+ onDateCompletedChange.mockReset();
+ });
+
+ function renderDialog(row: WorkOrderTableRow) {
+ // Mirrors board wiring: only completedDate / docStatus — never scheduledOn or lifecycle.
+ const patches: Array> = [];
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+ });
+
+ render(
+
+ {
+ await onSave(data, status);
+ patches.push({ docStatus: status });
+ }}
+ onDateCompletedChange={async (date) => {
+ await onDateCompletedChange(date);
+ patches.push({ completedDate: date });
+ }}
+ />
+ ,
+ );
+
+ return { patches };
+ }
+
+ function changeDateCompleted(value: string) {
+ const dateInput = document.querySelector('input[type="date"]');
+ expect(dateInput).not.toBeNull();
+ fireEvent.change(dateInput!, { target: { value } });
+ }
+
+ it("Save draft emits completedDate without writing scheduledOn or lifecycle Completed", async () => {
+ const row = baseRow();
+ const { patches } = renderDialog(row);
+
+ changeDateCompleted("2026-07-15");
+ fireEvent.click(screen.getByRole("button", { name: /save draft/i }));
+
+ await waitFor(() => {
+ expect(onDateCompletedChange).toHaveBeenCalledWith("2026-07-15");
+ });
+ expect(onSave).toHaveBeenCalledWith(expect.any(Object), "No");
+ expect(patches).toEqual([{ completedDate: "2026-07-15" }, { docStatus: "No" }]);
+ expect(patches.some((patch) => "scheduledOn" in patch)).toBe(false);
+ expect(patches.some((patch) => patch.status === "Completed")).toBe(false);
+ expect(row.scheduledOn).toBe(SCHEDULED_ON);
+ expect(row.status).toBe("Scheduled");
+ });
+
+ it("Generate emits completedDate and doc Yes without auto-setting lifecycle Completed", async () => {
+ const row = baseRow({ completedDate: "2026-07-01" });
+ const { patches } = renderDialog(row);
+
+ changeDateCompleted("2026-07-20");
+ fireEvent.click(screen.getByRole("button", { name: /^generate$/i }));
+
+ await waitFor(() => {
+ expect(onDateCompletedChange).toHaveBeenCalledWith("2026-07-20");
+ });
+ expect(onSave).toHaveBeenCalledWith(
+ expect.objectContaining({ generatedAt: expect.any(String) }),
+ "Yes",
+ );
+ expect(patches).toEqual([{ completedDate: "2026-07-20" }, { docStatus: "Yes" }]);
+ expect(patches.some((patch) => "scheduledOn" in patch)).toBe(false);
+ expect(patches.some((patch) => patch.status === "Completed")).toBe(false);
+ expect(row.scheduledOn).toBe(SCHEDULED_ON);
+ expect(row.status).toBe("Scheduled");
+ });
+
+ it("Generate keeps Draft and skips docStatus Yes when completedDate patch fails", async () => {
+ onDateCompletedChange.mockRejectedValueOnce(new Error("conflict"));
+ const row = baseRow({ completedDate: "2026-07-01" });
+ renderDialog(row);
+
+ changeDateCompleted("2026-07-20");
+ fireEvent.click(screen.getByRole("button", { name: /^generate$/i }));
+
+ await waitFor(() => {
+ expect(onDateCompletedChange).toHaveBeenCalledWith("2026-07-20");
+ });
+ expect(onSave).not.toHaveBeenCalled();
+ expect(screen.getByRole("button", { name: /^generate$/i })).toBeInTheDocument();
+ });
+
+ it("Generate never writes docStatus Yes after a successful status-capable save when date fails first", async () => {
+ // Regression for the prior order (status then date): if date fails, Yes must not have been sent.
+ // With prerequisites-first ordering, date failure short-circuits before onSave("Yes").
+ const callOrder: string[] = [];
+ onDateCompletedChange.mockImplementation(async () => {
+ callOrder.push("date");
+ throw new Error("completedDate conflict");
+ });
+ onSave.mockImplementation(async () => {
+ callOrder.push("status");
+ });
+
+ const row = baseRow({ completedDate: "2026-07-01" });
+ renderDialog(row);
+ changeDateCompleted("2026-07-20");
+ fireEvent.click(screen.getByRole("button", { name: /^generate$/i }));
+
+ await waitFor(() => {
+ expect(onDateCompletedChange).toHaveBeenCalled();
+ });
+ expect(callOrder).toEqual(["date"]);
+ expect(onSave).not.toHaveBeenCalled();
+ });
+
+ it("Save draft persists PM comments notes through onSave", async () => {
+ const row = baseRow();
+ renderDialog(row);
+
+ const notes = screen.getByPlaceholderText(/add findings/i);
+ fireEvent.change(notes, { target: { value: "No issues noted" } });
+ fireEvent.click(screen.getByRole("button", { name: /save draft/i }));
+
+ await waitFor(() => {
+ expect(onSave).toHaveBeenCalledWith(
+ expect.objectContaining({ notes: "No issues noted" }),
+ "No",
+ );
+ });
+ });
+
+ it("Mark as not required rolls back UI when persist fails", async () => {
+ onSave.mockRejectedValueOnce(new Error("persist failed"));
+ const row = baseRow();
+ renderDialog(row);
+
+ fireEvent.click(screen.getByRole("button", { name: /mark as not required/i }));
+
+ await waitFor(() => {
+ expect(onSave).toHaveBeenCalledWith(expect.any(Object), "NN");
+ });
+ await waitFor(() => {
+ expect(screen.getByRole("button", { name: /mark as not required/i })).toBeInTheDocument();
+ });
+ expect(screen.queryByRole("button", { name: /^undo$/i })).not.toBeInTheDocument();
+ });
+});
diff --git a/src/test/app/(protected)/workorders/comp-doc-dialog-footer-exclusive.test.tsx b/src/test/app/(protected)/workorders/comp-doc-dialog-footer-exclusive.test.tsx
new file mode 100644
index 00000000..49ced8be
--- /dev/null
+++ b/src/test/app/(protected)/workorders/comp-doc-dialog-footer-exclusive.test.tsx
@@ -0,0 +1,53 @@
+import { createRef } from "react";
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import { CompDocDialogFooter } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer";
+
+describe("CompDocDialogFooter exclusive states", () => {
+ const pdfInputRef = createRef();
+ const handlers = {
+ onMarkNotRequired: vi.fn(),
+ onSaveDraft: vi.fn(),
+ onGenerate: vi.fn(),
+ onUndo: vi.fn(),
+ onDownloadPdf: vi.fn(),
+ onUploadPdf: vi.fn(),
+ };
+
+ it("does not render draft actions when a signed PDF is on file while docState is still draft", () => {
+ render(
+ ,
+ );
+
+ expect(screen.queryByRole("button", { name: /save draft/i })).not.toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: /mark as not required/i })).not.toBeInTheDocument();
+ expect(screen.getByText(/document on file/i)).toBeInTheDocument();
+ });
+
+ it("renders only draft actions for a clean draft without attachment", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByRole("button", { name: /save draft/i })).toBeInTheDocument();
+ expect(screen.queryByText(/document on file/i)).not.toBeInTheDocument();
+ });
+});
diff --git a/src/test/app/(protected)/workorders/duplicate-wo-number-dialog.test.tsx b/src/test/app/(protected)/workorders/duplicate-wo-number-dialog.test.tsx
new file mode 100644
index 00000000..5d126913
--- /dev/null
+++ b/src/test/app/(protected)/workorders/duplicate-wo-number-dialog.test.tsx
@@ -0,0 +1,58 @@
+import { fireEvent, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import { DuplicateWoNumberDialog } from "@/app/(protected)/workorders/_components/list/duplicate-wo-number-dialog";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+import { renderWithProviders } from "@/test/test-utils";
+
+const existingRow = { id: "wo-99", woNumber: "12345678901" } as WorkOrderTableRow;
+
+describe("DuplicateWoNumberDialog", () => {
+ it("shows the duplicate title and work order number when open", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByRole("dialog")).toBeInTheDocument();
+ expect(screen.getByText("Duplicate Work Order Number")).toBeInTheDocument();
+ expect(screen.getByText(/12345678901/)).toBeInTheDocument();
+ });
+
+ it("calls onViewExisting when View existing is clicked", () => {
+ const onViewExisting = vi.fn();
+
+ renderWithProviders(
+ ,
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "View existing" }));
+
+ expect(onViewExisting).toHaveBeenCalledWith(existingRow);
+ });
+
+ it("calls onDismiss when Dismiss is clicked", () => {
+ const onDismiss = vi.fn();
+
+ renderWithProviders(
+ ,
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
+
+ expect(onDismiss).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx b/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx
new file mode 100644
index 00000000..402afa30
--- /dev/null
+++ b/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx
@@ -0,0 +1,114 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+
+const uploadMutateAsync = vi.fn();
+const deleteMutate = vi.fn();
+const categorizeMutate = vi.fn();
+
+vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
+ useWorkOrderBoardDetail: () => ({
+ data: {
+ info: { status: "Scheduled" },
+ media: [
+ {
+ id: "11",
+ fileName: "before.jpg",
+ url: "https://example.com/before.jpg",
+ category: "Before",
+ isLegacy: false,
+ },
+ ],
+ comments: [],
+ audit: [],
+ completion: {},
+ },
+ isLoading: false,
+ }),
+}));
+
+vi.mock("@/domain/work-orders/use-cases/use-work-order-mutations", () => ({
+ useUploadWorkOrderMedia: () => ({
+ mutate: vi.fn(),
+ mutateAsync: uploadMutateAsync,
+ isPending: false,
+ }),
+ useUpdateWorkOrderMediaCategory: () => ({ mutate: categorizeMutate, isPending: false }),
+ useDeleteWorkOrderMedia: () => ({ mutate: deleteMutate, isPending: false }),
+}));
+
+function baseRow(): WorkOrderTableRow {
+ return {
+ id: 7,
+ woNumber: "WO-7",
+ site: "SEA1",
+ type: "Emergency",
+ dispatcherId: "u1",
+ dispatcherName: "Dispatcher",
+ scheduledOn: "2026-06-01",
+ completedDate: "",
+ targetWeek: "",
+ dayGroup: "",
+ dayLabel: "",
+ location: "Seattle",
+ locationId: "loc-1",
+ pm: "",
+ serviceNotes: "",
+ extraServices: [],
+ vendorId: "",
+ company: "",
+ tech: "",
+ techPhone: "",
+ apptStart: "",
+ apptEnd: "",
+ apptTime: "",
+ status: "Scheduled",
+ docStatus: "",
+ pocName: "",
+ pocPhone: "",
+ pocNotes: "",
+ dueDate: "",
+ rescheduleCount: 0,
+ carriedOver: 0,
+ originalWeek: "",
+ originalDate: "",
+ isPastDue: false,
+ scheduleWeekOnly: false,
+ mediaCount: 1,
+ title: "Emergency",
+ priority: "Sev 1",
+ primaryDispatchId: null,
+ rowVersion: "",
+ dispatchRowVersion: "",
+ flagColor: null,
+ };
+}
+
+describe("MediaUploadModal API wiring", () => {
+ beforeEach(() => {
+ uploadMutateAsync.mockReset();
+ deleteMutate.mockReset();
+ categorizeMutate.mockReset();
+ });
+
+ it("renders persisted media from board detail (refresh-safe)", async () => {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+ });
+
+ render(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText("before.jpg")).toBeInTheDocument();
+ });
+ expect(screen.getByText("Photos & Videos")).toBeInTheDocument();
+ expect(screen.getByText(/categorize after upload/i)).toBeInTheDocument();
+ expect(screen.getByLabelText(/category for before\.jpg/i)).toBeInTheDocument();
+ });
+});
diff --git a/src/test/app/(protected)/workorders/new-wo-wizard-duplicate.test.tsx b/src/test/app/(protected)/workorders/new-wo-wizard-duplicate.test.tsx
new file mode 100644
index 00000000..e1e6acb5
--- /dev/null
+++ b/src/test/app/(protected)/workorders/new-wo-wizard-duplicate.test.tsx
@@ -0,0 +1,88 @@
+import { fireEvent, screen, waitFor } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import { NewWoWizard } from "@/app/(protected)/workorders/_components/wizard/new-wo-wizard";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+import { renderWithProviders } from "@/test/test-utils";
+
+const existingRow = { id: "wo-99", woNumber: "12345678901" } as WorkOrderTableRow;
+const findByWoNumber = vi.fn();
+
+vi.mock("@/domain/work-orders/use-cases/use-create-work-order-from-wizard", () => ({
+ useCreateWorkOrderFromWizard: () => ({
+ mutate: vi.fn(),
+ isPending: false,
+ }),
+}));
+
+vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
+ useWorkOrderLocations: () => ({ data: [] }),
+ useWorkOrderVendors: () => ({ data: [] }),
+}));
+
+vi.mock("@/domain/locations/use-cases/use-location-detail", () => ({
+ useLocationDetail: () => ({ data: undefined }),
+}));
+
+vi.mock("@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category", () => ({
+ useDropdownOptionsByCategory: () => ({ data: [] }),
+}));
+
+vi.mock("@/domain/work-orders/api/work-orders-api", () => ({
+ workOrdersApi: {
+ findByWoNumber: (...args: unknown[]) => findByWoNumber(...args),
+ },
+}));
+
+describe("NewWoWizard duplicate wo number", () => {
+ it("shows the duplicate dialog when the wizard wo number matches an existing row", async () => {
+ renderWithProviders(
+ ,
+ );
+
+ const woNumberInput = screen.getByPlaceholderText("e.g. 24818");
+ fireEvent.change(woNumberInput, {
+ target: { value: "12345678901" },
+ });
+ fireEvent.blur(woNumberInput);
+
+ await waitFor(() => {
+ expect(screen.getByText("Duplicate Work Order Number")).toBeInTheDocument();
+ });
+ expect(screen.getByText(/is already assigned to another work order/i)).toBeInTheDocument();
+ });
+
+ it("finds a duplicate outside the loaded board rows", async () => {
+ const offPageRow = {
+ id: "wo-off-page",
+ woNumber: "98765432109",
+ } as WorkOrderTableRow;
+ findByWoNumber.mockResolvedValueOnce(offPageRow);
+
+ renderWithProviders(
+ ,
+ );
+
+ const woNumberInput = screen.getByPlaceholderText("e.g. 24818");
+ fireEvent.change(woNumberInput, {
+ target: { value: "98765432109" },
+ });
+ fireEvent.blur(woNumberInput);
+
+ await waitFor(() => {
+ expect(screen.getByText("Duplicate Work Order Number")).toBeInTheDocument();
+ });
+ expect(findByWoNumber).toHaveBeenCalledWith("98765432109");
+ });
+});
diff --git a/src/test/app/(protected)/workorders/use-comp-doc-dialog-address-seed.test.tsx b/src/test/app/(protected)/workorders/use-comp-doc-dialog-address-seed.test.tsx
new file mode 100644
index 00000000..3e734f4a
--- /dev/null
+++ b/src/test/app/(protected)/workorders/use-comp-doc-dialog-address-seed.test.tsx
@@ -0,0 +1,148 @@
+import { act, renderHook, waitFor } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { ReactNode } from "react";
+import { useCompDocDialog } from "@/app/(protected)/workorders/_hooks/use-comp-doc-dialog";
+import type { Location } from "@/domain/locations/types/location";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+
+const locationDetailState = vi.hoisted(() => ({
+ data: undefined as Location | undefined,
+}));
+
+vi.mock("@/domain/locations/use-cases/use-location-detail", () => ({
+ useLocationDetail: () => ({ data: locationDetailState.data }),
+}));
+
+function baseRow(overrides: Partial = {}): WorkOrderTableRow {
+ return {
+ id: 42,
+ woNumber: "WO-42",
+ site: "SEA1",
+ type: "PM",
+ dispatcherId: "u1",
+ dispatcherName: "Dispatcher",
+ scheduledOn: "2026-06-01",
+ completedDate: "",
+ targetWeek: "",
+ dayGroup: "",
+ dayLabel: "",
+ location: "Seattle",
+ locationId: "loc-real-1",
+ pm: "HVAC",
+ serviceNotes: "",
+ extraServices: [],
+ vendorId: "",
+ company: "Vendor Co",
+ tech: "Tech One",
+ techPhone: "",
+ apptStart: "",
+ apptEnd: "",
+ apptTime: "",
+ status: "Scheduled",
+ docStatus: "No",
+ pocName: "Amazon Rep",
+ pocPhone: "",
+ pocNotes: "",
+ dueDate: "",
+ rescheduleCount: 0,
+ carriedOver: 0,
+ originalWeek: "",
+ originalDate: "",
+ isPastDue: false,
+ scheduleWeekOnly: false,
+ mediaCount: 0,
+ title: "PM — SEA1",
+ priority: "Sev 3",
+ primaryDispatchId: null,
+ rowVersion: "",
+ dispatchRowVersion: "",
+ flagColor: null,
+ ...overrides,
+ };
+}
+
+function wrapper({ children }: { children: ReactNode }) {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+ });
+ return {children} ;
+}
+
+describe("useCompDocDialog address seeding", () => {
+ beforeEach(() => {
+ locationDetailState.data = undefined;
+ });
+
+ it("seeds address from the first authoritative locationDetail without locking on the empty fallback", async () => {
+ const row = baseRow();
+ const { result, rerender } = renderHook(
+ () =>
+ useCompDocDialog({
+ row,
+ open: true,
+ onSave: vi.fn(),
+ }),
+ { wrapper },
+ );
+
+ expect(result.current.editAddress).toBe("");
+
+ locationDetailState.data = {
+ id: "loc-real-1",
+ name: "SEA1",
+ address: "3811 Distribution Dr",
+ city: "Seattle",
+ state: "WA",
+ zipCode: "98101",
+ } as Location;
+
+ rerender();
+
+ await waitFor(() => {
+ expect(result.current.editAddress).toContain("3811 Distribution Dr");
+ });
+ });
+
+ it("does not overwrite address after the dispatcher edits it", async () => {
+ locationDetailState.data = {
+ id: "loc-real-1",
+ name: "SEA1",
+ address: "3811 Distribution Dr",
+ city: "Seattle",
+ state: "WA",
+ zipCode: "98101",
+ } as Location;
+
+ const row = baseRow();
+ const { result, rerender } = renderHook(
+ () =>
+ useCompDocDialog({
+ row,
+ open: true,
+ onSave: vi.fn(),
+ }),
+ { wrapper },
+ );
+
+ await waitFor(() => {
+ expect(result.current.editAddress).toContain("3811 Distribution Dr");
+ });
+
+ act(() => {
+ result.current.setEditAddress("Dispatcher typed address");
+ });
+
+ locationDetailState.data = {
+ id: "loc-real-1",
+ name: "SEA1",
+ address: "999 Other St",
+ city: "Seattle",
+ state: "WA",
+ zipCode: "98101",
+ } as Location;
+ rerender();
+
+ expect(result.current.editAddress).toBe("Dispatcher typed address");
+ });
+});
diff --git a/src/test/app/(protected)/workorders/use-media-upload-session.test.tsx b/src/test/app/(protected)/workorders/use-media-upload-session.test.tsx
new file mode 100644
index 00000000..4ac689fc
--- /dev/null
+++ b/src/test/app/(protected)/workorders/use-media-upload-session.test.tsx
@@ -0,0 +1,206 @@
+import { act, renderHook, waitFor } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import { useMediaUploadSession } from "@/app/(protected)/workorders/_hooks/use-media-upload-session";
+
+function makeFile(name = "photo.jpg") {
+ return new File(["bytes"], name, { type: "image/jpeg" });
+}
+
+describe("useMediaUploadSession retry / cancel", () => {
+ it("retry upserts the same localId instead of appending a duplicate", async () => {
+ let rejectFirst!: (error: Error) => void;
+ const uploadMutateAsync = vi
+ .fn()
+ .mockImplementationOnce(
+ () =>
+ new Promise((_resolve, reject) => {
+ rejectFirst = reject;
+ }),
+ )
+ .mockImplementation(() => new Promise(() => undefined));
+
+ const { result } = renderHook(() => useMediaUploadSession(undefined, uploadMutateAsync));
+ const localId = "local-1";
+ const file = makeFile();
+
+ act(() => {
+ result.current.startUploads([
+ {
+ localId,
+ file,
+ onProgress: () => undefined,
+ },
+ ]);
+ });
+
+ await waitFor(() => {
+ expect(result.current.uploads).toHaveLength(1);
+ });
+
+ const firstSignal = uploadMutateAsync.mock.calls[0]?.[0]?.signal as AbortSignal;
+
+ act(() => {
+ rejectFirst(new Error("network"));
+ });
+
+ await waitFor(() => {
+ expect(result.current.uploads[0]?.uploadStatus).toBe("failed");
+ });
+
+ act(() => {
+ result.current.retryUpload(localId);
+ });
+
+ await waitFor(() => {
+ expect(result.current.uploads).toHaveLength(1);
+ expect(result.current.uploads[0]?.id).toBe(localId);
+ expect(result.current.uploads[0]?.uploadStatus).toBe("uploading");
+ });
+ expect(uploadMutateAsync).toHaveBeenCalledTimes(2);
+ const retrySignal = uploadMutateAsync.mock.calls[1]?.[0]?.signal as AbortSignal;
+ expect(retrySignal).toBeInstanceOf(AbortSignal);
+ expect(retrySignal).not.toBe(firstSignal);
+ expect(retrySignal.aborted).toBe(false);
+ });
+
+ it("fail → retry → cancel aborts the replacement upload and records no success", async () => {
+ let rejectFirst!: (error: Error) => void;
+ let resolveRetry!: (value: unknown) => void;
+ const uploadMutateAsync = vi
+ .fn()
+ .mockImplementationOnce(
+ () =>
+ new Promise((_resolve, reject) => {
+ rejectFirst = reject;
+ }),
+ )
+ .mockImplementation(
+ ({ signal }: { signal?: AbortSignal }) =>
+ new Promise((resolve, reject) => {
+ resolveRetry = resolve;
+ signal?.addEventListener("abort", () => {
+ const error = new Error("aborted");
+ error.name = "AbortError";
+ reject(error);
+ });
+ }),
+ );
+
+ const { result } = renderHook(() => useMediaUploadSession(undefined, uploadMutateAsync));
+ const localId = "local-retry";
+ const file = makeFile("retry.jpg");
+
+ act(() => {
+ result.current.startUploads([
+ {
+ localId,
+ file,
+ onProgress: () => undefined,
+ },
+ ]);
+ });
+
+ act(() => {
+ rejectFirst(new Error("upload failed"));
+ });
+
+ await waitFor(() => {
+ expect(result.current.uploads[0]?.uploadStatus).toBe("failed");
+ });
+
+ act(() => {
+ result.current.retryUpload(localId);
+ });
+
+ await waitFor(() => {
+ expect(result.current.uploads[0]?.uploadStatus).toBe("uploading");
+ });
+
+ const retrySignal = uploadMutateAsync.mock.calls[1]?.[0]?.signal as AbortSignal;
+
+ act(() => {
+ result.current.cancelUpload(localId);
+ });
+
+ await waitFor(() => {
+ expect(result.current.uploads).toHaveLength(0);
+ });
+ expect(retrySignal.aborted).toBe(true);
+ // Cancelling must not leave a successful settle path.
+ expect(() => resolveRetry({})).not.toThrow();
+ await waitFor(() => {
+ expect(result.current.uploads).toHaveLength(0);
+ });
+ });
+
+ it("clears local uploads and aborts in-flight work when the bound WO changes", async () => {
+ const uploadMutateAsync = vi.fn().mockImplementation(
+ ({ signal }: { signal?: AbortSignal }) =>
+ new Promise((_resolve, reject) => {
+ signal?.addEventListener("abort", () => {
+ const error = new Error("aborted");
+ error.name = "AbortError";
+ reject(error);
+ });
+ }),
+ );
+
+ const detailA = {
+ info: { id: 1 },
+ media: [],
+ comments: [],
+ audit: [],
+ completion: {
+ docStatus: "",
+ template: null,
+ signOffName: "",
+ signOffAttachment: "",
+ signOffSignature: "",
+ dispatchSignoffs: [],
+ },
+ } as unknown as Parameters[0];
+
+ const detailB = {
+ info: { id: 2 },
+ media: [],
+ comments: [],
+ audit: [],
+ completion: {
+ docStatus: "",
+ template: null,
+ signOffName: "",
+ signOffAttachment: "",
+ signOffSignature: "",
+ dispatchSignoffs: [],
+ },
+ } as unknown as Parameters[0];
+
+ const { result, rerender } = renderHook(
+ ({ detail }) => useMediaUploadSession(detail, uploadMutateAsync),
+ { initialProps: { detail: detailA } },
+ );
+
+ act(() => {
+ result.current.startUploads([
+ {
+ localId: "local-wo-a",
+ file: makeFile("a.jpg"),
+ onProgress: () => undefined,
+ },
+ ]);
+ });
+
+ await waitFor(() => {
+ expect(result.current.uploads).toHaveLength(1);
+ });
+
+ const firstSignal = uploadMutateAsync.mock.calls[0]?.[0]?.signal as AbortSignal;
+
+ rerender({ detail: detailB });
+
+ await waitFor(() => {
+ expect(result.current.uploads).toHaveLength(0);
+ });
+ expect(firstSignal.aborted).toBe(true);
+ });
+});
diff --git a/src/test/app/(protected)/workorders/use-site-dialog-state.test.tsx b/src/test/app/(protected)/workorders/use-site-dialog-state.test.tsx
new file mode 100644
index 00000000..dc0adb78
--- /dev/null
+++ b/src/test/app/(protected)/workorders/use-site-dialog-state.test.tsx
@@ -0,0 +1,112 @@
+import { act, renderHook } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { useSiteDialogState } from "@/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-state";
+import type { Location } from "@/domain/locations/types/location";
+import type { LocationOption } from "@/domain/work-orders/types/work-order";
+
+const detailById = new Map();
+
+vi.mock("@/domain/locations/use-cases/use-location-detail", () => ({
+ useLocationDetail: (id: string | undefined) => ({
+ data: id && detailById.has(id) ? detailById.get(id) : undefined,
+ isLoading: Boolean(id && !detailById.has(id)),
+ isError: false,
+ }),
+}));
+
+const SITES: LocationOption[] = [
+ { id: "loc-1", name: "BK5 · Dallas", siteCode: "BK5", city: "Dallas", state: "TX" },
+ { id: "loc-2", name: "SEA1 · Seattle", siteCode: "SEA1", city: "Seattle", state: "WA" },
+];
+
+function locationDetail(id: string, contact: string, phone: string): Location {
+ return {
+ id,
+ name: id,
+ contact,
+ phone,
+ address: "100 Main St",
+ city: "Dallas",
+ state: "TX",
+ zipCode: "75001",
+ };
+}
+
+function renderSiteDialogState() {
+ return renderHook(() =>
+ useSiteDialogState({
+ open: true,
+ onOpenChange: vi.fn(),
+ value: "",
+ locationId: "",
+ pocName: "",
+ pocPhone: "",
+ pocNotes: "",
+ sites: SITES,
+ createMode: true,
+ onSave: vi.fn(),
+ }),
+ );
+}
+
+describe("useSiteDialogState POC autofill", () => {
+ beforeEach(() => {
+ detailById.clear();
+ });
+
+ it("preserves POC typed before deferred location detail resolves", () => {
+ const { result, rerender } = renderSiteDialogState();
+
+ act(() => {
+ result.current.handlePick("loc-1");
+ });
+
+ act(() => {
+ result.current.setPn("Dispatcher Typed");
+ result.current.setPp("999-000-1111");
+ });
+
+ expect(result.current.pn).toBe("Dispatcher Typed");
+ expect(result.current.pp).toBe("999-000-1111");
+
+ detailById.set("loc-1", locationDetail("loc-1", "Site Contact", "214-555-0111"));
+ act(() => {
+ rerender();
+ });
+
+ expect(result.current.pn).toBe("Dispatcher Typed");
+ expect(result.current.pp).toBe("999-000-1111");
+ });
+
+ it("clears prior POC on site switch then autofills from the new site detail", () => {
+ const { result, rerender } = renderSiteDialogState();
+
+ act(() => {
+ result.current.handlePick("loc-1");
+ });
+
+ detailById.set("loc-1", locationDetail("loc-1", "Contact A", "111-111-1111"));
+ act(() => {
+ rerender();
+ });
+
+ expect(result.current.pn).toBe("Contact A");
+ expect(result.current.pp).toBe("111-111-1111");
+
+ act(() => {
+ result.current.handlePick("loc-2");
+ });
+
+ expect(result.current.locId).toBe("loc-2");
+ expect(result.current.pn).toBe("");
+ expect(result.current.pp).toBe("");
+
+ detailById.set("loc-2", locationDetail("loc-2", "Contact B", "222-222-2222"));
+ act(() => {
+ rerender();
+ });
+
+ expect(result.current.pn).toBe("Contact B");
+ expect(result.current.pp).toBe("222-222-2222");
+ });
+});
diff --git a/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx b/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx
new file mode 100644
index 00000000..c5c03f6c
--- /dev/null
+++ b/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx
@@ -0,0 +1,142 @@
+import { renderHook } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import { useSlideOverMediaState } from "@/app/(protected)/workorders/_components/detail/use-slide-over-media-state";
+import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+
+function baseInfo(overrides: Partial = {}): WorkOrderTableRow {
+ return {
+ id: 100,
+ woNumber: "100",
+ site: "SITE",
+ type: "Emergency",
+ dispatcherId: "d1",
+ dispatcherName: "Alex",
+ scheduledOn: "2026-07-16",
+ completedDate: "",
+ targetWeek: "",
+ dayGroup: "2026-07-16",
+ dayLabel: "Thu",
+ location: "Main",
+ locationId: 5,
+ pm: "",
+ serviceNotes: "",
+ extraServices: [],
+ vendorId: "v1",
+ company: "Acme",
+ tech: "Sam",
+ techPhone: "",
+ apptStart: "",
+ apptEnd: "",
+ apptTime: "",
+ status: "Incomplete",
+ docStatus: "",
+ pocName: "",
+ pocPhone: "",
+ pocNotes: "",
+ dueDate: "",
+ rescheduleCount: 0,
+ carriedOver: 0,
+ originalWeek: "",
+ originalDate: "",
+ isPastDue: false,
+ scheduleWeekOnly: false,
+ mediaCount: 0,
+ title: "",
+ priority: "",
+ primaryDispatchId: null,
+ rowVersion: "1",
+ dispatchRowVersion: "",
+ flagColor: null,
+ ...overrides,
+ };
+}
+
+function detailWithMedia(
+ media: WorkOrderBoardDetail["media"],
+ infoOverrides: Partial = {},
+): WorkOrderBoardDetail {
+ return {
+ info: baseInfo(infoOverrides),
+ completion: {
+ docStatus: "",
+ template: null,
+ signOffName: "",
+ signOffAttachment: "",
+ signOffSignature: "",
+ dispatchSignoffs: [],
+ },
+ comments: [],
+ audit: [],
+ media,
+ };
+}
+
+function idleMutation() {
+ return {
+ mutate: vi.fn(),
+ mutateAsync: vi.fn().mockResolvedValue({}),
+ isPending: false,
+ } as never;
+}
+
+describe("useSlideOverMediaState completion filter", () => {
+ it("keeps Before/After/Completion after refetch mapped purpose (not file category)", () => {
+ const detail = detailWithMedia([
+ {
+ id: "1",
+ category: "Before",
+ url: "https://cdn.example.com/before.jpg",
+ fileName: "before.jpg",
+ uploadedAt: "2026-07-15T18:00:00.0000000Z",
+ isLegacy: false,
+ },
+ {
+ id: "2",
+ category: "After",
+ url: "https://cdn.example.com/after.jpg",
+ fileName: "after.jpg",
+ uploadedAt: "2026-07-15T18:00:00.0000000Z",
+ isLegacy: false,
+ },
+ {
+ id: "3",
+ category: "Completion",
+ url: "https://cdn.example.com/completion.pdf",
+ fileName: "completion.pdf",
+ uploadedAt: "2026-07-15T18:00:00.0000000Z",
+ isLegacy: false,
+ },
+ {
+ id: "4",
+ category: "Extra",
+ url: "https://cdn.example.com/extra.pdf",
+ fileName: "extra.pdf",
+ uploadedAt: "2026-07-15T18:00:00.0000000Z",
+ isLegacy: false,
+ },
+ ]);
+
+ const { result } = renderHook(() =>
+ useSlideOverMediaState({
+ detailForRow: detail,
+ uploadMediaMutation: idleMutation(),
+ deleteMediaMutation: idleMutation(),
+ categorizeMediaMutation: idleMutation(),
+ }),
+ );
+
+ expect(result.current.completionMedia.map((item) => item.purpose)).toEqual([
+ "Before",
+ "After",
+ "Completion",
+ ]);
+ expect(
+ result.current.completionMedia.every((item) =>
+ ["Photo", "Video", "Report", "Other"].includes(item.category),
+ ),
+ ).toBe(true);
+ expect(result.current.extrasUploads).toHaveLength(1);
+ expect(result.current.extrasUploads[0]?.purpose).toBe("Extra");
+ });
+});
diff --git a/src/test/app/(protected)/workorders/use-wo-table-row-handlers-duplicate.test.ts b/src/test/app/(protected)/workorders/use-wo-table-row-handlers-duplicate.test.ts
new file mode 100644
index 00000000..df563025
--- /dev/null
+++ b/src/test/app/(protected)/workorders/use-wo-table-row-handlers-duplicate.test.ts
@@ -0,0 +1,43 @@
+import { renderHook } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import { useWoTableRowHandlers } from "@/app/(protected)/workorders/_components/list/table/use-wo-table-row-handlers";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+
+const existingRow = { id: "wo-99", woNumber: "12345678901" } as WorkOrderTableRow;
+
+function buildTableData() {
+ return {
+ dragState: { current: { dragId: null, overId: null } },
+ setDragViz: vi.fn(),
+ reorderRows: vi.fn(),
+ endDrag: vi.fn(),
+ setUnschedOpen: vi.fn(),
+ triggerFlash: vi.fn(),
+ setSelectedId: vi.fn(),
+ } as never;
+}
+
+describe("useWoTableRowHandlers onDuplicateFound", () => {
+ it("delegates to onDuplicateFound instead of opening the slide-over", () => {
+ const onDuplicateFound = vi.fn();
+ const onOpenSlideOver = vi.fn();
+
+ const { result } = renderHook(() =>
+ useWoTableRowHandlers({
+ tableData: buildTableData(),
+ weekMonday: "2026-07-20",
+ onWeekChange: vi.fn(),
+ onOpenSlideOver,
+ onOpenMedia: vi.fn(),
+ onPatch: vi.fn(),
+ onScheduleHighlight: vi.fn(),
+ onDuplicateFound,
+ }),
+ );
+
+ result.current.onDuplicateFound?.(existingRow);
+
+ expect(onDuplicateFound).toHaveBeenCalledWith(existingRow);
+ expect(onOpenSlideOver).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/test/app/(protected)/workorders/wo-table-unscheduled-highlight.test.tsx b/src/test/app/(protected)/workorders/wo-table-unscheduled-highlight.test.tsx
index 011c95a6..0c157819 100644
--- a/src/test/app/(protected)/workorders/wo-table-unscheduled-highlight.test.tsx
+++ b/src/test/app/(protected)/workorders/wo-table-unscheduled-highlight.test.tsx
@@ -17,6 +17,7 @@ const unscheduledRow: WorkOrderTableRow = {
dispatcherId: "u1",
dispatcherName: "Test Dispatcher",
scheduledOn: "",
+ completedDate: "",
targetWeek: "",
dayGroup: "",
dayLabel: "",
diff --git a/src/test/app/(protected)/workorders/work-order-slide-over-failed-save.test.tsx b/src/test/app/(protected)/workorders/work-order-slide-over-failed-save.test.tsx
new file mode 100644
index 00000000..de960548
--- /dev/null
+++ b/src/test/app/(protected)/workorders/work-order-slide-over-failed-save.test.tsx
@@ -0,0 +1,171 @@
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { WorkOrderSlideOver } from "@/app/(protected)/workorders/_components/detail/work-order-slide-over";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+
+vi.mock("@/providers/auth-context", () => ({
+ useAuthContext: () => ({ user: { id: "u1" } }),
+}));
+
+vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
+ useWorkOrderBoardDetail: () => ({
+ data: undefined,
+ isLoading: true,
+ isFetching: true,
+ error: null,
+ refetch: vi.fn(),
+ }),
+}));
+
+vi.mock("@/domain/work-orders/use-cases/use-work-order-mutations", () => ({
+ useAddWorkOrderComment: () => ({ mutate: vi.fn(), isPending: false }),
+ useUpdateWorkOrderComment: () => ({ mutate: vi.fn(), isPending: false }),
+ useUploadWorkOrderMedia: () => ({ mutate: vi.fn(), mutateAsync: vi.fn(), isPending: false }),
+ useUpdateWorkOrderMediaCategory: () => ({ mutate: vi.fn(), isPending: false }),
+ useDeleteWorkOrderMedia: () => ({ mutate: vi.fn(), isPending: false }),
+ useUploadCompletionDoc: () => ({ mutate: vi.fn(), isPending: false }),
+}));
+
+vi.mock("@/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab", () => ({
+ SlideOverInfoTab: ({
+ draft,
+ onDraftChange,
+ }: {
+ draft: WorkOrderTableRow;
+ onDraftChange: (patch: Partial) => void;
+ }) => (
+
+ WO number
+ onDraftChange({ woNumber: event.target.value })}
+ />
+
+ ),
+}));
+
+vi.mock("react-toastify", () => ({ toast: { error: vi.fn(), success: vi.fn() } }));
+
+function baseRow(overrides: Partial = {}): WorkOrderTableRow {
+ return {
+ id: 42,
+ woNumber: "WO-42",
+ site: "SEA1",
+ type: "PM",
+ dispatcherId: "u1",
+ dispatcherName: "Dispatcher",
+ scheduledOn: "2026-07-20",
+ completedDate: "",
+ targetWeek: "",
+ dayGroup: "07/20",
+ dayLabel: "Mon",
+ location: "Seattle",
+ locationId: "loc-1",
+ pm: "HVAC",
+ serviceNotes: "",
+ extraServices: [],
+ company: "Vendor Co",
+ tech: "Tech",
+ techPhone: "",
+ vendorId: "v1",
+ apptStart: "",
+ apptEnd: "",
+ apptTime: "",
+ status: "Scheduled",
+ docStatus: "No",
+ pocName: "",
+ pocPhone: "",
+ pocNotes: "",
+ dueDate: "",
+ rescheduleCount: 0,
+ carriedOver: 0,
+ originalWeek: "",
+ originalDate: "",
+ isPastDue: false,
+ scheduleWeekOnly: false,
+ mediaCount: 0,
+ title: "PM — SEA1",
+ priority: "Sev 3",
+ primaryDispatchId: null,
+ rowVersion: "v1",
+ dispatchRowVersion: "",
+ flagColor: null,
+ ...overrides,
+ };
+}
+
+describe("WorkOrderSlideOver failed-save retry (no detail yet)", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("keeps draft dirty and Save enabled after optimistic parent row + failed save", async () => {
+ const initial = baseRow();
+ let currentRow = initial;
+ const onSave = vi.fn(
+ (
+ _id: string | number,
+ patch: Partial,
+ options?: { onSuccess?: () => void; onError?: (error: Error) => void },
+ ) => {
+ currentRow = { ...currentRow, ...patch };
+ rerenderWithRow(currentRow);
+ currentRow = initial;
+ rerenderWithRow(currentRow);
+ options?.onError?.(new Error("save failed"));
+ },
+ );
+
+ let rerenderWithRow = (_row: WorkOrderTableRow) => {
+ /* assigned after first render */
+ };
+
+ const view = render(
+ ,
+ );
+
+ rerenderWithRow = (row) => {
+ view.rerender(
+ ,
+ );
+ };
+
+ fireEvent.change(screen.getByLabelText("WO number"), { target: { value: "WO-99" } });
+ expect(screen.getByDisplayValue("WO-99")).toBeInTheDocument();
+
+ const saveButton = screen.getByRole("button", { name: /^save$/i });
+ expect(saveButton).not.toBeDisabled();
+ fireEvent.click(saveButton);
+
+ expect(onSave).toHaveBeenCalled();
+ await waitFor(() => {
+ expect(screen.getByDisplayValue("WO-99")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /^save$/i })).not.toBeDisabled();
+ });
+ });
+});
diff --git a/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx b/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx
index 7e0fbe28..09475faf 100644
--- a/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx
+++ b/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx
@@ -85,6 +85,13 @@ vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
useWorkOrderVendors: () => ({
data: [{ companyName: "Vendor Co", email: "real@vendor.com" }],
}),
+ useWorkOrderBoardDetail: () => ({
+ data: undefined,
+ isLoading: false,
+ isFetching: false,
+ error: null,
+ refetch: vi.fn(),
+ }),
}));
vi.mock("@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category", () => ({
diff --git a/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx b/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx
index 004e96d3..bde11a70 100644
--- a/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx
+++ b/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx
@@ -75,6 +75,7 @@ const canceledRow: WorkOrderTableRow = {
dispatcherId: "u1",
dispatcherName: "Test Dispatcher",
scheduledOn: "2026-07-20",
+ completedDate: "",
targetWeek: "2026-07-20",
dayGroup: "2026-07-20",
dayLabel: "Mon",
@@ -134,6 +135,13 @@ vi.mock("@/domain/work-orders/use-cases/use-work-orders-list", () => ({
vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
useWorkOrderLocations: () => ({ data: [] }),
useWorkOrderVendors: () => ({ data: [] }),
+ useWorkOrderBoardDetail: () => ({
+ data: undefined,
+ isLoading: false,
+ isFetching: false,
+ error: null,
+ refetch: vi.fn(),
+ }),
}));
vi.mock("@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category", () => ({
@@ -197,7 +205,7 @@ describe("WorkOrdersListHeader slice affordances", () => {
});
describe("WorkOrdersListPage board-core public interface", () => {
- it("opens New WO wizard on click, enforces step validation, closes, and View navigates to detail", async () => {
+ it("opens New WO wizard on click, enforces step validation, closes, and View opens the slide-over", async () => {
navigate.mockReset();
renderWithProviders( , { route: "/workorders", withAuth: false });
@@ -216,7 +224,10 @@ describe("WorkOrdersListPage board-core public interface", () => {
await waitForElementToBeRemoved(() => screen.queryByRole("dialog"));
fireEvent.click(screen.getByRole("button", { name: /view canceled row/i }));
- expect(navigate).toHaveBeenCalledWith("/workorders/77");
+
+ expect(await screen.findByLabelText("Work order details")).toBeInTheDocument();
+ expect(screen.getAllByText(/WO-77/i).length).toBeGreaterThan(0);
+ expect(navigate).not.toHaveBeenCalled();
});
});
@@ -233,7 +244,13 @@ describe("CompDocCell slice affordances", () => {
it("shows View Doc when onOpen is provided", () => {
renderWithProviders(
- ,
+ ,
{ withAuth: false },
);
diff --git a/src/test/app/v/dispatch-snapshot-details.test.tsx b/src/test/app/v/dispatch-snapshot-details.test.tsx
new file mode 100644
index 00000000..66f31610
--- /dev/null
+++ b/src/test/app/v/dispatch-snapshot-details.test.tsx
@@ -0,0 +1,52 @@
+import { screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { DispatchSnapshotDetails } from "@/app/v/[token]/dispatch/_components/dispatch-snapshot-details";
+import type { VendorPortalDispatchDetail } from "@/domain/vendor-portal/types/vendor-portal";
+import { renderWithProviders } from "@/test/test-utils";
+
+function buildData(
+ overrides: Partial = {},
+): VendorPortalDispatchDetail {
+ return {
+ id: 9,
+ dispatchNumber: "DSP-9",
+ status: "Verified",
+ nteAmount: 500,
+ checklist: [],
+ comments: [],
+ upliftRequests: [],
+ signoffs: [],
+ documents: [],
+ ...overrides,
+ };
+}
+
+describe("DispatchSnapshotDetails locked banner", () => {
+ it.each(["Verified", "Cancelled", "Refused"])(
+ "states that comments remain open when status is %s",
+ (status) => {
+ renderWithProviders( , {
+ withAuth: false,
+ });
+
+ expect(
+ screen.getByText(
+ new RegExp(
+ `This dispatch is ${status.toLowerCase()} and locked to further changes\\. Comments remain open\\.`,
+ "i",
+ ),
+ ),
+ ).toBeInTheDocument();
+ expect(screen.queryByText(/sign-off/i)).not.toBeInTheDocument();
+ },
+ );
+
+ it("hides the locked banner when the dispatch is not locked", () => {
+ renderWithProviders(
+ ,
+ { withAuth: false },
+ );
+
+ expect(screen.queryByText(/locked to further changes/i)).not.toBeInTheDocument();
+ });
+});
diff --git a/src/test/domain/work-orders/api/work-orders-api.test.ts b/src/test/domain/work-orders/api/work-orders-api.test.ts
index af480a06..a8fdf14d 100644
--- a/src/test/domain/work-orders/api/work-orders-api.test.ts
+++ b/src/test/domain/work-orders/api/work-orders-api.test.ts
@@ -164,6 +164,13 @@ describe("workOrdersApi.update", () => {
});
});
+describe("workOrdersApi completedDate board contract", () => {
+ it("does not expose updateCompletedDate — completedDate uses board PATCH", () => {
+ expect("updateCompletedDate" in workOrdersApi).toBe(false);
+ expect(API_PATHS.workOrder.boardPatch(55)).toBe("workorders/55/board");
+ });
+});
+
describe("workOrdersApi.fetchDispatchers", () => {
beforeEach(() => {
apiGet.mockReset();
@@ -291,6 +298,157 @@ describe("workOrdersApi.searchBoard", () => {
});
});
+describe("workOrdersApi.findByWoNumber", () => {
+ beforeEach(() => {
+ apiGet.mockReset();
+ });
+
+ it("searches globally without the active board filters", async () => {
+ apiGet.mockResolvedValue({
+ items: [
+ {
+ id: 505,
+ woNumber: "10000001235",
+ lifecycleStatusLabel: "Incomplete",
+ },
+ ],
+ totalCount: 1,
+ page: 0,
+ pageSize: 100,
+ });
+
+ const result = await workOrdersApi.findByWoNumber("10000001235");
+
+ expect(apiGet).toHaveBeenCalledWith(
+ API_PATHS.workOrder.boardSearch,
+ expect.objectContaining({
+ searchParams: expect.any(URLSearchParams),
+ }),
+ );
+ const searchParams = apiGet.mock.calls[0]?.[1]?.searchParams as URLSearchParams;
+ expect(Object.fromEntries(searchParams)).toEqual({
+ search: "10000001235",
+ page: "0",
+ pageSize: "100",
+ });
+ expect(result).toMatchObject({ id: 505, woNumber: "10000001235" });
+ });
+
+ it("pages through fuzzy search until the exact WO number is found", async () => {
+ apiGet
+ .mockResolvedValueOnce({
+ items: Array.from({ length: 100 }, (_, index) => ({
+ id: index + 1,
+ woNumber: `fuzzy-match-${index}`,
+ lifecycleStatusLabel: "Incomplete",
+ })),
+ totalCount: 101,
+ page: 0,
+ pageSize: 100,
+ })
+ .mockResolvedValueOnce({
+ items: [
+ {
+ id: 999,
+ woNumber: "10000009999",
+ lifecycleStatusLabel: "Incomplete",
+ },
+ ],
+ totalCount: 101,
+ page: 1,
+ pageSize: 100,
+ });
+
+ const result = await workOrdersApi.findByWoNumber("10000009999");
+
+ expect(apiGet).toHaveBeenCalledTimes(2);
+ const secondParams = apiGet.mock.calls[1]?.[1]?.searchParams as URLSearchParams;
+ expect(Object.fromEntries(secondParams)).toMatchObject({
+ search: "10000009999",
+ page: "1",
+ pageSize: "100",
+ });
+ expect(result).toMatchObject({ id: 999, woNumber: "10000009999" });
+ });
+
+ it("skips remote search for empty WO numbers", async () => {
+ const result = await workOrdersApi.findByWoNumber(" ");
+ expect(apiGet).not.toHaveBeenCalled();
+ expect(result).toBeUndefined();
+ });
+
+ it("finds a one-character WO number duplicate outside loaded board rows", async () => {
+ apiGet.mockResolvedValue({
+ items: [
+ {
+ id: 7,
+ woNumber: "9",
+ lifecycleStatusLabel: "Incomplete",
+ },
+ ],
+ totalCount: 1,
+ page: 0,
+ pageSize: 100,
+ });
+
+ const result = await workOrdersApi.findByWoNumber("9");
+
+ expect(apiGet).toHaveBeenCalledTimes(1);
+ const searchParams = apiGet.mock.calls[0]?.[1]?.searchParams as URLSearchParams;
+ expect(Object.fromEntries(searchParams)).toEqual({
+ search: "9",
+ page: "0",
+ pageSize: "100",
+ });
+ expect(result).toMatchObject({ id: 7, woNumber: "9" });
+ });
+
+ it("throws when the safety page limit is hit before exhausting results", async () => {
+ apiGet.mockResolvedValue({
+ items: Array.from({ length: 100 }, (_, index) => ({
+ id: index + 1,
+ woNumber: `fuzzy-match-${index}`,
+ lifecycleStatusLabel: "Incomplete",
+ })),
+ totalCount: 10_000,
+ page: 0,
+ pageSize: 100,
+ });
+
+ await expect(workOrdersApi.findByWoNumber("10000005555")).rejects.toThrow(/safety page limit/i);
+ expect(apiGet).toHaveBeenCalledTimes(50);
+ });
+
+ it("returns undefined after exhausting all fuzzy search pages", async () => {
+ apiGet
+ .mockResolvedValueOnce({
+ items: Array.from({ length: 100 }, (_, index) => ({
+ id: index + 1,
+ woNumber: `fuzzy-match-${index}`,
+ lifecycleStatusLabel: "Incomplete",
+ })),
+ totalCount: 150,
+ page: 0,
+ pageSize: 100,
+ })
+ .mockResolvedValueOnce({
+ items: Array.from({ length: 50 }, (_, index) => ({
+ id: index + 101,
+ woNumber: `fuzzy-match-${index + 100}`,
+ lifecycleStatusLabel: "Incomplete",
+ })),
+ totalCount: 150,
+ page: 1,
+ pageSize: 100,
+ });
+
+ const result = await workOrdersApi.findByWoNumber("10000005555");
+
+ expect(apiGet).toHaveBeenCalledTimes(2);
+ expect(result).toBeUndefined();
+ });
+});
+
describe("workOrdersApi.patchBoardField", () => {
beforeEach(() => {
apiPatchFn.mockReset();
@@ -496,12 +654,55 @@ describe("workOrdersApi.uploadMedia", () => {
});
});
+describe("workOrdersApi.updateMediaCategory", () => {
+ beforeEach(() => {
+ apiPatchFn.mockReset();
+ });
+
+ it("PATCHes FormData with category and workOrderVersion", async () => {
+ apiPatchFn.mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ id: 12,
+ category: 1,
+ url: "https://cdn.example.com/before.jpg",
+ fileName: "before.jpg",
+ uploadedAt: "2026-07-16T12:00:00Z",
+ isLegacy: false,
+ }),
+ });
+
+ const item = await workOrdersApi.updateMediaCategory(10, 12, "Before", "AAAAAAA=");
+
+ expect(apiPatchFn).toHaveBeenCalledWith(
+ API_PATHS.workOrder.mediaItem(10, 12),
+ expect.objectContaining({ throwHttpErrors: false }),
+ );
+ const body = apiPatchFn.mock.calls[0]?.[1]?.body as FormData;
+ expect(body.get("category")).toBe("Before");
+ expect(body.get("workOrderVersion")).toBe("AAAAAAA=");
+ expect(item.category).toBe("Before");
+ });
+
+ it("rejects missing workOrderVersion client-side", async () => {
+ const { BoardPatchValidationError } =
+ await import("@/domain/work-orders/errors/board-patch-errors");
+
+ await expect(workOrdersApi.updateMediaCategory(10, 12, "After", "")).rejects.toMatchObject({
+ name: "BoardPatchValidationError",
+ code: "WorkOrderVersionRequired",
+ });
+ expect(apiPatchFn).not.toHaveBeenCalled();
+ expect(BoardPatchValidationError).toBeTruthy();
+ });
+});
+
describe("workOrdersApi.deleteMedia", () => {
beforeEach(() => {
apiDeleteFn.mockReset();
});
- it("DELETEs media item and accepts 204", async () => {
+ it("DELETEs media item with workOrderVersion query and accepts 204", async () => {
apiDeleteFn.mockResolvedValue({
ok: true,
status: 204,
@@ -510,18 +711,31 @@ describe("workOrdersApi.deleteMedia", () => {
},
});
- await workOrdersApi.deleteMedia(10, 12);
+ await workOrdersApi.deleteMedia(10, 12, "AAAAAAA=");
expect(apiDeleteFn).toHaveBeenCalledWith(API_PATHS.workOrder.mediaItem(10, 12), {
+ searchParams: { workOrderVersion: "AAAAAAA=" },
throwHttpErrors: false,
});
});
+ it("rejects missing workOrderVersion client-side", async () => {
+ const { BoardPatchValidationError } =
+ await import("@/domain/work-orders/errors/board-patch-errors");
+
+ await expect(workOrdersApi.deleteMedia(10, 12, "")).rejects.toMatchObject({
+ name: "BoardPatchValidationError",
+ code: "WorkOrderVersionRequired",
+ });
+ expect(apiDeleteFn).not.toHaveBeenCalled();
+ expect(BoardPatchValidationError).toBeTruthy();
+ });
+
it("rejects legacy media ids client-side", async () => {
const { BoardPatchValidationError } =
await import("@/domain/work-orders/errors/board-patch-errors");
- await expect(workOrdersApi.deleteMedia(10, -1)).rejects.toBeInstanceOf(
+ await expect(workOrdersApi.deleteMedia(10, -1, "AAAAAAA=")).rejects.toBeInstanceOf(
BoardPatchValidationError,
);
expect(apiDeleteFn).not.toHaveBeenCalled();
diff --git a/src/test/domain/work-orders/mappers/completed-date-board-column.test.ts b/src/test/domain/work-orders/mappers/completed-date-board-column.test.ts
new file mode 100644
index 00000000..9b74c71a
--- /dev/null
+++ b/src/test/domain/work-orders/mappers/completed-date-board-column.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, it } from "vitest";
+import { WO_TABLE_COLS } from "@/app/(protected)/workorders/_components/list/table/wo-table-cols";
+import { expandScalarBoardOps } from "@/domain/work-orders/mappers/expand-scalar-board-ops";
+import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-table-row-mapper";
+
+describe("COMPLETED DATE board column wiring", () => {
+ it("includes COMPLETED DATE column after COMP DOC", () => {
+ const keys = WO_TABLE_COLS.map((col) => col.key);
+ expect(keys).toContain("completed");
+ expect(keys.indexOf("completed")).toBeGreaterThan(keys.indexOf("doc"));
+ });
+
+ it("expands completedDate patch to board field op", () => {
+ const ops = expandScalarBoardOps({ completedDate: "2026-07-30" });
+ expect(ops).toEqual([{ field: "completedDate", value: "2026-07-30" }]);
+ });
+
+ it("maps completedDate returned by a board GET row", () => {
+ const row = mapWorkOrderTableRow({
+ id: 42,
+ completedDate: "2026-07-30T00:00:00Z",
+ });
+
+ expect(row.completedDate).toBe("2026-07-30");
+ });
+});
diff --git a/src/test/domain/work-orders/mappers/work-order-board-detail-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-board-detail-mapper.test.ts
index 350df95f..1bf399ce 100644
--- a/src/test/domain/work-orders/mappers/work-order-board-detail-mapper.test.ts
+++ b/src/test/domain/work-orders/mappers/work-order-board-detail-mapper.test.ts
@@ -122,6 +122,8 @@ describe("mapWorkOrderBoardDetail", () => {
expect(detail.info.docStatus).toBe("No");
expect(detail.info.company).toBe("ACME HVAC");
expect(detail.info.tech).toBe("Mike Tech");
+ expect(detail.info.pm).toBe("HVAC PM");
+ expect(detail.info.location).toBe("Main Warehouse");
expect(detail.completion.docStatus).toBe("No");
expect(detail.completion.template?.name).toBe("HVAC PM Completion");
@@ -170,13 +172,40 @@ describe("mapWorkOrderBoardDetail", () => {
name: "before.jpg",
url: "https://cdn.example.com/before.jpg",
category: "Photo",
+ purpose: "Before",
fileType: "image",
isLegacy: false,
});
expect(files[1]?.category).toBe("Other");
+ expect(files[1]?.purpose).toBe("Extra");
expect(files[1]?.fileType).toBe("pdf");
});
+ it("maps After and Completion board categories to purpose for completion UI", () => {
+ const after = mapBoardDetailMediaItem({
+ id: 20,
+ category: 2,
+ url: "https://cdn.example.com/after.jpg",
+ fileName: "after.jpg",
+ });
+ const completion = mapBoardDetailMediaItem({
+ id: 21,
+ category: 4,
+ url: "https://cdn.example.com/completion.pdf",
+ fileName: "completion.pdf",
+ });
+ const files = boardMediaItemsToMediaFiles([after, completion]);
+ expect(files[0]).toMatchObject({
+ category: "Photo",
+ purpose: "After",
+ });
+ expect(files[1]).toMatchObject({
+ category: "Report",
+ purpose: "Completion",
+ fileType: "pdf",
+ });
+ });
+
it("maps numeric category and marks negative ids as legacy", () => {
const legacy = mapBoardDetailMediaItem({
id: -1,
diff --git a/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts
index 3d8034a7..5ecb9568 100644
--- a/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts
+++ b/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts
@@ -190,6 +190,21 @@ describe("mapWizardDraftToBoardCreatePayload", () => {
expect(payload.scheduledDate).toBeUndefined();
});
+ it("omits week-mode flags when scheduleWeekOnly is set without a week", () => {
+ const payload = mapWizardDraftToBoardCreatePayload({
+ ...EMPTY_WIZARD_DRAFT,
+ type: "PM",
+ siteCode: "BK5",
+ scheduleWeekOnly: true,
+ scheduledOn: "",
+ targetWeek: "",
+ });
+
+ expect(payload.scheduleWeekOnly).toBeUndefined();
+ expect(payload.targetWeek).toBeUndefined();
+ expect(payload.scheduledDate).toBeUndefined();
+ });
+
it("omits appointment fields when vendor is missing but still sends techPhone and vendorNotes", () => {
const payload = mapWizardDraftToBoardCreatePayload({
...EMPTY_WIZARD_DRAFT,
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 5b71e223..ed1be20e 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
@@ -135,4 +135,18 @@ describe("mapWorkOrderFormToUpdateFormData", () => {
expect(formData.getAll("ContactIds")).toEqual([]);
expect(formData.getAll("CategoryIds")).toEqual([]);
});
+
+ it("keeps completedDate on full EditWorkorder form and omits DocStatus (board field)", () => {
+ const values = {
+ ...createDefaultWorkOrderFormValues(),
+ createdDate: "2026-01-15",
+ completedDate: "2026-07-20",
+ docStatus: "NN" as const,
+ };
+
+ const formData = mapWorkOrderFormToUpdateFormData(1, values);
+
+ expect(formData.get("CompletedDate")).toBe("2026-07-20");
+ expect(formData.has("DocStatus")).toBe(false);
+ });
});
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 63a92912..1dd43618 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,5 +1,8 @@
import { describe, expect, it } from "vitest";
import {
+ mapDocStatusToApi,
+ mapWorkOrderDetail,
+ mapWorkOrderDetailToFormValues,
mapWorkOrderDispatchSummary,
mapWorkOrderTableRow,
} from "@/domain/work-orders/mappers/work-order-mapper";
@@ -23,6 +26,40 @@ 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("maps API NotNeeded to UI NN", () => {
+ expect(mapWorkOrderDetail({ Id: 1, DocStatus: "NotNeeded" }).docStatus).toBe("NN");
+ expect(mapWorkOrderDetail({ Id: 2, docStatus: 3 }).docStatus).toBe("NN");
+ });
+
+ it("falls back to an empty doc status for unknown/missing values", () => {
+ const detail = mapWorkOrderDetail({ Id: 1 });
+ expect(detail.docStatus).toBe("");
+ });
+});
+
+describe("mapDocStatusToApi", () => {
+ it("maps NN to NotNeeded and keeps Yes/No", () => {
+ expect(mapDocStatusToApi("NN")).toBe("NotNeeded");
+ expect(mapDocStatusToApi("Yes")).toBe("Yes");
+ expect(mapDocStatusToApi("No")).toBe("No");
+ });
+});
+
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-completed-date.test.ts b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations-completed-date.test.ts
new file mode 100644
index 00000000..b9951795
--- /dev/null
+++ b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations-completed-date.test.ts
@@ -0,0 +1,50 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+
+const patchBoardField = vi.fn();
+
+vi.mock("@/domain/work-orders/api/work-orders-api", () => ({
+ workOrdersApi: {
+ getById: vi.fn(),
+ update: vi.fn(),
+ patchBoardField: (...args: unknown[]) => patchBoardField(...args),
+ },
+}));
+
+import { runWorkOrderTablePatch } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
+
+const BASE_ROW = {
+ id: 99,
+ rowVersion: "AAAAAAA=",
+ completedDate: "",
+} as WorkOrderTableRow;
+
+describe("runWorkOrderTablePatch completedDate", () => {
+ beforeEach(() => {
+ patchBoardField.mockReset();
+ patchBoardField.mockResolvedValue({
+ ...BASE_ROW,
+ completedDate: "2026-07-30",
+ rowVersion: "BBBBBBB=",
+ });
+ });
+
+ it("persists completedDate via board PATCH (not EditWorkorder)", async () => {
+ const result = await runWorkOrderTablePatch(
+ 99,
+ { completedDate: "2026-07-30" },
+ () => BASE_ROW,
+ );
+
+ expect(patchBoardField).toHaveBeenCalledTimes(1);
+ expect(patchBoardField).toHaveBeenCalledWith(99, {
+ field: "completedDate",
+ value: "2026-07-30",
+ workOrderVersion: "AAAAAAA=",
+ });
+ expect(result).toEqual({
+ kind: "board",
+ row: expect.objectContaining({ completedDate: "2026-07-30" }),
+ });
+ });
+});
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..1b992108
--- /dev/null
+++ b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx
@@ -0,0 +1,209 @@
+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 {
+ CompletionDocStatus,
+ WorkOrderTableRow,
+} from "@/domain/work-orders/types/work-order-table-row";
+
+const getById = vi.fn();
+const update = vi.fn();
+const patchBoardField = vi.fn();
+
+vi.mock("@/domain/work-orders/api/work-orders-api", () => ({
+ workOrdersApi: {
+ getById: (...args: unknown[]) => getById(...args),
+ update: (...args: unknown[]) => update(...args),
+ patchBoardField: (...args: unknown[]) => patchBoardField(...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} ;
+ };
+}
+
+type ServerState = {
+ status: string;
+ scheduledDate: string;
+ completedDate: string;
+ docStatus: CompletionDocStatus;
+};
+
+const BASE_SERVER: ServerState = {
+ status: "Open",
+ scheduledDate: "2026-06-01",
+ completedDate: "",
+ docStatus: "No",
+};
+
+const BASE_ROW = {
+ id: 42,
+ woNumber: "WO-42",
+ site: "SEA1",
+ type: "PM",
+ dispatcherId: "d1",
+ dispatcherName: "Dispatcher",
+ scheduledOn: "2026-06-01",
+ completedDate: "",
+ targetWeek: "",
+ dayGroup: "",
+ dayLabel: "",
+ location: "Seattle",
+ locationId: "loc-1",
+ pm: "HVAC",
+ serviceNotes: "",
+ extraServices: [],
+ vendorId: "",
+ company: "",
+ tech: "",
+ techPhone: "",
+ apptStart: "",
+ apptEnd: "",
+ apptTime: "",
+ status: "Incomplete",
+ docStatus: "No",
+ pocName: "",
+ pocPhone: "",
+ pocNotes: "",
+ dueDate: "",
+ rescheduleCount: 0,
+ carriedOver: 0,
+ originalWeek: "",
+ originalDate: "",
+ isPastDue: false,
+ scheduleWeekOnly: false,
+ mediaCount: 0,
+ title: "PM — SEA1",
+ priority: "Sev 3",
+ primaryDispatchId: null,
+ rowVersion: "AAAAAAA=",
+ dispatchRowVersion: "",
+ flagColor: null,
+} as WorkOrderTableRow;
+
+describe("useWorkOrderTableMutations completion-doc persistence", () => {
+ let serverState: ServerState;
+ let events: string[];
+ let rowState: WorkOrderTableRow;
+
+ beforeEach(() => {
+ getById.mockReset();
+ update.mockReset();
+ patchBoardField.mockReset();
+ serverState = { ...BASE_SERVER };
+ rowState = { ...BASE_ROW };
+ events = [];
+
+ patchBoardField.mockImplementation(
+ async (
+ _id: unknown,
+ payload: { field: string; value?: string | null; workOrderVersion: string },
+ ) => {
+ events.push(`boardPatch:${payload.field}:start`);
+ await delay(5);
+ if (payload.field === "docStatus" && typeof payload.value === "string") {
+ const docStatus =
+ payload.value === "NotNeeded" ? "NN" : (payload.value as CompletionDocStatus);
+ serverState = { ...serverState, docStatus };
+ rowState = {
+ ...rowState,
+ docStatus,
+ rowVersion: `${payload.workOrderVersion}-next`,
+ };
+ }
+ if (payload.field === "completedDate" && typeof payload.value === "string") {
+ serverState = { ...serverState, completedDate: payload.value };
+ rowState = {
+ ...rowState,
+ completedDate: payload.value,
+ rowVersion: `${payload.workOrderVersion}-date`,
+ };
+ }
+ events.push(`boardPatch:${payload.field}:end`);
+ return { ...rowState };
+ },
+ );
+ });
+
+ it("persists docStatus via board PATCH with NotNeeded for UI NN", async () => {
+ const onPatch = vi.fn();
+ const { result } = renderHook(
+ () =>
+ useWorkOrderTableMutations({
+ onPatch,
+ clearPatch: () => undefined,
+ getRow: () => rowState,
+ }),
+ { wrapper: makeWrapper() },
+ );
+
+ act(() => {
+ result.current.patchField(42, { docStatus: "NN" });
+ });
+
+ await waitFor(() => expect(events).toContain("boardPatch:docStatus:end"));
+
+ expect(patchBoardField).toHaveBeenCalledWith(42, {
+ field: "docStatus",
+ value: "NotNeeded",
+ workOrderVersion: "AAAAAAA=",
+ });
+ expect(update).not.toHaveBeenCalled();
+ expect(getById).not.toHaveBeenCalled();
+ expect(serverState.docStatus).toBe("NN");
+ });
+
+ it("serializes board docStatus and completedDate via PATCH without racing or EditWorkorder", async () => {
+ const onPatch = vi.fn();
+ const { result } = renderHook(
+ () =>
+ useWorkOrderTableMutations({
+ onPatch,
+ clearPatch: () => undefined,
+ getRow: () => rowState,
+ }),
+ { wrapper: makeWrapper() },
+ );
+
+ act(() => {
+ result.current.patchField(42, { docStatus: "Yes" });
+ result.current.patchField(42, { completedDate: "2026-07-20" });
+ });
+
+ await waitFor(() => expect(events).toContain("boardPatch:completedDate:end"));
+ await waitFor(() => expect(events).toContain("boardPatch:docStatus:end"));
+
+ expect(events).toEqual([
+ "boardPatch:docStatus:start",
+ "boardPatch:docStatus:end",
+ "boardPatch:completedDate:start",
+ "boardPatch:completedDate:end",
+ ]);
+
+ expect(getById).not.toHaveBeenCalled();
+ expect(update).not.toHaveBeenCalled();
+ expect(patchBoardField).toHaveBeenCalledWith(
+ 42,
+ expect.objectContaining({ field: "completedDate", value: "2026-07-20" }),
+ );
+
+ expect(serverState.docStatus).toBe("Yes");
+ expect(serverState.completedDate).toBe("2026-07-20");
+ expect(serverState.scheduledDate).toBe(BASE_SERVER.scheduledDate);
+ expect(serverState.status).toBe(BASE_SERVER.status);
+ });
+});
diff --git a/src/test/domain/work-orders/utils/completion-doc-service-title.test.ts b/src/test/domain/work-orders/utils/completion-doc-service-title.test.ts
new file mode 100644
index 00000000..380a28e4
--- /dev/null
+++ b/src/test/domain/work-orders/utils/completion-doc-service-title.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it } from "vitest";
+import { hasCompletionDocService } from "@/domain/work-orders/utils/completion-doc-service-gate";
+import { serviceTitle } from "@/domain/work-orders/utils/completion-doc-service-title";
+
+describe("completion doc service gate", () => {
+ it("rejects empty or whitespace-only pm", () => {
+ expect(hasCompletionDocService("")).toBe(false);
+ expect(hasCompletionDocService(" ")).toBe(false);
+ expect(hasCompletionDocService(null)).toBe(false);
+ expect(hasCompletionDocService(undefined)).toBe(false);
+ });
+
+ it("accepts a selected service", () => {
+ expect(hasCompletionDocService("HVAC Inspection")).toBe(true);
+ });
+});
+
+describe("completion doc service title", () => {
+ it("uses the selected service without fabricating a frequency cycle", () => {
+ expect(serviceTitle({ pm: "HVAC Inspection" })).toBe("Amer-Building HVAC Inspection");
+ expect(serviceTitle({ pm: "HVAC Inspection" })).not.toMatch(/Week/);
+ });
+
+ it("maps known service phrases without hashing the work-order number", () => {
+ expect(serviceTitle({ pm: "Scrubber Pit" })).toBe("Amer-Building Scrubber Pit Augering");
+ });
+});
diff --git a/src/test/domain/work-orders/utils/find-duplicate-wo-number-row.test.ts b/src/test/domain/work-orders/utils/find-duplicate-wo-number-row.test.ts
new file mode 100644
index 00000000..f56dda8d
--- /dev/null
+++ b/src/test/domain/work-orders/utils/find-duplicate-wo-number-row.test.ts
@@ -0,0 +1,19 @@
+import { describe, expect, it } from "vitest";
+import { findDuplicateWoNumberRow } from "@/domain/work-orders/utils/find-duplicate-wo-number-row";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+
+const rows = [
+ { id: "wo-1", woNumber: "12345678901" },
+ { id: "wo-2", woNumber: "99999999999" },
+] as WorkOrderTableRow[];
+
+describe("findDuplicateWoNumberRow", () => {
+ it("returns a matching row when the wo number already exists", () => {
+ expect(findDuplicateWoNumberRow("12345678901", rows)).toEqual(rows[0]);
+ });
+
+ it("ignores blank values and excluded ids", () => {
+ expect(findDuplicateWoNumberRow("", rows)).toBeUndefined();
+ expect(findDuplicateWoNumberRow("12345678901", rows, "wo-1")).toBeUndefined();
+ });
+});
diff --git a/src/test/domain/work-orders/utils/get-inline-create-missing.test.ts b/src/test/domain/work-orders/utils/get-inline-create-missing.test.ts
new file mode 100644
index 00000000..3ddd8dca
--- /dev/null
+++ b/src/test/domain/work-orders/utils/get-inline-create-missing.test.ts
@@ -0,0 +1,82 @@
+import { describe, expect, it } from "vitest";
+import {
+ draftHasScheduleDate,
+ getInlineCreateMissing,
+ inlineCreateSaveHint,
+ inlineCreateSiteError,
+} from "@/domain/work-orders/utils/get-inline-create-missing";
+
+describe("getInlineCreateMissing", () => {
+ it("returns empty when Site, WO#, and POC are filled even without schedule", () => {
+ expect(
+ getInlineCreateMissing({
+ site: "BK5",
+ locationId: "12",
+ woNumber: "25001",
+ pocName: "Jane",
+ pocPhone: "555-0100",
+ }),
+ ).toEqual([]);
+ });
+
+ it("accepts locationId alone as site presence", () => {
+ expect(
+ getInlineCreateMissing({
+ site: "",
+ locationId: 42,
+ woNumber: "25001",
+ pocName: "Jane",
+ pocPhone: "555-0100",
+ }),
+ ).toEqual([]);
+ });
+
+ it("lists missing POC fields without requiring schedule", () => {
+ expect(
+ getInlineCreateMissing({
+ site: "BK5",
+ woNumber: "25001",
+ pocName: "",
+ pocPhone: "",
+ }),
+ ).toEqual(["pocName", "pocPhone"]);
+ });
+
+ it("lists site and woNumber when empty", () => {
+ expect(getInlineCreateMissing({})).toEqual(["site", "woNumber", "pocName", "pocPhone"]);
+ });
+});
+
+describe("inlineCreateSaveHint", () => {
+ it("returns Save when ready", () => {
+ expect(inlineCreateSaveHint([])).toBe("Save");
+ });
+
+ it("lists missing labels", () => {
+ expect(inlineCreateSaveHint(["site", "woNumber"])).toBe("Missing: Site, WO #");
+ });
+});
+
+describe("inlineCreateSiteError", () => {
+ it("prefers site-required message when site is missing", () => {
+ expect(inlineCreateSiteError(["site", "pocName"])).toBe("Site is required");
+ });
+
+ it("surfaces contact message when only POC is missing", () => {
+ expect(inlineCreateSiteError(["pocName", "pocPhone"])).toBe(
+ "Contact name and phone are required",
+ );
+ });
+});
+
+describe("draftHasScheduleDate", () => {
+ it("is true for a concrete day", () => {
+ expect(draftHasScheduleDate({ scheduledOn: "2026-06-25" })).toBe(true);
+ });
+
+ it("is false for week-only", () => {
+ expect(
+ draftHasScheduleDate({ scheduledOn: "", scheduleWeekOnly: true, schedMode: "week" }),
+ ).toBe(false);
+ });
+});
diff --git a/src/test/domain/work-orders/utils/run-sequential-uploads.test.ts b/src/test/domain/work-orders/utils/run-sequential-uploads.test.ts
new file mode 100644
index 00000000..92bd2d01
--- /dev/null
+++ b/src/test/domain/work-orders/utils/run-sequential-uploads.test.ts
@@ -0,0 +1,65 @@
+import { describe, expect, it, vi } from "vitest";
+import { runSequentialUploads } from "@/domain/work-orders/utils/run-sequential-uploads";
+
+describe("runSequentialUploads", () => {
+ it("keeps pending true across the full batch; second upload starts only after the first settles", async () => {
+ const pendingLog: boolean[] = [];
+ const setPending = (pending: boolean) => {
+ pendingLog.push(pending);
+ };
+
+ let resolveFirst: (() => void) | undefined;
+ let resolveSecond: (() => void) | undefined;
+
+ const first = new Promise((resolve) => {
+ resolveFirst = resolve;
+ });
+ const second = new Promise((resolve) => {
+ resolveSecond = resolve;
+ });
+
+ const upload = vi
+ .fn()
+ .mockImplementationOnce(() => first)
+ .mockImplementationOnce(() => second);
+
+ const done = runSequentialUploads(["large.bin", "small.bin"], upload, setPending);
+
+ expect(pendingLog).toEqual([true]);
+ expect(upload).toHaveBeenCalledTimes(1);
+ expect(upload).toHaveBeenCalledWith("large.bin");
+
+ // First file still in flight — pending must stay true.
+ await Promise.resolve();
+ expect(pendingLog).toEqual([true]);
+ expect(upload).toHaveBeenCalledTimes(1);
+
+ resolveFirst?.();
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(upload).toHaveBeenCalledTimes(2);
+ expect(upload).toHaveBeenNthCalledWith(2, "small.bin");
+ expect(pendingLog).toEqual([true]);
+
+ resolveSecond?.();
+ await done;
+
+ expect(pendingLog).toEqual([true, false]);
+ });
+
+ it("clears pending when a middle upload rejects", async () => {
+ const setPending = vi.fn();
+ const upload = vi
+ .fn()
+ .mockResolvedValueOnce(undefined)
+ .mockRejectedValueOnce(new Error("upload failed"));
+
+ await expect(runSequentialUploads(["a", "b", "c"], upload, setPending)).rejects.toThrow(
+ "upload failed",
+ );
+
+ expect(setPending.mock.calls.map((call) => call[0])).toEqual([true, false]);
+ expect(upload).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/src/test/domain/work-orders/utils/slide-over-draft.test.ts b/src/test/domain/work-orders/utils/slide-over-draft.test.ts
index 25788b12..bc7ad080 100644
--- a/src/test/domain/work-orders/utils/slide-over-draft.test.ts
+++ b/src/test/domain/work-orders/utils/slide-over-draft.test.ts
@@ -15,6 +15,7 @@ function baseRow(overrides: Partial = {}): WorkOrderTableRow
dispatcherId: "10",
dispatcherName: "Alex",
scheduledOn: "2026-07-16",
+ completedDate: "",
targetWeek: "",
dayGroup: "2026-07-16",
dayLabel: "Thu",
@@ -57,9 +58,23 @@ describe("slide-over-draft", () => {
it("exposes only board-persistable edit keys", () => {
expect(SLIDE_OVER_EDIT_KEYS).toContain("woNumber");
expect(SLIDE_OVER_EDIT_KEYS).toContain("status");
+ expect(SLIDE_OVER_EDIT_KEYS).toContain("vendorId");
expect(SLIDE_OVER_EDIT_KEYS).not.toContain("pocName");
expect(SLIDE_OVER_EDIT_KEYS).not.toContain("locationId");
expect(SLIDE_OVER_EDIT_KEYS).not.toContain("techPhone");
+ expect(SLIDE_OVER_EDIT_KEYS).not.toContain("company");
+ expect(SLIDE_OVER_EDIT_KEYS).not.toContain("tech");
+ });
+
+ it("tracks vendor via vendorId only (company/tech are display companions)", () => {
+ const row = baseRow();
+ const draft = baseRow({ company: "Other Co", tech: "Pat" });
+ expect(isSlideOverDraftDirty(draft, row)).toBe(false);
+ expect(buildSlideOverPatch(draft, row)).toEqual({});
+
+ const vendorDraft = baseRow({ vendorId: "v2", company: "Other Co", tech: "Pat" });
+ expect(isSlideOverDraftDirty(vendorDraft, row)).toBe(true);
+ expect(buildSlideOverPatch(vendorDraft, row)).toEqual({ vendorId: "v2" });
});
it("ignores POC-only edits for dirty state", () => {
diff --git a/src/test/domain/work-orders/utils/slide-over-failed-save-baseline.test.ts b/src/test/domain/work-orders/utils/slide-over-failed-save-baseline.test.ts
new file mode 100644
index 00000000..8543f18c
--- /dev/null
+++ b/src/test/domain/work-orders/utils/slide-over-failed-save-baseline.test.ts
@@ -0,0 +1,99 @@
+import { describe, expect, it } from "vitest";
+import {
+ buildSlideOverPatch,
+ isSlideOverDraftDirty,
+} from "@/domain/work-orders/utils/slide-over-draft";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+
+/**
+ * Mirrors the failed-save path: optimistic parent row absorbs the patch, then
+ * rolls back, while draft is compared against a frozen pre-save baseline.
+ */
+function afterFailedOptimisticSave(input: {
+ baseline: WorkOrderTableRow;
+ draft: WorkOrderTableRow;
+ optimisticParent: WorkOrderTableRow;
+ rolledBackParent: WorkOrderTableRow;
+}) {
+ const dirtyAgainstOptimistic = isSlideOverDraftDirty(input.draft, input.optimisticParent);
+ const dirtyAgainstBaseline = isSlideOverDraftDirty(input.draft, input.baseline);
+ const dirtyAgainstRolledBack = isSlideOverDraftDirty(input.draft, input.rolledBackParent);
+ const retryPatch = buildSlideOverPatch(input.draft, input.baseline);
+ return {
+ dirtyAgainstOptimistic,
+ dirtyAgainstBaseline,
+ dirtyAgainstRolledBack,
+ retryPatch,
+ canRetry: dirtyAgainstBaseline && Object.keys(retryPatch).length > 0,
+ };
+}
+
+function row(overrides: Partial = {}): WorkOrderTableRow {
+ return {
+ id: 1,
+ woNumber: "1001",
+ site: "SITE-A",
+ type: "PM",
+ dispatcherId: "10",
+ dispatcherName: "Alex",
+ scheduledOn: "2026-07-16",
+ completedDate: "",
+ targetWeek: "",
+ dayGroup: "2026-07-16",
+ dayLabel: "Thu",
+ location: "Main",
+ locationId: 5,
+ pm: "Plumbing",
+ serviceNotes: "",
+ extraServices: [],
+ vendorId: "v1",
+ company: "Acme",
+ tech: "Sam",
+ techPhone: "",
+ apptStart: "",
+ apptEnd: "",
+ apptTime: "09:00",
+ status: "Incomplete",
+ docStatus: "",
+ pocName: "",
+ pocPhone: "",
+ pocNotes: "",
+ dueDate: "2026-07-20",
+ rescheduleCount: 0,
+ carriedOver: 0,
+ originalWeek: "",
+ originalDate: "",
+ isPastDue: false,
+ scheduleWeekOnly: false,
+ mediaCount: 0,
+ title: "",
+ priority: "",
+ primaryDispatchId: null,
+ rowVersion: "rv1",
+ dispatchRowVersion: "",
+ flagColor: null,
+ ...overrides,
+ };
+}
+
+describe("slide-over failed-save baseline contract", () => {
+ it("stays dirty vs frozen baseline after optimistic parent absorbs the edit", () => {
+ const baseline = row({ woNumber: "1001" });
+ const draft = row({ woNumber: "2002" });
+ const optimisticParent = row({ woNumber: "2002" });
+
+ const result = afterFailedOptimisticSave({
+ baseline,
+ draft,
+ optimisticParent,
+ rolledBackParent: baseline,
+ });
+
+ // Bug the review caught: comparing against optimistic parent clears dirty.
+ expect(result.dirtyAgainstOptimistic).toBe(false);
+ expect(result.dirtyAgainstBaseline).toBe(true);
+ expect(result.dirtyAgainstRolledBack).toBe(true);
+ expect(result.canRetry).toBe(true);
+ expect(result.retryPatch).toEqual({ woNumber: "2002" });
+ });
+});
diff --git a/src/test/domain/work-orders/utils/slide-over-save-contract.test.ts b/src/test/domain/work-orders/utils/slide-over-save-contract.test.ts
new file mode 100644
index 00000000..31828684
--- /dev/null
+++ b/src/test/domain/work-orders/utils/slide-over-save-contract.test.ts
@@ -0,0 +1,76 @@
+import { describe, expect, it, vi } from "vitest";
+
+/**
+ * Mirrors WorkOrderSlideOver commit: exit edit mode only after a successful save
+ * (or when there is nothing to persist).
+ */
+function commitSlideOverEdit(input: {
+ patchKeys: number;
+ onSave: (options: { onSuccess?: () => void }) => void;
+ exitEdit: () => void;
+ clearUnsaved: () => void;
+}) {
+ if (input.patchKeys === 0) {
+ input.exitEdit();
+ input.clearUnsaved();
+ return;
+ }
+
+ input.onSave({
+ onSuccess: () => {
+ input.exitEdit();
+ input.clearUnsaved();
+ },
+ });
+}
+
+describe("slide-over commit save contract", () => {
+ it("exits edit mode immediately when there is no patch", () => {
+ const exitEdit = vi.fn();
+ const clearUnsaved = vi.fn();
+ const onSave = vi.fn();
+
+ commitSlideOverEdit({ patchKeys: 0, onSave, exitEdit, clearUnsaved });
+
+ expect(onSave).not.toHaveBeenCalled();
+ expect(exitEdit).toHaveBeenCalledOnce();
+ expect(clearUnsaved).toHaveBeenCalledOnce();
+ });
+
+ it("keeps edit mode when save fails (onSuccess never runs)", () => {
+ const exitEdit = vi.fn();
+ const clearUnsaved = vi.fn();
+
+ commitSlideOverEdit({
+ patchKeys: 1,
+ onSave: () => {
+ /* rejected — do not call onSuccess */
+ },
+ exitEdit,
+ clearUnsaved,
+ });
+
+ expect(exitEdit).not.toHaveBeenCalled();
+ expect(clearUnsaved).not.toHaveBeenCalled();
+ });
+
+ it("exits edit mode only from onSuccess after a rejected-then-retry path would not", () => {
+ const exitEdit = vi.fn();
+ const clearUnsaved = vi.fn();
+ let success: (() => void) | undefined;
+
+ commitSlideOverEdit({
+ patchKeys: 2,
+ onSave: (options) => {
+ success = options.onSuccess;
+ },
+ exitEdit,
+ clearUnsaved,
+ });
+
+ expect(exitEdit).not.toHaveBeenCalled();
+ success?.();
+ expect(exitEdit).toHaveBeenCalledOnce();
+ expect(clearUnsaved).toHaveBeenCalledOnce();
+ });
+});
diff --git a/src/test/domain/work-orders/utils/table-filter-utils.test.ts b/src/test/domain/work-orders/utils/table-filter-utils.test.ts
index 4ff5dc93..8c9b17c3 100644
--- a/src/test/domain/work-orders/utils/table-filter-utils.test.ts
+++ b/src/test/domain/work-orders/utils/table-filter-utils.test.ts
@@ -26,6 +26,7 @@ function makeRow(overrides: Partial = {}): WorkOrderTableRow
dispatcherId: "u1",
dispatcherName: "Alice",
scheduledOn: "2026-06-23",
+ completedDate: "",
targetWeek: "",
dayGroup: "06/23",
dayLabel: "TUE · 06/23",
diff --git a/src/test/domain/work-orders/utils/wizard-date-utils.test.ts b/src/test/domain/work-orders/utils/wizard-date-utils.test.ts
index 3ee9c66a..8f63bbbf 100644
--- a/src/test/domain/work-orders/utils/wizard-date-utils.test.ts
+++ b/src/test/domain/work-orders/utils/wizard-date-utils.test.ts
@@ -1,8 +1,10 @@
-import { describe, expect, it } from "vitest";
+import { afterEach, describe, expect, it, vi } from "vitest";
import {
fmtDateRange,
fmtShortDate,
+ pad2,
rangeDayCount,
+ todayIso,
} from "@/domain/work-orders/utils/wizard-date-utils";
describe("fmtShortDate", () => {
@@ -29,3 +31,27 @@ describe("rangeDayCount", () => {
expect(rangeDayCount("2026-06-08", "2026-06-08")).toBe(1);
});
});
+
+describe("todayIso", () => {
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("uses local calendar day, not UTC ISO date, near midnight", () => {
+ // 02:30 UTC is still the previous local evening in Americas (UTC−N).
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-08-11T02:30:00.000Z"));
+
+ const now = new Date();
+ const expectedLocal = `${now.getFullYear()}-${pad2(now.getMonth() + 1)}-${pad2(now.getDate())}`;
+ const utcSlice = now.toISOString().slice(0, 10);
+
+ expect(todayIso()).toBe(expectedLocal);
+ expect(utcSlice).toBe("2026-08-11");
+
+ if (now.getTimezoneOffset() > 0) {
+ expect(todayIso()).toBe("2026-08-10");
+ expect(todayIso()).not.toBe(utcSlice);
+ }
+ });
+});
diff --git a/src/test/domain/work-orders/utils/work-order-media-limits.test.ts b/src/test/domain/work-orders/utils/work-order-media-limits.test.ts
index 932e74e4..2ae1f827 100644
--- a/src/test/domain/work-orders/utils/work-order-media-limits.test.ts
+++ b/src/test/domain/work-orders/utils/work-order-media-limits.test.ts
@@ -3,6 +3,7 @@ import {
canDeleteWorkOrderMedia,
isWorkOrderDocumentsReadOnly,
isWorkOrderFileTooLarge,
+ isWorkOrderMediaTypeAllowed,
MAX_WORK_ORDER_MEDIA_BYTES,
} from "@/domain/work-orders/utils/work-order-media-limits";
@@ -23,4 +24,17 @@ describe("work-order-media-limits", () => {
expect(isWorkOrderFileTooLarge({ size: MAX_WORK_ORDER_MEDIA_BYTES + 1 } as File)).toBe(true);
expect(isWorkOrderFileTooLarge({ size: 10 } as File)).toBe(false);
});
+
+ it("allows only JPG PNG MP4 MOV by MIME or extension", () => {
+ expect(isWorkOrderMediaTypeAllowed({ name: "a.jpg", type: "image/jpeg" } as File)).toBe(true);
+ expect(isWorkOrderMediaTypeAllowed({ name: "a.png", type: "image/png" } as File)).toBe(true);
+ expect(isWorkOrderMediaTypeAllowed({ name: "a.mp4", type: "video/mp4" } as File)).toBe(true);
+ expect(isWorkOrderMediaTypeAllowed({ name: "a.mov", type: "video/quicktime" } as File)).toBe(
+ true,
+ );
+ expect(isWorkOrderMediaTypeAllowed({ name: "a.gif", type: "image/gif" } as File)).toBe(false);
+ expect(isWorkOrderMediaTypeAllowed({ name: "a.pdf", type: "application/pdf" } as File)).toBe(
+ false,
+ );
+ });
});
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");
+ });
+});
diff --git a/tmp/pr-descriptions/pr8-duplicate-dialog.md b/tmp/pr-descriptions/pr8-duplicate-dialog.md
new file mode 100644
index 00000000..242e05f7
--- /dev/null
+++ b/tmp/pr-descriptions/pr8-duplicate-dialog.md
@@ -0,0 +1,26 @@
+## Jira
+
+- [SH-189](https://luby-us.atlassian.net/browse/SH-189)
+
+## QA residual
+
+- P0 done: duplicate dialog also on **wizard create** (not only table cell).
+- P1 polish: Cancel / View Existing copy tweaks.
+
+## Summary
+
+- Show a confirmation dialog when inline WO number editing **or wizard create** detects a duplicate instead of navigating immediately to the existing work order.
+- Dialog title: **Duplicate Work Order Number**; actions: **View existing** (opens the existing WO via slide-over route) and **Dismiss** (closes the dialog and keeps the current row editable).
+- Duplicate scan logic shared via `find-duplicate-wo-number-row`.
+
+## Test plan
+
+- [ ] Edit a WO number in the table to match an existing row → dialog opens with the duplicate number in the message.
+- [ ] Click **Dismiss** → dialog closes; edited row keeps its original number.
+- [ ] Repeat duplicate entry → click **View existing** → navigates to `/workorders/{id}` for the existing row.
+- [ ] Wizard create with duplicate WO number → same dialog; **View existing** closes wizard and opens `/workorders/{id}`.
+
+## Out of scope
+
+- Server-side uniqueness (422) handling
+- SH provisional numbering