diff --git a/docs/adr/0001-query-broadcast-client.md b/docs/adr/0001-query-broadcast-client.md
index c990e584..4ced20f2 100644
--- a/docs/adr/0001-query-broadcast-client.md
+++ b/docs/adr/0001-query-broadcast-client.md
@@ -61,8 +61,16 @@ close to zero application code (~20 lines) to adopt.
isolated to one setup file and does not touch domain/query-key code.
- **Risk — package lifecycle**: the dependency is explicitly "experimental" in
the TanStack ecosystem; its API may change or be deprecated between minor
- versions. We pin a specific version in `package.json` and re-evaluate on
- every TanStack Query upgrade.
+ versions. `@tanstack/query-broadcast-client-experimental`,
+ `@tanstack/react-query`, and `@tanstack/react-query-devtools` are pinned to
+ the **exact same version** in `package.json` (no `^` range). All three
+ resolve to the identical `@tanstack/query-core` version at that pin, which
+ keeps the `QueryClient` type used by `setupQueryBroadcast` structurally
+ identical to the one constructed in `query-client.ts` — a caret range lets
+ npm resolve the broadcast client and React Query against two different
+ `query-core` versions independently, which breaks `QueryClient` type
+ identity (TS2322) even though both packages build fine in isolation. Bump
+ all three together and re-evaluate on every TanStack Query upgrade.
- **Risk — same-origin only**: `BroadcastChannel` only syncs tabs on the same
origin; it does not sync across different users/sessions or devices. This is
acceptable for the current requirement (single user, multiple tabs).
diff --git a/package-lock.json b/package-lock.json
index d25ee989..54d96afc 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -21,8 +21,9 @@
"@hookform/resolvers": "^5.4.0",
"@mui/icons-material": "^9.2.0",
"@mui/material": "^9.2.0",
- "@tanstack/react-query": "^5.101.2",
- "@tanstack/react-query-devtools": "^5.101.2",
+ "@tanstack/query-broadcast-client-experimental": "5.101.2",
+ "@tanstack/react-query": "5.101.2",
+ "@tanstack/react-query-devtools": "5.101.2",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
@@ -2332,6 +2333,20 @@
"vite": "^5.2.0 || ^6 || ^7 || ^8"
}
},
+ "node_modules/@tanstack/query-broadcast-client-experimental": {
+ "version": "5.101.2",
+ "resolved": "https://registry.npmjs.org/@tanstack/query-broadcast-client-experimental/-/query-broadcast-client-experimental-5.101.2.tgz",
+ "integrity": "sha512-BFJp1HqDjUY3x51zzgfB2scGhg1nw2V8Y6+aoSA8TEWYh0oI5kYx1z+37fzlws2uaT7ObjCz1PXzzftE6xpmhw==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/query-core": "5.101.2",
+ "broadcast-channel": "^7.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
"node_modules/@tanstack/query-core": {
"version": "5.101.2",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.2.tgz",
@@ -3214,6 +3229,30 @@
"node": ">=8"
}
},
+ "node_modules/broadcast-channel": {
+ "version": "7.3.0",
+ "resolved": "https://registry.npmjs.org/broadcast-channel/-/broadcast-channel-7.3.0.tgz",
+ "integrity": "sha512-UHPhLBQKfQ8OmMFMpmPfO5dRakyA1vsfiDGWTYNvChYol65tbuhivPEGgZZiuetorvExdvxaWiBy/ym1Ty08yA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "7.28.6",
+ "oblivious-set": "2.0.0",
+ "p-queue": "6.6.2",
+ "unload": "2.4.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/pubkey"
+ }
+ },
+ "node_modules/broadcast-channel/node_modules/@babel/runtime": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
+ "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/browserslist": {
"version": "4.28.6",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz",
@@ -5345,6 +5384,15 @@
"node": ">=0.10.0"
}
},
+ "node_modules/oblivious-set": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/oblivious-set/-/oblivious-set-2.0.0.tgz",
+ "integrity": "sha512-QOUH5Xrsced9fKXaQTjWoDGKeS/Or7E2jB0FN63N4mkAO4qJdB7WR7e6qWAOHM5nk25FJ8TGjhP7DH4l6vFVLg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=16"
+ }
+ },
"node_modules/obug": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz",
@@ -5393,6 +5441,15 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/p-finally": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz",
+ "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
@@ -5425,6 +5482,40 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/p-queue": {
+ "version": "6.6.2",
+ "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz",
+ "integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==",
+ "license": "MIT",
+ "dependencies": {
+ "eventemitter3": "^4.0.4",
+ "p-timeout": "^3.2.0"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-queue/node_modules/eventemitter3": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
+ "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
+ "license": "MIT"
+ },
+ "node_modules/p-timeout": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz",
+ "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==",
+ "license": "MIT",
+ "dependencies": {
+ "p-finally": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -6401,6 +6492,15 @@
"devOptional": true,
"license": "MIT"
},
+ "node_modules/unload": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/unload/-/unload-2.4.1.tgz",
+ "integrity": "sha512-IViSAm8Z3sRBYA+9wc0fLQmU9Nrxb16rcDmIiR6Y9LJSZzI7QY5QsDhqPpKOjAn0O9/kfK1TfNEMMAGPTIraPw==",
+ "license": "Apache-2.0",
+ "funding": {
+ "url": "https://github.com/sponsors/pubkey"
+ }
+ },
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
diff --git a/package.json b/package.json
index 14f2dcfe..e3204f6b 100644
--- a/package.json
+++ b/package.json
@@ -54,9 +54,9 @@
"@hookform/resolvers": "^5.4.0",
"@mui/icons-material": "^9.2.0",
"@mui/material": "^9.2.0",
- "@tanstack/query-broadcast-client-experimental": "^5.101.2",
- "@tanstack/react-query": "^5.101.2",
- "@tanstack/react-query-devtools": "^5.101.2",
+ "@tanstack/query-broadcast-client-experimental": "5.101.2",
+ "@tanstack/react-query": "5.101.2",
+ "@tanstack/react-query-devtools": "5.101.2",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
diff --git a/scripts/governance-baseline.json b/scripts/governance-baseline.json
index 401e1e70..18c4ae08 100644
--- a/scripts/governance-baseline.json
+++ b/scripts/governance-baseline.json
@@ -3,11 +3,6 @@
"purpose": "Frozen grandfathered debt inventory for the frontend governance checks. New entries and cap increases fail the governance comparison. Existing caps may only decrease and entries must be removed when compliant.",
"maxFileLines": 500,
"godfileDebt": [
- {
- "path": "src/app/(protected)/workorders/[id].tsx",
- "maxLines": 797,
- "reason": "Work-order detail route page; legacy godfile targeted for decomposition into focused state components."
- },
{
"path": "src/app/(protected)/vendors/index.tsx",
"maxLines": 612,
diff --git a/src/app/(protected)/workorders/[id].tsx b/src/app/(protected)/workorders/[id].tsx
index 3c7f2b82..945bd3b3 100644
--- a/src/app/(protected)/workorders/[id].tsx
+++ b/src/app/(protected)/workorders/[id].tsx
@@ -1,310 +1,26 @@
-import { useMemo, useState } from "react";
-import { Link as RouterLink, useNavigate, useParams } from "react-router";
-import {
- Accordion,
- AccordionDetails,
- AccordionSummary,
- Alert,
- Box,
- Breadcrumbs,
- Button,
- Chip,
- CircularProgress,
- Dialog,
- DialogActions,
- DialogContent,
- DialogTitle,
- Link,
- Menu,
- MenuItem,
- Paper,
- Stack,
- Tab,
- Tabs,
- Table,
- TableBody,
- TableCell,
- TableContainer,
- TableHead,
- TableRow,
- TextField,
- Typography,
-} from "@mui/material";
-import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
+import { useNavigate, useParams } from "react-router";
+import { Box } from "@mui/material";
import { QueryErrorPanel } from "@/components/ui/query-error-panel";
import { DetailPageSkeleton } from "@/app/(protected)/workorders/_components/loading/detail-page-skeleton";
import { DispatchCreateModal } from "./_components/dispatch-create-modal";
import { DispatchDetailModal } from "./_components/dispatch-detail-modal";
-import type { WorkOrderAuditEntry, WorkOrderComment } from "@/domain/work-orders/types/work-order";
-import { WORK_ORDER_STATUS_OPTIONS } from "@/domain/work-orders/types/work-order";
-import { useWorkOrderDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
+import { WorkOrderHeaderSection } from "@/app/(protected)/workorders/_components/view/work-order-header-section";
+import { WorkOrderDetailsPanel } from "@/app/(protected)/workorders/_components/view/work-order-details-panel";
import {
- useAddWorkOrderComment,
- useChangeWorkOrderAssignment,
- useChangeWorkOrderStatus,
- useUpdateWorkOrderVendor,
-} from "@/domain/work-orders/use-cases/use-work-order-mutations";
-import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
-import { vendorsApi } from "@/domain/vendors/api/vendors-api";
-import type { VendorPreferredContact } from "@/domain/vendors/types/vendor";
-import { formatDateTime, timeSince, waitTimeColor } from "@/lib/time-utils";
-
-const STATUS_COLORS: Record<
- string,
- "default" | "primary" | "secondary" | "error" | "info" | "success" | "warning"
-> = {
- Open: "primary",
- "In Progress": "info",
- "On Hold": "warning",
- Done: "success",
- Cancelled: "default",
-};
-
-type ActivityTab = "customer" | "internal" | "audit";
-
-interface VendorEditorState {
- vendorId: string | number;
- vendorName: string;
- contactName: string;
- preferredContact: VendorPreferredContact;
- phone: string;
- email: string;
- notes: string;
-}
-
-function formatPhoneMask(input: string): string {
- const digits = input.replace(/\D/g, "").slice(0, 10);
- if (digits.length <= 3) return digits;
- if (digits.length <= 6) return `(${digits.slice(0, 3)}) ${digits.slice(3)}`;
- return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
-}
-
-function isVendorEditorValid(editor: VendorEditorState): boolean {
- const phoneValid = editor.phone === "" || /^\(\d{3}\) \d{3}-\d{4}$/.test(editor.phone);
- const emailValid = editor.email === "" || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(editor.email);
- return (
- phoneValid && emailValid && editor.notes.length <= 2000 && editor.contactName.length <= 200
- );
-}
-
-function CommentTimeline({ comments }: { comments: WorkOrderComment[] }) {
- const sorted = useMemo(
- () =>
- [...comments].sort(
- (a, b) => new Date(a.createdDate).getTime() - new Date(b.createdDate).getTime(),
- ),
- [comments],
- );
-
- if (!sorted.length) {
- return (
-
- No comments yet.
-
- );
- }
-
- return (
-
- {sorted.map((comment) => (
-
-
-
- {comment.userName || comment.commenter || "System"}
-
-
- {comment.createdDate ? formatDateTime(comment.createdDate) : ""}
-
-
- {comment.commenttext}
-
- ))}
-
- );
-}
-
-function AuditTimeline({ entries }: { entries: WorkOrderAuditEntry[] }) {
- const sorted = useMemo(
- () =>
- [...entries].sort(
- (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
- ),
- [entries],
- );
-
- if (!sorted.length) {
- return (
-
- No changes recorded.
-
- );
- }
-
- return (
-
- {sorted.map((entry) => (
-
-
-
- {entry.userName || "System"}
-
-
- {entry.createdAt ? formatDateTime(entry.createdAt) : ""}
-
-
-
- {entry.fieldName} {entry.oldValue} → {entry.newValue}
-
-
- ))}
-
- );
-}
+ WorkOrderDescriptionAccordion,
+ WorkOrderInfoAccordions,
+} from "@/app/(protected)/workorders/_components/view/work-order-info-accordions";
+import { WorkOrderDispatchesAccordion } from "@/app/(protected)/workorders/_components/view/work-order-dispatches-accordion";
+import { WorkOrderActivityPanel } from "@/app/(protected)/workorders/_components/view/work-order-activity-panel";
+import { WorkOrderVendorEditorDialog } from "@/app/(protected)/workorders/_components/view/work-order-vendor-editor-dialog";
+import { useWorkOrderViewController } from "@/app/(protected)/workorders/_hooks/use-work-order-view-controller";
export default function WorkOrderViewPage() {
const { id } = useParams();
const navigate = useNavigate();
const workOrderId = id ?? "";
-
- const { data: wo, isLoading, error, refetch } = useWorkOrderDetail(workOrderId);
- const { data: users = [] } = useWorkOrderUsers();
- const statusMutation = useChangeWorkOrderStatus(workOrderId);
- const assignmentMutation = useChangeWorkOrderAssignment(workOrderId);
- const commentMutation = useAddWorkOrderComment(workOrderId);
- const vendorMutation = useUpdateWorkOrderVendor(workOrderId);
-
- const [activeTab, setActiveTab] = useState("customer");
- const [commentText, setCommentText] = useState("");
- const [dispatchOpen, setDispatchOpen] = useState(false);
- const [dispatchDetailId, setDispatchDetailId] = useState(null);
- const [statusAnchor, setStatusAnchor] = useState(null);
- const [assignAnchor, setAssignAnchor] = useState(null);
- const [vendorEditor, setVendorEditor] = useState(null);
- const [vendorEditorLoading, setVendorEditorLoading] = useState(false);
- const [vendorEditorError, setVendorEditorError] = 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;
- }
- if (activeTab !== "customer" && activeTab !== "internal") {
- return;
- }
-
- commentMutation.mutate(
- { text: commentText.trim(), commentType: activeTab },
- { onSuccess: () => setCommentText("") },
- );
- };
-
- const openVendorEditor = async (
- event: React.MouseEvent,
- vendorId: string | number,
- vendorName: string,
- ) => {
- event.stopPropagation();
- setVendorEditorError("");
- setVendorEditorLoading(true);
- setVendorEditor({
- vendorId,
- vendorName,
- contactName: "",
- preferredContact: "Phone",
- phone: "",
- email: "",
- notes: "",
- });
-
- try {
- const vendor = await vendorsApi.getById(vendorId);
- setVendorEditor({
- vendorId,
- vendorName: vendor.companyName || vendorName,
- contactName: vendor.contactName,
- preferredContact: vendor.preferredContact,
- phone: vendor.phone,
- email: vendor.email,
- notes: vendor.notes,
- });
- } catch (vendorError) {
- setVendorEditorError(
- vendorError instanceof Error ? vendorError.message : "Unable to load vendor details",
- );
- } finally {
- setVendorEditorLoading(false);
- }
- };
-
- const saveVendorEditor = () => {
- if (!vendorEditor || !isVendorEditorValid(vendorEditor)) return;
- const numericWorkOrderId = Number(workOrderId);
- if (!Number.isInteger(numericWorkOrderId) || numericWorkOrderId <= 0) {
- setVendorEditorError("A valid work order is required");
- return;
- }
-
- vendorMutation.mutate(
- {
- vendorId: vendorEditor.vendorId,
- payload: {
- workOrderId: numericWorkOrderId,
- contactName: vendorEditor.contactName.trim(),
- preferredContact: vendorEditor.preferredContact,
- phone: vendorEditor.phone,
- email: vendorEditor.email.trim(),
- notes: vendorEditor.notes.trim(),
- },
- },
- {
- onSuccess: () => {
- setVendorEditor(null);
- void refetch();
- },
- onError: (vendorError) => setVendorEditorError(vendorError.message),
- },
- );
- };
+ const controller = useWorkOrderViewController(workOrderId);
+ const { wo, isLoading, error, refetch, vendorEditor } = controller;
if (isLoading && !wo) {
return ;
@@ -327,444 +43,69 @@ export default function WorkOrderViewPage() {
return (
-
-
- Work Orders
-
-
- {wo.internalWONumber}
-
-
- controller.statusMutation.mutate(status)}
+ onDispatch={() => controller.setDispatchOpen(true)}
+ onEdit={() => navigate(`/workorders/edit/${id}`)}
+ onBack={() => navigate("/workorders")}
+ />
+ controller.assignmentMutation.mutate({ userId, userName })}
+ />
+
+ {
+ event.stopPropagation();
+ void vendorEditor.openVendorEditor(vendorId, vendorName);
}}
- >
-
-
- {wo.title}
-
- setStatusAnchor(event.currentTarget)}
- className="w-fit cursor-pointer"
- />
-
-
-
-
-
-
-
-
-
-
-
- Details
-
-
-
-
-
-
-
-
- Assigned To
-
-
-
-
-
-
-
-
-
- }>
- Classification
-
-
-
-
-
-
-
-
-
-
- }>
- Schedule
-
-
-
-
-
-
-
-
-
-
-
- }>
- Financial
-
-
-
-
-
-
-
-
-
-
- }>
- Dispatches ({wo.dispatches.length})
-
-
- {wo.dispatches.length ? (
-
-
-
-
- Dispatch #
- Vendor
- NTE
- Status
- Date
- Wait
- Actions
-
-
-
- {wo.dispatches.map((dispatch) => {
- const waitColor = waitTimeColor(dispatch.dispatchedAt);
- return (
- setDispatchDetailId(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.
-
- )}
-
-
-
- }>
- Description
-
-
- {wo.description || "No description provided."}
-
-
-
-
- Activity
-
- setActiveTab(value)}
- className="mb-3"
- >
-
-
-
-
-
- {activeTab === "customer" && }
- {activeTab === "internal" && }
- {activeTab === "audit" && }
-
- {activeTab === "internal" && (
-
- setCommentText(event.target.value)}
- placeholder="Type a comment..."
- disabled={commentMutation.isPending}
- />
-
-
- )}
-
- {dispatchOpen && (
+ />
+
+
+ {controller.dispatchOpen && (
setDispatchOpen(false)}
+ onClose={() => controller.setDispatchOpen(false)}
onDispatched={() => void refetch()}
/>
)}
- {dispatchDetailId != null && (
+ {controller.dispatchDetailId != null && (
setDispatchDetailId(null)}
+ onClose={() => controller.setDispatchDetailId(null)}
onUpdated={() => void refetch()}
/>
)}
-
-
- );
-}
-
-function Field({ label, value }: { label: string; value: string }) {
- return (
-
-
- {label}
-
- {value}
+
+ vendorEditor.setVendorEditor((current) => (current ? { ...current, ...patch } : current))
+ }
+ onSave={vendorEditor.saveVendorEditor}
+ />
);
}
diff --git a/src/app/(protected)/workorders/_components/dispatch-create-modal.tsx b/src/app/(protected)/workorders/_components/dispatch-create-modal.tsx
deleted file mode 100644
index 30838735..00000000
--- a/src/app/(protected)/workorders/_components/dispatch-create-modal.tsx
+++ /dev/null
@@ -1,391 +0,0 @@
-import { useMemo, useState } from "react";
-import {
- Box,
- Chip,
- Dialog,
- DialogActions,
- DialogContent,
- DialogTitle,
- IconButton,
- MenuItem,
- Radio,
- Skeleton,
- Stack,
- TextField,
- Typography,
-} from "@mui/material";
-import CloseIcon from "@mui/icons-material/Close";
-import { Button } from "@/components/ui/button";
-import { useWorkOrdersList } from "@/domain/work-orders/use-cases/use-work-orders-list";
-import {
- useTaskListTemplates,
- useWorkOrderVendors,
-} from "@/domain/work-orders/use-cases/use-work-order-detail";
-import { useDispatchToVendor } from "@/domain/work-orders/use-cases/use-work-order-mutations";
-import type { SelectedWorkOrderRef, WorkOrderDetail } from "@/domain/work-orders/types/work-order";
-import { guardDialogClose } from "@/lib/dialog-guard";
-
-interface DispatchCreateModalProps {
- workOrder: WorkOrderDetail;
- onClose: () => void;
- onDispatched: () => void;
-}
-
-export function DispatchCreateModal({
- workOrder,
- onClose,
- onDispatched,
-}: DispatchCreateModalProps) {
- const [selectedVendorId, setSelectedVendorId] = useState(null);
- const [nteAmount, setNteAmount] = useState(workOrder.vendorNTE || "");
- const [scheduledDate, setScheduledDate] = useState("");
- const [description, setDescription] = useState(workOrder.description || "");
- const [vendorSearch, setVendorSearch] = useState("");
- const [selectedTemplateId, setSelectedTemplateId] = useState("");
- const [customItems, setCustomItems] = useState([]);
- const [newCustomItem, setNewCustomItem] = useState("");
- const [selectedWOs, setSelectedWOs] = useState([
- {
- id: workOrder.id,
- number: workOrder.internalWONumber,
- title: workOrder.title,
- },
- ]);
- const [woSearch, setWoSearch] = useState("");
-
- const { data: vendors = [], isLoading: isLoadingVendors } = useWorkOrderVendors(
- workOrder.trade,
- workOrder.locationZip,
- );
- const { data: taskTemplates = [], isLoading: isLoadingTemplates } = useTaskListTemplates();
- const dispatchMutation = useDispatchToVendor(workOrder.id);
- const isPending = dispatchMutation.isPending;
- const handleClose = guardDialogClose(isPending, onClose);
-
- const { data: siteWorkOrdersData } = useWorkOrdersList({
- search: "",
- page: 1,
- pageSize: 500,
- sortBy: "number",
- sortDir: "asc",
- locationId: workOrder.locationId,
- });
-
- const siteWorkOrders = useMemo(
- () =>
- (siteWorkOrdersData?.items ?? []).filter((item) => String(item.id) !== String(workOrder.id)),
- [siteWorkOrdersData?.items, workOrder.id],
- );
-
- const filteredVendors = useMemo(
- () =>
- vendors.filter(
- (vendor) =>
- vendor.companyName.toLowerCase().includes(vendorSearch.toLowerCase()) ||
- vendor.contactName.toLowerCase().includes(vendorSearch.toLowerCase()) ||
- vendor.tradeSpecialties.toLowerCase().includes(vendorSearch.toLowerCase()),
- ),
- [vendors, vendorSearch],
- );
-
- const filteredSiteWOs = useMemo(
- () =>
- siteWorkOrders
- .filter((item) => !selectedWOs.some((selected) => String(selected.id) === String(item.id)))
- .filter(
- (item) =>
- !woSearch ||
- item.woNumber.includes(woSearch) ||
- item.title.toLowerCase().includes(woSearch.toLowerCase()),
- )
- .slice(0, 10),
- [siteWorkOrders, selectedWOs, woSearch],
- );
-
- const handleSubmit = () => {
- if (!selectedVendorId) {
- return;
- }
-
- dispatchMutation.mutate(
- {
- workOrderIds: selectedWOs.map((wo) => wo.id),
- vendorIds: [selectedVendorId],
- nteAmount: nteAmount ? parseFloat(nteAmount) : null,
- scheduledDate: scheduledDate || null,
- description,
- taskListTemplateId: selectedTemplateId ? parseInt(selectedTemplateId, 10) : null,
- customChecklistItems: customItems.length > 0 ? customItems : null,
- },
- {
- onSuccess: () => {
- onDispatched();
- onClose();
- },
- },
- );
- };
-
- return (
-
- );
-}
-
-function PaperList({ children }: { children: React.ReactNode }) {
- return (
- {children}
- );
-}
diff --git a/src/app/(protected)/workorders/_components/dispatch-create-modal/dispatch-create-checklist-fields.tsx b/src/app/(protected)/workorders/_components/dispatch-create-modal/dispatch-create-checklist-fields.tsx
new file mode 100644
index 00000000..8b9aa5e8
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/dispatch-create-modal/dispatch-create-checklist-fields.tsx
@@ -0,0 +1,84 @@
+import { Chip, MenuItem, Skeleton, Stack, TextField } from "@mui/material";
+import type { TaskListTemplate } from "@/domain/work-orders/types/work-order";
+
+interface DispatchCreateChecklistFieldsProps {
+ taskTemplates: TaskListTemplate[];
+ isLoadingTemplates: boolean;
+ selectedTemplateId: string;
+ onTemplateChange: (id: string) => void;
+ isPending: boolean;
+ customItems: string[];
+ onRemoveCustomItem: (index: number) => void;
+ newCustomItem: string;
+ onNewCustomItemChange: (value: string) => void;
+ onAddCustomItem: () => void;
+}
+
+export function DispatchCreateChecklistFields({
+ taskTemplates,
+ isLoadingTemplates,
+ selectedTemplateId,
+ onTemplateChange,
+ isPending,
+ customItems,
+ onRemoveCustomItem,
+ newCustomItem,
+ onNewCustomItemChange,
+ onAddCustomItem,
+}: DispatchCreateChecklistFieldsProps) {
+ return (
+ <>
+ {isLoadingTemplates ? (
+
+ ) : (
+ onTemplateChange(event.target.value)}
+ fullWidth
+ disabled={isPending}
+ >
+
+ {taskTemplates.map((template) => (
+
+ ))}
+
+ )}
+
+ {customItems.length > 0 && (
+
+ {customItems.map((item, index) => (
+ onRemoveCustomItem(index)}
+ />
+ ))}
+
+ )}
+
+ onNewCustomItemChange(event.target.value)}
+ onKeyDown={(event) => {
+ if (event.key === "Enter" && newCustomItem.trim()) {
+ event.preventDefault();
+ onAddCustomItem();
+ }
+ }}
+ placeholder="Add custom checklist item..."
+ fullWidth
+ size="small"
+ />
+ >
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/dispatch-create-modal/dispatch-create-details-fields.tsx b/src/app/(protected)/workorders/_components/dispatch-create-modal/dispatch-create-details-fields.tsx
new file mode 100644
index 00000000..fa265c4f
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/dispatch-create-modal/dispatch-create-details-fields.tsx
@@ -0,0 +1,38 @@
+import { Stack, TextField } from "@mui/material";
+
+interface DispatchCreateDetailsFieldsProps {
+ nteAmount: string;
+ onNteAmountChange: (value: string) => void;
+ scheduledDate: string;
+ onScheduledDateChange: (value: string) => void;
+}
+
+export function DispatchCreateDetailsFields({
+ nteAmount,
+ onNteAmountChange,
+ scheduledDate,
+ onScheduledDateChange,
+}: DispatchCreateDetailsFieldsProps) {
+ return (
+
+ onNteAmountChange(event.target.value)}
+ fullWidth
+ slotProps={{
+ htmlInput: { step: "0.01", min: 0 },
+ }}
+ />
+ onScheduledDateChange(event.target.value)}
+ slotProps={{ inputLabel: { shrink: true } }}
+ fullWidth
+ />
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/dispatch-create-modal/dispatch-create-vendor-section.tsx b/src/app/(protected)/workorders/_components/dispatch-create-modal/dispatch-create-vendor-section.tsx
new file mode 100644
index 00000000..29133854
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/dispatch-create-modal/dispatch-create-vendor-section.tsx
@@ -0,0 +1,110 @@
+import { Box, Radio, Skeleton, Stack, TextField, Typography } from "@mui/material";
+import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
+
+interface DispatchCreateVendorSectionProps {
+ vendors: VendorDropdownItem[];
+ isLoading: boolean;
+ vendorSearch: string;
+ selectedVendorId: string | number | null;
+ onSearchChange: (value: string) => void;
+ onSelect: (vendorId: string | number) => void;
+}
+
+function VendorLine({ label }: { label: string }) {
+ return (
+
+ {label}
+
+ );
+}
+
+function VendorOption({
+ vendor,
+ isSelected,
+ onSelect,
+}: {
+ vendor: VendorDropdownItem;
+ isSelected: boolean;
+ onSelect: () => void;
+}) {
+ return (
+
+
+
+
+ {vendor.companyName}
+
+ {Boolean(vendor.contactName) && }
+ {Boolean(vendor.tradeSpecialties) && }
+ {Boolean(vendor.address) && }
+
+ {vendor.distanceMiles != null && (
+
+ {vendor.distanceMiles} miles
+
+ )}
+
+ );
+}
+
+export function DispatchCreateVendorSection({
+ vendors,
+ isLoading,
+ vendorSearch,
+ selectedVendorId,
+ onSearchChange,
+ onSelect,
+}: DispatchCreateVendorSectionProps) {
+ return (
+
+
+ Select Vendor
+
+ onSearchChange(event.target.value)}
+ placeholder="Search vendors..."
+ fullWidth
+ className="mb-2"
+ />
+ {isLoading ? (
+
+ {Array.from({ length: 3 }, (_, i) => (
+
+ ))}
+
+ ) : vendors.length === 0 ? (
+
+ No vendors found
+
+ ) : (
+
+ {vendors.map((vendor) => (
+ onSelect(vendor.id)}
+ />
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/dispatch-create-modal/dispatch-create-work-orders-section.tsx b/src/app/(protected)/workorders/_components/dispatch-create-modal/dispatch-create-work-orders-section.tsx
new file mode 100644
index 00000000..c3026a26
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/dispatch-create-modal/dispatch-create-work-orders-section.tsx
@@ -0,0 +1,73 @@
+import { Box, Chip, Stack, TextField, Typography } from "@mui/material";
+import type { SelectedWorkOrderRef } from "@/domain/work-orders/types/work-order";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+
+interface DispatchCreateWorkOrdersSectionProps {
+ workOrderId: string | number;
+ selectedWOs: SelectedWorkOrderRef[];
+ siteWorkOrders: WorkOrderTableRow[];
+ filteredSiteWOs: WorkOrderTableRow[];
+ woSearch: string;
+ onWoSearchChange: (value: string) => void;
+ onAddWO: (item: WorkOrderTableRow) => void;
+ onRemoveWO: (id: string | number) => void;
+}
+
+export function DispatchCreateWorkOrdersSection({
+ workOrderId,
+ selectedWOs,
+ siteWorkOrders,
+ filteredSiteWOs,
+ woSearch,
+ onWoSearchChange,
+ onAddWO,
+ onRemoveWO,
+}: DispatchCreateWorkOrdersSectionProps) {
+ return (
+
+
+ Work Orders ({selectedWOs.length})
+
+
+ {selectedWOs.map((wo) => (
+ onRemoveWO(wo.id) : undefined}
+ />
+ ))}
+
+ {siteWorkOrders.length > 0 && (
+
+ onWoSearchChange(event.target.value)}
+ placeholder="Search to filter..."
+ fullWidth
+ className="mb-2"
+ />
+
+ {filteredSiteWOs.map((item) => (
+ onAddWO(item)}
+ >
+
+ {item.woNumber} — {item.title}
+
+
+ ))}
+
+
+ )}
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/dispatch-create-modal/index.tsx b/src/app/(protected)/workorders/_components/dispatch-create-modal/index.tsx
new file mode 100644
index 00000000..08fe407d
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/dispatch-create-modal/index.tsx
@@ -0,0 +1,114 @@
+import {
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogTitle,
+ IconButton,
+ TextField,
+} from "@mui/material";
+import CloseIcon from "@mui/icons-material/Close";
+import { Button } from "@/components/ui/button";
+import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order";
+import { useDispatchCreateController } from "@/app/(protected)/workorders/_hooks/use-dispatch-create-controller";
+import { DispatchCreateWorkOrdersSection } from "./dispatch-create-work-orders-section";
+import { DispatchCreateVendorSection } from "./dispatch-create-vendor-section";
+import { DispatchCreateChecklistFields } from "./dispatch-create-checklist-fields";
+import { DispatchCreateDetailsFields } from "./dispatch-create-details-fields";
+
+interface DispatchCreateModalProps {
+ workOrder: WorkOrderDetail;
+ onClose: () => void;
+ onDispatched: () => void;
+}
+
+export function DispatchCreateModal({
+ workOrder,
+ onClose,
+ onDispatched,
+}: DispatchCreateModalProps) {
+ const controller = useDispatchCreateController(workOrder, onClose, onDispatched);
+
+ return (
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-detail-body.tsx b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-detail-body.tsx
new file mode 100644
index 00000000..637f3a47
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-detail-body.tsx
@@ -0,0 +1,100 @@
+import { DialogContent, Skeleton } from "@mui/material";
+import type { DispatchDetail } from "@/domain/work-orders/types/work-order";
+import type { DispatchDetailController } from "@/app/(protected)/workorders/_hooks/use-dispatch-detail-controller";
+import { DispatchChecklistSection } from "./dispatch-checklist-section";
+import { DispatchDetailsGrid, DispatchDetailsView, DispatchEditForm } from "./dispatch-edit-form";
+import { DispatchSignoffsSection } from "./dispatch-signoffs-section";
+import { DispatchUpliftsSection } from "./dispatch-uplifts-section";
+import { DispatchVendorCommunication } from "./dispatch-vendor-communication";
+import { DispatchVerifyFooter } from "./dispatch-verify-footer";
+
+interface DispatchDetailBodyProps {
+ dispatch: DispatchDetail;
+ controller: DispatchDetailController;
+}
+
+export function DispatchDetailBody({ dispatch, controller }: DispatchDetailBodyProps) {
+ return (
+
+
+ {controller.editing ? (
+ controller.setEditing(false)}
+ isSaving={controller.updateDispatchMutation.isPending}
+ />
+ ) : (
+ controller.handleCancelDispatch(dispatch)}
+ />
+ )}
+
+
+ {(dispatch.checklistItems.length > 0 || !controller.isLocked) && (
+
+ controller.updateChecklistMutation.mutate({
+ itemId,
+ isCompleted,
+ completedBy: controller.userName,
+ })
+ }
+ onAddItem={(text) =>
+ controller.addChecklistMutation.mutate(text, {
+ onSuccess: () => controller.setNewChecklistItem(""),
+ })
+ }
+ />
+ )}
+
+
+
+ {controller.isLoadingUplifts ? (
+
+ ) : (
+ controller.setUpliftAction({ type: "approve", uplift })}
+ onDeny={(uplift) => controller.setUpliftAction({ type: "deny", uplift })}
+ />
+ )}
+
+
+
+ controller.verifyMutation.mutate(undefined)}
+ />
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/index.tsx b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/index.tsx
index 015edcbd..b5d0562c 100644
--- a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/index.tsx
+++ b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/index.tsx
@@ -1,15 +1,10 @@
-import { Dialog, DialogContent, DialogTitle, Skeleton } from "@mui/material";
+import { Dialog, DialogTitle } from "@mui/material";
import { SignatureCapture } from "@/components/common/signature-capture";
import { useDispatchDetailController } from "@/app/(protected)/workorders/_hooks/use-dispatch-detail-controller";
import { DispatchDetailSkeleton } from "@/app/(protected)/workorders/_components/loading/dispatch-detail-skeleton";
import { guardDialogClose } from "@/lib/dialog-guard";
-import { DispatchChecklistSection } from "./dispatch-checklist-section";
+import { DispatchDetailBody } from "./dispatch-detail-body";
import { DispatchDetailHeader } from "./dispatch-detail-header";
-import { DispatchDetailsGrid, DispatchDetailsView, DispatchEditForm } from "./dispatch-edit-form";
-import { DispatchSignoffsSection } from "./dispatch-signoffs-section";
-import { DispatchUpliftsSection } from "./dispatch-uplifts-section";
-import { DispatchVendorCommunication } from "./dispatch-vendor-communication";
-import { DispatchVerifyFooter } from "./dispatch-verify-footer";
import { UpliftDecisionDialog } from "./uplift-decision-dialog";
interface DispatchDetailModalProps {
@@ -53,97 +48,14 @@ export function DispatchDetailModal({
/>
-
-
- {controller.editing ? (
- controller.setEditing(false)}
- isSaving={controller.updateDispatchMutation.isPending}
- />
- ) : (
- controller.handleCancelDispatch(dispatch)}
- />
- )}
-
-
- {(dispatch.checklistItems.length > 0 || !controller.isLocked) && (
-
- controller.updateChecklistMutation.mutate({
- itemId,
- isCompleted,
- completedBy: controller.userName,
- })
- }
- onAddItem={(text) =>
- controller.addChecklistMutation.mutate(text, {
- onSuccess: () => controller.setNewChecklistItem(""),
- })
- }
- />
- )}
-
-
-
- {controller.isLoadingUplifts ? (
-
- ) : (
- controller.setUpliftAction({ type: "approve", uplift })}
- onDeny={(uplift) => controller.setUpliftAction({ type: "deny", uplift })}
- />
- )}
-
-
-
- controller.verifyMutation.mutate(undefined)}
- />
-
+
{controller.sigCaptureType != null && (
{
- if (!controller.addSignoffMutation.isPending) {
- controller.setSigCaptureType(null);
- }
- }}
+ isPending={controller.addSignoffMutation.isPending}
+ onClose={() => controller.setSigCaptureType(null)}
onSave={(signature, method, name) => {
controller.addSignoffMutation.mutate(
{
diff --git a/src/app/(protected)/workorders/_components/view/work-order-activity-panel.tsx b/src/app/(protected)/workorders/_components/view/work-order-activity-panel.tsx
new file mode 100644
index 00000000..2c153d5f
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/view/work-order-activity-panel.tsx
@@ -0,0 +1,73 @@
+import { Box, Button, Paper, Tab, Tabs, TextField, Typography } from "@mui/material";
+import type { WorkOrderAuditEntry, WorkOrderComment } from "@/domain/work-orders/types/work-order";
+import {
+ AuditTimeline,
+ CommentTimeline,
+} from "@/app/(protected)/workorders/_components/view/work-order-timelines";
+import type { WorkOrderActivityTab } from "@/app/(protected)/workorders/_hooks/use-work-order-view-controller";
+
+interface WorkOrderActivityPanelProps {
+ activeTab: WorkOrderActivityTab;
+ onTabChange: (tab: WorkOrderActivityTab) => void;
+ customerComments: WorkOrderComment[];
+ internalComments: WorkOrderComment[];
+ auditLog: WorkOrderAuditEntry[];
+ commentText: string;
+ onCommentTextChange: (text: string) => void;
+ isSubmittingComment: boolean;
+ onSubmitComment: (event: React.FormEvent) => void;
+}
+
+export function WorkOrderActivityPanel({
+ activeTab,
+ onTabChange,
+ customerComments,
+ internalComments,
+ auditLog,
+ commentText,
+ onCommentTextChange,
+ isSubmittingComment,
+ onSubmitComment,
+}: WorkOrderActivityPanelProps) {
+ return (
+
+
+ Activity
+
+ onTabChange(value)}
+ className="mb-3"
+ >
+
+
+
+
+
+ {activeTab === "customer" && }
+ {activeTab === "internal" && }
+ {activeTab === "audit" && }
+
+ {activeTab === "internal" && (
+
+ onCommentTextChange(event.target.value)}
+ placeholder="Type a comment..."
+ disabled={isSubmittingComment}
+ />
+
+
+ )}
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/view/work-order-details-panel.tsx b/src/app/(protected)/workorders/_components/view/work-order-details-panel.tsx
new file mode 100644
index 00000000..55186842
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/view/work-order-details-panel.tsx
@@ -0,0 +1,76 @@
+import { Box, Button, Menu, MenuItem, Paper, Typography } from "@mui/material";
+import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail";
+import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
+import { Field } from "@/app/(protected)/workorders/_components/view/work-order-timelines";
+
+interface WorkOrderDetailsPanelProps {
+ wo: WorkOrderDetail;
+ users: WorkOrderUser[];
+ assignAnchor: HTMLElement | null;
+ onAssignAnchorChange: (anchor: HTMLElement | null) => void;
+ onAssign: (userId: string | number, userName: string) => void;
+}
+
+export function WorkOrderDetailsPanel({
+ wo,
+ users,
+ assignAnchor,
+ onAssignAnchorChange,
+ onAssign,
+}: WorkOrderDetailsPanelProps) {
+ return (
+
+
+ Details
+
+
+
+
+
+
+
+
+ Assigned To
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/view/work-order-dispatches-accordion.tsx b/src/app/(protected)/workorders/_components/view/work-order-dispatches-accordion.tsx
new file mode 100644
index 00000000..871d7120
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/view/work-order-dispatches-accordion.tsx
@@ -0,0 +1,117 @@
+import {
+ Accordion,
+ AccordionDetails,
+ AccordionSummary,
+ Button,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
+ Typography,
+} from "@mui/material";
+import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
+import type { WorkOrderDispatchSummary } from "@/domain/work-orders/types/work-order";
+import { formatDateTime, timeSince, waitTimeColor } from "@/lib/time-utils";
+
+interface WorkOrderDispatchesAccordionProps {
+ dispatches: WorkOrderDispatchSummary[];
+ onSelectDispatch: (dispatchId: string | number) => void;
+ onEditVendor: (event: React.MouseEvent, vendorId: string | number, vendorName: string) => void;
+}
+
+function DispatchWaitCell({ dispatch }: { dispatch: WorkOrderDispatchSummary }) {
+ if (dispatch.status !== "Sent") {
+ return <>—>;
+ }
+
+ const waitColor = waitTimeColor(dispatch.dispatchedAt);
+ const colorClass =
+ waitColor === "green"
+ ? "text-green-600"
+ : waitColor === "yellow"
+ ? "text-yellow-600"
+ : "text-red-600";
+
+ return (
+
+ {timeSince(dispatch.dispatchedAt)}
+
+ );
+}
+
+export function WorkOrderDispatchesAccordion({
+ dispatches,
+ onSelectDispatch,
+ onEditVendor,
+}: WorkOrderDispatchesAccordionProps) {
+ return (
+
+ }>
+ Dispatches ({dispatches.length})
+
+
+ {dispatches.length ? (
+
+
+
+
+ Dispatch #
+ Vendor
+ NTE
+ Status
+ Date
+ Wait
+ Actions
+
+
+
+ {dispatches.map((dispatch) => (
+ onSelectDispatch(dispatch.id)}
+ >
+ {dispatch.dispatchNumber || dispatch.poNumber}
+ {dispatch.vendorName}
+
+ {dispatch.nteAmount != null ? `$${dispatch.nteAmount.toFixed(2)}` : "—"}
+
+ {dispatch.status}
+
+ {dispatch.dispatchedAt ? formatDateTime(dispatch.dispatchedAt) : "—"}
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+ ) : (
+
+ No dispatches yet.
+
+ )}
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/view/work-order-header-section.tsx b/src/app/(protected)/workorders/_components/view/work-order-header-section.tsx
new file mode 100644
index 00000000..0fa4ede7
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/view/work-order-header-section.tsx
@@ -0,0 +1,113 @@
+import { Link as RouterLink } from "react-router";
+import { Breadcrumbs, Button, Chip, Link, Menu, MenuItem, Stack, Typography } from "@mui/material";
+import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail";
+import {
+ WORK_ORDER_STATUS_OPTIONS,
+ type WorkOrderStatus,
+} from "@/domain/work-orders/types/work-order";
+
+const STATUS_COLORS: Record<
+ string,
+ "default" | "primary" | "secondary" | "error" | "info" | "success" | "warning"
+> = {
+ Open: "primary",
+ "In Progress": "info",
+ "On Hold": "warning",
+ Done: "success",
+ Cancelled: "default",
+};
+
+interface WorkOrderHeaderSectionProps {
+ wo: WorkOrderDetail;
+ statusAnchor: HTMLElement | null;
+ onStatusAnchorChange: (anchor: HTMLElement | null) => void;
+ onStatusChange: (status: WorkOrderStatus) => void;
+ onDispatch: () => void;
+ onEdit: () => void;
+ onBack: () => void;
+}
+
+export function WorkOrderHeaderSection({
+ wo,
+ statusAnchor,
+ onStatusAnchorChange,
+ onStatusChange,
+ onDispatch,
+ onEdit,
+ onBack,
+}: WorkOrderHeaderSectionProps) {
+ return (
+ <>
+
+
+ Work Orders
+
+
+ {wo.internalWONumber}
+
+
+
+
+
+ {wo.title}
+
+ onStatusAnchorChange(event.currentTarget)}
+ className="w-fit cursor-pointer"
+ />
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/view/work-order-info-accordions.tsx b/src/app/(protected)/workorders/_components/view/work-order-info-accordions.tsx
new file mode 100644
index 00000000..2fce76a7
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/view/work-order-info-accordions.tsx
@@ -0,0 +1,68 @@
+import { Accordion, AccordionDetails, AccordionSummary, Box, Typography } from "@mui/material";
+import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
+import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail";
+import { formatDateTime } from "@/lib/time-utils";
+import { Field } from "@/app/(protected)/workorders/_components/view/work-order-timelines";
+
+export function WorkOrderInfoAccordions({ wo }: { wo: WorkOrderDetail }) {
+ return (
+ <>
+
+ }>
+ Classification
+
+
+
+
+
+
+
+
+
+
+ }>
+ Schedule
+
+
+
+
+
+
+
+
+
+
+
+ }>
+ Financial
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
+
+export function WorkOrderDescriptionAccordion({ description }: { description: string }) {
+ return (
+
+ }>
+ Description
+
+
+ {description || "No description provided."}
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/view/work-order-timelines.tsx b/src/app/(protected)/workorders/_components/view/work-order-timelines.tsx
new file mode 100644
index 00000000..3ce629fb
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/view/work-order-timelines.tsx
@@ -0,0 +1,125 @@
+import { useMemo } from "react";
+import { Box, Stack, Typography } from "@mui/material";
+import type { WorkOrderAuditEntry, WorkOrderComment } from "@/domain/work-orders/types/work-order";
+import { formatDateTime } from "@/lib/time-utils";
+
+export function Field({ label, value }: { label: string; value: string }) {
+ return (
+
+
+ {label}
+
+ {value}
+
+ );
+}
+
+export function CommentTimeline({ comments }: { comments: WorkOrderComment[] }) {
+ const sorted = useMemo(
+ () =>
+ [...comments].sort(
+ (a, b) => new Date(a.createdDate).getTime() - new Date(b.createdDate).getTime(),
+ ),
+ [comments],
+ );
+
+ if (!sorted.length) {
+ return (
+
+ No comments yet.
+
+ );
+ }
+
+ return (
+
+ {sorted.map((comment) => (
+
+
+
+ {comment.userName || comment.commenter || "System"}
+
+
+ {comment.createdDate ? formatDateTime(comment.createdDate) : ""}
+
+
+ {comment.commenttext}
+
+ ))}
+
+ );
+}
+
+export function AuditTimeline({ entries }: { entries: WorkOrderAuditEntry[] }) {
+ const sorted = useMemo(
+ () =>
+ [...entries].sort(
+ (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
+ ),
+ [entries],
+ );
+
+ if (!sorted.length) {
+ return (
+
+ No changes recorded.
+
+ );
+ }
+
+ return (
+
+ {sorted.map((entry) => (
+
+
+
+ {entry.userName || "System"}
+
+
+ {entry.createdAt ? formatDateTime(entry.createdAt) : ""}
+
+
+
+ {entry.fieldName} {entry.oldValue} → {entry.newValue}
+
+
+ ))}
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/view/work-order-vendor-editor-dialog.tsx b/src/app/(protected)/workorders/_components/view/work-order-vendor-editor-dialog.tsx
new file mode 100644
index 00000000..3cf338e7
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/view/work-order-vendor-editor-dialog.tsx
@@ -0,0 +1,115 @@
+import {
+ Alert,
+ Box,
+ Button,
+ CircularProgress,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogTitle,
+ MenuItem,
+ Stack,
+ TextField,
+ Typography,
+} from "@mui/material";
+import type { VendorPreferredContact } from "@/domain/vendors/types/vendor";
+import {
+ formatPhoneMask,
+ isVendorEditorValid,
+ type VendorEditorState,
+} from "@/app/(protected)/workorders/_hooks/use-vendor-editor";
+
+interface WorkOrderVendorEditorDialogProps {
+ vendorEditor: VendorEditorState | null;
+ isLoading: boolean;
+ isSaving: boolean;
+ error: string;
+ onClose: () => void;
+ onChange: (patch: Partial) => void;
+ onSave: () => void;
+}
+
+export function WorkOrderVendorEditorDialog({
+ vendorEditor,
+ isLoading,
+ isSaving,
+ error,
+ onClose,
+ onChange,
+ onSave,
+}: WorkOrderVendorEditorDialogProps) {
+ return (
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_hooks/use-dispatch-create-controller.ts b/src/app/(protected)/workorders/_hooks/use-dispatch-create-controller.ts
new file mode 100644
index 00000000..47464dfe
--- /dev/null
+++ b/src/app/(protected)/workorders/_hooks/use-dispatch-create-controller.ts
@@ -0,0 +1,159 @@
+import { useMemo, useState } from "react";
+import { useWorkOrdersList } from "@/domain/work-orders/use-cases/use-work-orders-list";
+import {
+ useTaskListTemplates,
+ useWorkOrderVendors,
+} from "@/domain/work-orders/use-cases/use-work-order-detail";
+import { useDispatchToVendor } from "@/domain/work-orders/use-cases/use-work-order-mutations";
+import type { SelectedWorkOrderRef, WorkOrderDetail } from "@/domain/work-orders/types/work-order";
+import { guardDialogClose } from "@/lib/dialog-guard";
+
+export function useDispatchCreateController(
+ workOrder: WorkOrderDetail,
+ onClose: () => void,
+ onDispatched: () => void,
+) {
+ const [selectedVendorId, setSelectedVendorId] = useState(null);
+ const [nteAmount, setNteAmount] = useState(workOrder.vendorNTE || "");
+ const [scheduledDate, setScheduledDate] = useState("");
+ const [description, setDescription] = useState(workOrder.description || "");
+ const [vendorSearch, setVendorSearch] = useState("");
+ const [selectedTemplateId, setSelectedTemplateId] = useState("");
+ const [customItems, setCustomItems] = useState([]);
+ const [newCustomItem, setNewCustomItem] = useState("");
+ const [selectedWOs, setSelectedWOs] = useState([
+ {
+ id: workOrder.id,
+ number: workOrder.internalWONumber,
+ title: workOrder.title,
+ },
+ ]);
+ const [woSearch, setWoSearch] = useState("");
+
+ const { data: vendors = [], isLoading: isLoadingVendors } = useWorkOrderVendors(
+ workOrder.trade,
+ workOrder.locationZip,
+ );
+ const { data: taskTemplates = [], isLoading: isLoadingTemplates } = useTaskListTemplates();
+ const dispatchMutation = useDispatchToVendor(workOrder.id);
+ const isPending = dispatchMutation.isPending;
+
+ const { data: siteWorkOrdersData } = useWorkOrdersList({
+ search: "",
+ page: 1,
+ pageSize: 500,
+ sortBy: "number",
+ sortDir: "asc",
+ locationId: workOrder.locationId,
+ });
+
+ const siteWorkOrders = useMemo(
+ () =>
+ (siteWorkOrdersData?.items ?? []).filter((item) => String(item.id) !== String(workOrder.id)),
+ [siteWorkOrdersData?.items, workOrder.id],
+ );
+
+ const filteredVendors = useMemo(
+ () =>
+ vendors.filter(
+ (vendor) =>
+ vendor.companyName.toLowerCase().includes(vendorSearch.toLowerCase()) ||
+ vendor.contactName.toLowerCase().includes(vendorSearch.toLowerCase()) ||
+ vendor.tradeSpecialties.toLowerCase().includes(vendorSearch.toLowerCase()),
+ ),
+ [vendors, vendorSearch],
+ );
+
+ const filteredSiteWOs = useMemo(
+ () =>
+ siteWorkOrders
+ .filter((item) => !selectedWOs.some((selected) => String(selected.id) === String(item.id)))
+ .filter(
+ (item) =>
+ !woSearch ||
+ item.woNumber.includes(woSearch) ||
+ item.title.toLowerCase().includes(woSearch.toLowerCase()),
+ )
+ .slice(0, 10),
+ [siteWorkOrders, selectedWOs, woSearch],
+ );
+
+ const addSelectedWO = (item: { id: string | number; woNumber: string; title: string }) => {
+ setSelectedWOs((prev) => [...prev, { id: item.id, number: item.woNumber, title: item.title }]);
+ setWoSearch("");
+ };
+
+ const removeSelectedWO = (id: string | number) => {
+ setSelectedWOs((prev) => prev.filter((item) => item.id !== id));
+ };
+
+ const addCustomItem = () => {
+ if (!newCustomItem.trim()) return;
+ setCustomItems((prev) => [...prev, newCustomItem.trim()]);
+ setNewCustomItem("");
+ };
+
+ const removeCustomItem = (index: number) => {
+ setCustomItems((prev) => prev.filter((_, i) => i !== index));
+ };
+
+ const handleSubmit = () => {
+ if (!selectedVendorId) {
+ return;
+ }
+
+ dispatchMutation.mutate(
+ {
+ workOrderIds: selectedWOs.map((wo) => wo.id),
+ vendorIds: [selectedVendorId],
+ nteAmount: nteAmount ? parseFloat(nteAmount) : null,
+ scheduledDate: scheduledDate || null,
+ description,
+ taskListTemplateId: selectedTemplateId ? parseInt(selectedTemplateId, 10) : null,
+ customChecklistItems: customItems.length > 0 ? customItems : null,
+ },
+ {
+ onSuccess: () => {
+ onDispatched();
+ onClose();
+ },
+ },
+ );
+ };
+
+ return {
+ selectedVendorId,
+ setSelectedVendorId,
+ nteAmount,
+ setNteAmount,
+ scheduledDate,
+ setScheduledDate,
+ description,
+ setDescription,
+ vendorSearch,
+ setVendorSearch,
+ selectedTemplateId,
+ setSelectedTemplateId,
+ customItems,
+ newCustomItem,
+ setNewCustomItem,
+ addCustomItem,
+ removeCustomItem,
+ selectedWOs,
+ addSelectedWO,
+ removeSelectedWO,
+ woSearch,
+ setWoSearch,
+ vendors: filteredVendors,
+ isLoadingVendors,
+ taskTemplates,
+ isLoadingTemplates,
+ siteWorkOrders,
+ filteredSiteWOs,
+ isPending,
+ handleClose: guardDialogClose(isPending, onClose),
+ handleSubmit,
+ };
+}
+
+export type DispatchCreateController = ReturnType;
diff --git a/src/app/(protected)/workorders/_hooks/use-dispatch-detail-controller.ts b/src/app/(protected)/workorders/_hooks/use-dispatch-detail-controller.ts
index eb72e090..948975bc 100644
--- a/src/app/(protected)/workorders/_hooks/use-dispatch-detail-controller.ts
+++ b/src/app/(protected)/workorders/_hooks/use-dispatch-detail-controller.ts
@@ -1,11 +1,6 @@
import { useMemo, useState } from "react";
-import {
- useApproveUplift,
- useDenyUplift,
- useUpliftsForDispatch,
-} from "@/domain/uplifts/use-cases/use-uplift-actions";
-import type { UpliftRequest } from "@/domain/uplifts/types/uplift";
import type { DispatchDetail } from "@/domain/work-orders/types/work-order";
+import { useDispatchUpliftController } from "@/app/(protected)/workorders/_hooks/use-dispatch-uplift-controller";
import { useDispatchDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
import {
useAddChecklistItem,
@@ -53,7 +48,7 @@ export function useDispatchDetailController(
) {
const { user } = useAuthContext();
const { data: dispatch, isLoading, isFetching, refetch } = useDispatchDetail(dispatchId);
- const { data: uplifts = [], isLoading: isLoadingUplifts } = useUpliftsForDispatch(dispatchId);
+ const uplift = useDispatchUpliftController(dispatchId);
const [editing, setEditing] = useState(false);
const [form, setForm] = useState({
@@ -67,10 +62,6 @@ export function useDispatchDetailController(
const [commentText, setCommentText] = useState("");
const [sendEmail, setSendEmail] = useState(true);
const [sigCaptureType, setSigCaptureType] = useState(null);
- const [upliftAction, setUpliftAction] = useState<{
- type: "approve" | "deny";
- uplift: UpliftRequest;
- } | null>(null);
const updateDispatchMutation = useUpdateDispatch(dispatchId, workOrderId);
const addCommentMutation = useAddDispatchComment(dispatchId);
@@ -78,8 +69,6 @@ export function useDispatchDetailController(
const addChecklistMutation = useAddChecklistItem(dispatchId);
const addSignoffMutation = useAddDispatchSignoff(dispatchId);
const verifyMutation = useVerifyDispatch(dispatchId, workOrderId);
- const approveUpliftMutation = useApproveUplift(dispatchId);
- const denyUpliftMutation = useDenyUplift(dispatchId);
const userName =
(typeof user?.name === "string" ? user.name : undefined) ??
@@ -138,25 +127,6 @@ export function useDispatchDetailController(
);
};
- const handleUpliftConfirm = (note: string) => {
- if (!upliftAction) {
- return;
- }
-
- if (upliftAction.type === "approve") {
- approveUpliftMutation.mutate(
- { id: upliftAction.uplift.id, note },
- { onSuccess: () => setUpliftAction(null) },
- );
- return;
- }
-
- denyUpliftMutation.mutate(
- { id: upliftAction.uplift.id, note },
- { onSuccess: () => setUpliftAction(null) },
- );
- };
-
const isMutating =
updateDispatchMutation.isPending ||
addCommentMutation.isPending ||
@@ -164,16 +134,15 @@ export function useDispatchDetailController(
addChecklistMutation.isPending ||
addSignoffMutation.isPending ||
verifyMutation.isPending ||
- approveUpliftMutation.isPending ||
- denyUpliftMutation.isPending;
+ uplift.isMutating;
return {
dispatch,
isLoading,
isFetching,
refetch,
- uplifts,
- isLoadingUplifts,
+ uplifts: uplift.uplifts,
+ isLoadingUplifts: uplift.isLoadingUplifts,
editing,
form,
setForm,
@@ -185,8 +154,8 @@ export function useDispatchDetailController(
setSendEmail,
sigCaptureType,
setSigCaptureType,
- upliftAction,
- setUpliftAction,
+ upliftAction: uplift.upliftAction,
+ setUpliftAction: uplift.setUpliftAction,
isLocked,
isMutating,
userName,
@@ -195,15 +164,17 @@ export function useDispatchDetailController(
handleSave,
handleCancelDispatch,
handleComment,
- handleUpliftConfirm,
+ handleUpliftConfirm: uplift.handleUpliftConfirm,
setEditing,
updateDispatchMutation,
updateChecklistMutation,
addChecklistMutation,
addSignoffMutation,
verifyMutation,
- approveUpliftMutation,
- denyUpliftMutation,
+ approveUpliftMutation: uplift.approveUpliftMutation,
+ denyUpliftMutation: uplift.denyUpliftMutation,
addCommentMutation,
};
}
+
+export type DispatchDetailController = ReturnType;
diff --git a/src/app/(protected)/workorders/_hooks/use-dispatch-uplift-controller.ts b/src/app/(protected)/workorders/_hooks/use-dispatch-uplift-controller.ts
new file mode 100644
index 00000000..acab0985
--- /dev/null
+++ b/src/app/(protected)/workorders/_hooks/use-dispatch-uplift-controller.ts
@@ -0,0 +1,51 @@
+import { useState } from "react";
+import {
+ useApproveUplift,
+ useDenyUplift,
+ useUpliftsForDispatch,
+} from "@/domain/uplifts/use-cases/use-uplift-actions";
+import type { UpliftRequest } from "@/domain/uplifts/types/uplift";
+
+type UpliftAction = { type: "approve" | "deny"; uplift: UpliftRequest };
+
+/**
+ * Owns the uplift approve/deny decision workflow for a dispatch: the pending
+ * uplifts list, which uplift (if any) the user is currently deciding on, and
+ * the mutations backing that decision.
+ */
+export function useDispatchUpliftController(dispatchId: string | number) {
+ const { data: uplifts = [], isLoading: isLoadingUplifts } = useUpliftsForDispatch(dispatchId);
+ const [upliftAction, setUpliftAction] = useState(null);
+ const approveUpliftMutation = useApproveUplift(dispatchId);
+ const denyUpliftMutation = useDenyUplift(dispatchId);
+
+ const handleUpliftConfirm = (note: string) => {
+ if (!upliftAction) {
+ return;
+ }
+
+ if (upliftAction.type === "approve") {
+ approveUpliftMutation.mutate(
+ { id: upliftAction.uplift.id, note },
+ { onSuccess: () => setUpliftAction(null) },
+ );
+ return;
+ }
+
+ denyUpliftMutation.mutate(
+ { id: upliftAction.uplift.id, note },
+ { onSuccess: () => setUpliftAction(null) },
+ );
+ };
+
+ return {
+ uplifts,
+ isLoadingUplifts,
+ upliftAction,
+ setUpliftAction,
+ approveUpliftMutation,
+ denyUpliftMutation,
+ handleUpliftConfirm,
+ isMutating: approveUpliftMutation.isPending || denyUpliftMutation.isPending,
+ };
+}
diff --git a/src/app/(protected)/workorders/_hooks/use-vendor-editor.ts b/src/app/(protected)/workorders/_hooks/use-vendor-editor.ts
new file mode 100644
index 00000000..198dfe14
--- /dev/null
+++ b/src/app/(protected)/workorders/_hooks/use-vendor-editor.ts
@@ -0,0 +1,119 @@
+import { useState } from "react";
+import { vendorsApi } from "@/domain/vendors/api/vendors-api";
+import type { VendorPreferredContact } from "@/domain/vendors/types/vendor";
+import { useUpdateWorkOrderVendor } from "@/domain/work-orders/use-cases/use-work-order-mutations";
+
+export interface VendorEditorState {
+ vendorId: string | number;
+ vendorName: string;
+ contactName: string;
+ preferredContact: VendorPreferredContact;
+ phone: string;
+ email: string;
+ notes: string;
+}
+
+export function formatPhoneMask(input: string): string {
+ const digits = input.replace(/\D/g, "").slice(0, 10);
+ if (digits.length <= 3) return digits;
+ if (digits.length <= 6) return `(${digits.slice(0, 3)}) ${digits.slice(3)}`;
+ return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
+}
+
+export function isVendorEditorValid(editor: VendorEditorState): boolean {
+ const phoneValid = editor.phone === "" || /^\(\d{3}\) \d{3}-\d{4}$/.test(editor.phone);
+ const emailValid = editor.email === "" || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(editor.email);
+ return (
+ phoneValid && emailValid && editor.notes.length <= 2000 && editor.contactName.length <= 200
+ );
+}
+
+/**
+ * Owns the "edit assigned vendor" dialog state for a work order: loading the
+ * current vendor contact details, tracking in-flight edits, and saving them
+ * back via `useUpdateWorkOrderVendor`.
+ */
+export function useVendorEditor(workOrderId: string, onSaved: () => void) {
+ const vendorMutation = useUpdateWorkOrderVendor(workOrderId);
+ const [vendorEditor, setVendorEditor] = useState(null);
+ const [vendorEditorLoading, setVendorEditorLoading] = useState(false);
+ const [vendorEditorError, setVendorEditorError] = useState("");
+
+ const openVendorEditor = async (vendorId: string | number, vendorName: string) => {
+ setVendorEditorError("");
+ setVendorEditorLoading(true);
+ setVendorEditor({
+ vendorId,
+ vendorName,
+ contactName: "",
+ preferredContact: "Phone",
+ phone: "",
+ email: "",
+ notes: "",
+ });
+
+ try {
+ const vendor = await vendorsApi.getById(vendorId);
+ setVendorEditor({
+ vendorId,
+ vendorName: vendor.companyName || vendorName,
+ contactName: vendor.contactName,
+ preferredContact: vendor.preferredContact,
+ phone: vendor.phone,
+ email: vendor.email,
+ notes: vendor.notes,
+ });
+ } catch (vendorError) {
+ setVendorEditorError(
+ vendorError instanceof Error ? vendorError.message : "Unable to load vendor details",
+ );
+ } finally {
+ setVendorEditorLoading(false);
+ }
+ };
+
+ const closeVendorEditor = () => {
+ if (!vendorMutation.isPending) setVendorEditor(null);
+ };
+
+ const saveVendorEditor = () => {
+ if (!vendorEditor || !isVendorEditorValid(vendorEditor)) return;
+ const numericWorkOrderId = Number(workOrderId);
+ if (!Number.isInteger(numericWorkOrderId) || numericWorkOrderId <= 0) {
+ setVendorEditorError("A valid work order is required");
+ return;
+ }
+
+ vendorMutation.mutate(
+ {
+ vendorId: vendorEditor.vendorId,
+ payload: {
+ workOrderId: numericWorkOrderId,
+ contactName: vendorEditor.contactName.trim(),
+ preferredContact: vendorEditor.preferredContact,
+ phone: vendorEditor.phone,
+ email: vendorEditor.email.trim(),
+ notes: vendorEditor.notes.trim(),
+ },
+ },
+ {
+ onSuccess: () => {
+ setVendorEditor(null);
+ onSaved();
+ },
+ onError: (vendorError) => setVendorEditorError(vendorError.message),
+ },
+ );
+ };
+
+ return {
+ vendorEditor,
+ setVendorEditor,
+ vendorEditorLoading,
+ vendorEditorError,
+ vendorMutation,
+ openVendorEditor,
+ closeVendorEditor,
+ saveVendorEditor,
+ };
+}
diff --git a/src/app/(protected)/workorders/_hooks/use-work-order-view-controller.ts b/src/app/(protected)/workorders/_hooks/use-work-order-view-controller.ts
new file mode 100644
index 00000000..9053c006
--- /dev/null
+++ b/src/app/(protected)/workorders/_hooks/use-work-order-view-controller.ts
@@ -0,0 +1,82 @@
+import { useMemo, useState } from "react";
+import { useWorkOrderDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
+import {
+ useAddWorkOrderComment,
+ useChangeWorkOrderAssignment,
+ useChangeWorkOrderStatus,
+} from "@/domain/work-orders/use-cases/use-work-order-mutations";
+import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
+import { useVendorEditor } from "@/app/(protected)/workorders/_hooks/use-vendor-editor";
+
+export type WorkOrderActivityTab = "customer" | "internal" | "audit";
+
+export function useWorkOrderViewController(workOrderId: string) {
+ const { data: wo, isLoading, error, refetch } = useWorkOrderDetail(workOrderId);
+ const { data: users = [] } = useWorkOrderUsers();
+ const statusMutation = useChangeWorkOrderStatus(workOrderId);
+ const assignmentMutation = useChangeWorkOrderAssignment(workOrderId);
+ const commentMutation = useAddWorkOrderComment(workOrderId);
+ const vendorEditor = useVendorEditor(workOrderId, () => void refetch());
+
+ const [activeTab, setActiveTab] = useState("customer");
+ const [commentText, setCommentText] = useState("");
+ const [dispatchOpen, setDispatchOpen] = useState(false);
+ const [dispatchDetailId, setDispatchDetailId] = useState(null);
+ const [statusAnchor, setStatusAnchor] = useState(null);
+ const [assignAnchor, setAssignAnchor] = useState(null);
+
+ 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;
+ }
+ if (activeTab !== "customer" && activeTab !== "internal") {
+ return;
+ }
+
+ commentMutation.mutate(
+ { text: commentText.trim(), commentType: activeTab },
+ { onSuccess: () => setCommentText("") },
+ );
+ };
+
+ return {
+ wo,
+ isLoading,
+ error,
+ refetch,
+ users,
+ statusMutation,
+ assignmentMutation,
+ commentMutation,
+ vendorEditor,
+ activeTab,
+ setActiveTab,
+ commentText,
+ setCommentText,
+ dispatchOpen,
+ setDispatchOpen,
+ dispatchDetailId,
+ setDispatchDetailId,
+ statusAnchor,
+ setStatusAnchor,
+ assignAnchor,
+ setAssignAnchor,
+ customerComments,
+ internalComments,
+ auditLog,
+ handleCommentSubmit,
+ };
+}
+
+export type WorkOrderViewController = ReturnType;
diff --git a/src/components/common/signature-capture.tsx b/src/components/common/signature-capture.tsx
index 9c14cf18..6c17eb80 100644
--- a/src/components/common/signature-capture.tsx
+++ b/src/components/common/signature-capture.tsx
@@ -1,4 +1,5 @@
import { CSS_VAR_FALLBACKS, getCssVar } from "@/lib/theme/css-vars";
+import { guardDialogClose } from "@/lib/dialog-guard";
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import CloseIcon from "@mui/icons-material/Close";
@@ -20,9 +21,15 @@ interface SignatureCaptureProps {
title?: string;
onClose: () => void;
onSave: (signature: string, method: "drawn" | "typed", name: string) => void;
+ isPending?: boolean;
}
-export function SignatureCapture({ onClose, onSave, title }: SignatureCaptureProps) {
+export function SignatureCapture({
+ onClose,
+ onSave,
+ title,
+ isPending = false,
+}: SignatureCaptureProps) {
const [mode, setMode] = useState<"draw" | "type">("draw");
const [typedName, setTypedName] = useState("");
const [signerName, setSignerName] = useState("");
@@ -113,7 +120,7 @@ export function SignatureCapture({ onClose, onSave, title }: SignatureCapturePro
};
const handleSave = () => {
- if (!signerName.trim()) {
+ if (isPending || !signerName.trim()) {
return;
}
@@ -134,10 +141,10 @@ export function SignatureCapture({ onClose, onSave, title }: SignatureCapturePro
};
const dialog = (
-