diff --git a/src/app/(protected)/workorders/[id].tsx b/src/app/(protected)/workorders/[id].tsx
new file mode 100644
index 00000000..d898d763
--- /dev/null
+++ b/src/app/(protected)/workorders/[id].tsx
@@ -0,0 +1,479 @@
+import { useMemo, useState } from "react";
+import { Link as RouterLink, useNavigate, useParams } from "react-router-dom";
+import {
+ Accordion,
+ AccordionDetails,
+ AccordionSummary,
+ Alert,
+ Box,
+ Breadcrumbs,
+ Button,
+ Chip,
+ CircularProgress,
+ 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 { 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 {
+ 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 { 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";
+
+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}
+
+
+ ))}
+
+ );
+}
+
+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 [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;
+ }
+
+ commentMutation.mutate(
+ { text: commentText.trim(), commentType: activeTab },
+ { onSuccess: () => setCommentText("") },
+ );
+ };
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (error || !wo) {
+ return (
+
+
+ {error instanceof Error ? error.message : "Work order not found."}
+
+
+ );
+ }
+
+ return (
+
+
+
+ Work Orders
+
+ {wo.internalWONumber}
+
+
+
+
+
+ {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
+
+
+
+ {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 && (
+ setDispatchOpen(false)}
+ onDispatched={() => void refetch()}
+ />
+ )}
+
+ {dispatchDetailId && (
+ setDispatchDetailId(null)}
+ onUpdated={() => void refetch()}
+ />
+ )}
+
+ );
+}
+
+function Field({ label, value }: { label: string; value: string }) {
+ return (
+
+
+ {label}
+
+ {value}
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/dispatch-create-modal.tsx b/src/app/(protected)/workorders/_components/dispatch-create-modal.tsx
new file mode 100644
index 00000000..1ab4bb80
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/dispatch-create-modal.tsx
@@ -0,0 +1,341 @@
+import { useMemo, useState } from "react";
+import {
+ Box,
+ Button,
+ Chip,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogTitle,
+ IconButton,
+ MenuItem,
+ Radio,
+ Stack,
+ TextField,
+ Typography,
+} from "@mui/material";
+import CloseIcon from "@mui/icons-material/Close";
+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 { WorkOrderDetail } from "@/domain/work-orders/types/work-order";
+
+interface SelectedWorkOrder {
+ id: string | number;
+ number: string;
+ title: string;
+}
+
+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 = [] } = useTaskListTemplates();
+ const dispatchMutation = useDispatchToVendor(workOrder.id);
+
+ 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.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.number.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-detail-modal.tsx b/src/app/(protected)/workorders/_components/dispatch-detail-modal.tsx
new file mode 100644
index 00000000..a1860a13
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/dispatch-detail-modal.tsx
@@ -0,0 +1,698 @@
+import { useMemo, useState } from "react";
+import CloseIcon from "@mui/icons-material/Close";
+import {
+ Alert,
+ Box,
+ Button,
+ Checkbox,
+ Chip,
+ CircularProgress,
+ Dialog,
+ DialogContent,
+ DialogTitle,
+ FormControlLabel,
+ IconButton,
+ MenuItem,
+ Stack,
+ TextField,
+ Tooltip,
+ Typography,
+} from "@mui/material";
+import { SignatureCapture } from "@/components/common/signature-capture";
+import {
+ useApproveUplift,
+ useDenyUplift,
+ useUpliftsForDispatch,
+} from "@/domain/uplifts/use-cases/use-uplift-actions";
+import { DISPATCH_STATUS_OPTIONS } from "@/domain/work-orders/types/work-order";
+import { useDispatchDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
+import {
+ useAddChecklistItem,
+ useAddDispatchComment,
+ useAddDispatchSignoff,
+ useUpdateChecklistItem,
+ useUpdateDispatch,
+ useVerifyDispatch,
+} from "@/domain/work-orders/use-cases/use-work-order-mutations";
+import { isDispatchLocked } from "@/domain/work-orders/utils/is-dispatch-locked";
+import { useAuthContext } from "@/providers/auth-context";
+import {
+ formatDateTime,
+ timeBetween,
+ timeSince,
+ toDateInputValue,
+ waitTimeColor,
+} from "@/lib/time-utils";
+
+interface DispatchDetailModalProps {
+ dispatchId: string | number;
+ workOrderId: string | number;
+ onClose: () => void;
+ onUpdated: () => void;
+}
+
+export function DispatchDetailModal({
+ dispatchId,
+ workOrderId,
+ onClose,
+ onUpdated,
+}: DispatchDetailModalProps) {
+ const { user } = useAuthContext();
+ const { data: dispatch, isLoading } = useDispatchDetail(dispatchId);
+ const { data: uplifts = [] } = useUpliftsForDispatch(dispatchId);
+
+ const [editing, setEditing] = useState(false);
+ const [form, setForm] = useState({
+ status: "",
+ nteAmount: "",
+ scheduledDate: "",
+ completedDate: "",
+ description: "",
+ });
+ const [newChecklistItem, setNewChecklistItem] = useState("");
+ const [commentText, setCommentText] = useState("");
+ const [sendEmail, setSendEmail] = useState(true);
+ const [sigCaptureType, setSigCaptureType] = useState(null);
+
+ const updateDispatchMutation = useUpdateDispatch(dispatchId, workOrderId);
+ const addCommentMutation = useAddDispatchComment(dispatchId);
+ const updateChecklistMutation = useUpdateChecklistItem(dispatchId);
+ 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) ??
+ (typeof user?.fullname === "string" ? user.fullname : undefined) ??
+ "User";
+
+ const beginEditing = () => {
+ if (dispatch) {
+ setForm({
+ status: dispatch.status || "",
+ nteAmount: dispatch.nteAmount != null ? String(dispatch.nteAmount) : "",
+ scheduledDate: toDateInputValue(dispatch.scheduledDate),
+ completedDate: toDateInputValue(dispatch.completedDate),
+ description: dispatch.description || "",
+ });
+ }
+ setEditing(true);
+ };
+
+ const verifyState = useMemo(() => {
+ if (!dispatch) {
+ return { canVerify: false, missing: [] as string[] };
+ }
+
+ const checklistTotal = dispatch.checklistItems.length;
+ const checklistDone = dispatch.checklistItems.filter((item) => item.isCompleted).length;
+ const signoffCount = dispatch.signoffs.length;
+ const missing: string[] = [];
+
+ if (checklistTotal > 0 && checklistDone < checklistTotal) {
+ missing.push(`${checklistTotal - checklistDone} checklist item(s)`);
+ }
+
+ if (signoffCount < 2) {
+ const types = dispatch.signoffs.map((signoff) => signoff.signoffType);
+ if (!types.includes("Customer")) {
+ missing.push("Customer sign-off");
+ }
+ if (!types.includes("Vendor")) {
+ missing.push("Vendor sign-off");
+ }
+ }
+
+ return {
+ missing,
+ canVerify: missing.length === 0 && (checklistTotal > 0 || signoffCount >= 2),
+ };
+ }, [dispatch]);
+
+ const handleSave = () => {
+ updateDispatchMutation.mutate(
+ {
+ id: dispatchId,
+ status: form.status,
+ nteAmount: form.nteAmount ? parseFloat(form.nteAmount) : null,
+ scheduledDate: form.scheduledDate || null,
+ completedDate: form.completedDate || null,
+ description: form.description,
+ },
+ {
+ onSuccess: () => {
+ setEditing(false);
+ onUpdated();
+ },
+ },
+ );
+ };
+
+ const handleComment = (event: React.FormEvent) => {
+ event.preventDefault();
+ if (!commentText.trim()) {
+ return;
+ }
+
+ addCommentMutation.mutate(
+ { text: commentText.trim(), sendEmail },
+ {
+ onSuccess: () => setCommentText(""),
+ },
+ );
+ };
+
+ const handleApproveUplift = (upliftId: string | number, requestedNTE: number) => {
+ const note = window.prompt("Optional approval note:", "") || "";
+ if (!window.confirm(`Approve uplift to $${requestedNTE.toFixed(2)}?`)) {
+ return;
+ }
+
+ approveUpliftMutation.mutate({ id: upliftId, note }, { onSuccess: onUpdated });
+ };
+
+ const handleDenyUplift = (upliftId: string | number) => {
+ const note = window.prompt("Denial reason (required):", "");
+ if (!note?.trim()) {
+ return;
+ }
+
+ denyUpliftMutation.mutate({ id: upliftId, note: note.trim() }, { onSuccess: onUpdated });
+ };
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ if (!dispatch) {
+ return null;
+ }
+
+ const isLocked = isDispatchLocked(dispatch);
+ const waitColor = waitTimeColor(dispatch.dispatchedAt);
+
+ return (
+ <>
+
+
+ {sigCaptureType && (
+ setSigCaptureType(null)}
+ onSave={(signature, method, name) => {
+ addSignoffMutation.mutate(
+ {
+ signoffType: sigCaptureType,
+ name,
+ signature,
+ signatureMethod: method,
+ },
+ { onSuccess: () => setSigCaptureType(null) },
+ );
+ }}
+ />
+ )}
+ >
+ );
+}
+
+function DetailField({
+ label,
+ value,
+ className,
+ highlight,
+}: {
+ label: string;
+ value: string;
+ className?: string;
+ highlight?: "green" | "yellow" | "red" | "";
+}) {
+ const colorClass =
+ highlight === "green"
+ ? "text-green-600"
+ : highlight === "yellow"
+ ? "text-yellow-600"
+ : highlight === "red"
+ ? "text-red-600"
+ : undefined;
+
+ return (
+
+
+ {label}
+
+
+ {value}
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/work-order-form.tsx b/src/app/(protected)/workorders/_components/work-order-form.tsx
new file mode 100644
index 00000000..c7db0492
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/work-order-form.tsx
@@ -0,0 +1,355 @@
+import { useEffect, useMemo } from "react";
+import { Controller, useForm, useWatch } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { Link as RouterLink, useNavigate, useParams } from "react-router-dom";
+import {
+ Alert,
+ Autocomplete,
+ Box,
+ Breadcrumbs,
+ Button,
+ CircularProgress,
+ Link,
+ MenuItem,
+ Paper,
+ Stack,
+ TextField,
+ Typography,
+} from "@mui/material";
+import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
+import { mapWorkOrderDetailToFormValues } from "@/domain/work-orders/mappers/work-order-mapper";
+import {
+ workOrderFormSchema,
+ type WorkOrderFormSchemaValues,
+} from "@/domain/work-orders/schemas/work-order-schema";
+import {
+ createDefaultWorkOrderFormValues,
+ WORK_ORDER_PRIORITY_OPTIONS,
+ WORK_ORDER_STATUS_OPTIONS,
+ type LocationOption,
+} from "@/domain/work-orders/types/work-order";
+import {
+ useWorkOrderDetail,
+ useWorkOrderEmployeesDropdown,
+ useWorkOrderLocations,
+} from "@/domain/work-orders/use-cases/use-work-order-detail";
+import {
+ useCreateWorkOrder,
+ useUpdateWorkOrder,
+} from "@/domain/work-orders/use-cases/use-work-order-mutations";
+
+function SectionTitle({ children }: { children: string }) {
+ return (
+
+ {children}
+
+ );
+}
+
+export default function WorkOrderFormPage() {
+ const { id } = useParams();
+ const navigate = useNavigate();
+ const isEdit = Boolean(id);
+
+ const { data: detail, isLoading: isLoadingDetail, error: detailError } = useWorkOrderDetail(id);
+ const { data: locations = [], isLoading: isLoadingLocations } = useWorkOrderLocations();
+ const { data: employees = [] } = useWorkOrderEmployeesDropdown();
+ const { data: problems = [] } = useDropdownOptionsByCategory("Problem");
+ const createMutation = useCreateWorkOrder();
+ const updateMutation = useUpdateWorkOrder(id ?? "");
+
+ const {
+ register,
+ control,
+ handleSubmit,
+ reset,
+ setValue,
+ formState: { errors },
+ } = useForm({
+ resolver: zodResolver(workOrderFormSchema),
+ defaultValues: createDefaultWorkOrderFormValues() as WorkOrderFormSchemaValues,
+ });
+
+ const tradeValue = useWatch({ control, name: "trade" });
+
+ const { data: subTrades = [] } = useDropdownOptionsByCategory("SubTrade", {
+ parent: tradeValue || undefined,
+ enabled: Boolean(tradeValue),
+ });
+ const { data: trades = [] } = useDropdownOptionsByCategory("Trade");
+
+ useEffect(() => {
+ if (isEdit && detail) {
+ reset(mapWorkOrderDetailToFormValues(detail) as WorkOrderFormSchemaValues);
+ }
+ }, [detail, isEdit, reset]);
+
+ useEffect(() => {
+ if (!tradeValue) {
+ setValue("subTrade", "");
+ }
+ }, [tradeValue, setValue]);
+
+ const locationOptions = useMemo(() => locations, [locations]);
+
+ const isSaving = createMutation.isPending || updateMutation.isPending;
+ const isLoading = isEdit && isLoadingDetail;
+
+ const onSubmit = (values: WorkOrderFormSchemaValues) => {
+ if (isEdit && id) {
+ updateMutation.mutate(values);
+ return;
+ }
+ createMutation.mutate(values);
+ };
+
+ const handleCancel = () => {
+ navigate(isEdit && id ? `/workorders/${id}` : "/workorders");
+ };
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (detailError) {
+ return (
+
+
+ {detailError instanceof Error ? detailError.message : "Failed to load work order"}
+
+
+ );
+ }
+
+ return (
+
+
+
+ Work Orders
+
+ {isEdit ? "Edit" : "Create"}
+
+
+
+ {isEdit ? `Edit Work Order ${detail?.internalWONumber ?? id}` : "Create a Work Order"}
+
+
+
+
+
+ Work Order Details
+
+
+
+
+
+ (
+ option.name}
+ value={locationOptions.find((loc) => String(loc.id) === field.value) ?? null}
+ onChange={(_event, option) => field.onChange(option ? String(option.id) : "")}
+ renderInput={(params) => (
+
+ )}
+ className="flex-1"
+ />
+ )}
+ />
+
+
+
+
+
+ Classification
+
+
+
+ {problems.map((option) => (
+
+ ))}
+
+
+
+ {trades.map((option) => (
+
+ ))}
+
+
+
+ {subTrades.map((option) => (
+
+ ))}
+
+
+
+
+
+ Schedule & Priority
+
+
+
+ {WORK_ORDER_PRIORITY_OPTIONS.map((priority) => (
+
+ ))}
+
+
+ {WORK_ORDER_STATUS_OPTIONS.map((status) => (
+
+ ))}
+
+ (
+ employee.name)}
+ value={field.value}
+ onChange={(_event, value) => field.onChange(value ?? "")}
+ onInputChange={(_event, value) => field.onChange(value)}
+ renderInput={(params) => (
+
+ )}
+ className="flex-1"
+ />
+ )}
+ />
+
+
+
+
+
+
+
+
+
+
+ Financial
+
+
+
+
+
+
+
+
+ Description
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/edit/[id].tsx b/src/app/(protected)/workorders/edit/[id].tsx
new file mode 100644
index 00000000..56354fc4
--- /dev/null
+++ b/src/app/(protected)/workorders/edit/[id].tsx
@@ -0,0 +1 @@
+export { default } from "../_components/work-order-form";
diff --git a/src/app/(protected)/workorders/index.tsx b/src/app/(protected)/workorders/index.tsx
new file mode 100644
index 00000000..c3b165eb
--- /dev/null
+++ b/src/app/(protected)/workorders/index.tsx
@@ -0,0 +1,310 @@
+import { useMemo, useState } from "react";
+import { useNavigate } from "react-router-dom";
+import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
+import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
+import { WorkOrderStatusBadge } from "@/components/domain/status-badge";
+import {
+ Alert,
+ Box,
+ Button,
+ Chip,
+ CircularProgress,
+ FormControl,
+ IconButton,
+ InputLabel,
+ MenuItem,
+ Paper,
+ Select,
+ Stack,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TablePagination,
+ TableRow,
+ TextField,
+ Tooltip,
+ Typography,
+} from "@mui/material";
+import { useDebounce } from "@/hooks/use-debounce";
+import type { WorkOrderListItem } from "@/domain/work-orders/types/work-order";
+import {
+ useWorkOrderUsers,
+ useWorkOrdersList,
+} from "@/domain/work-orders/use-cases/use-work-orders-list";
+
+const STATUS_OPTIONS = ["Open", "In Progress", "On Hold", "Done", "Cancelled"] as const;
+
+function formatDateTime(value: string): string {
+ if (!value) {
+ return "—";
+ }
+
+ const normalized = value.endsWith("Z") ? value : `${value}Z`;
+ const date = new Date(normalized);
+
+ if (Number.isNaN(date.getTime())) {
+ return value;
+ }
+
+ const datePart = date.toLocaleDateString("en-CA");
+ const timePart = date.toLocaleTimeString(undefined, {
+ hour: "2-digit",
+ minute: "2-digit",
+ hour12: false,
+ });
+ const tz = date.toLocaleTimeString(undefined, { timeZoneName: "short" }).split(" ").pop();
+
+ return `${datePart} ${timePart} ${tz ?? ""}`.trim();
+}
+
+export default function WorkOrdersListPage() {
+ const navigate = useNavigate();
+ const [search, setSearch] = useState("");
+ const [statusFilter, setStatusFilter] = useState("");
+ const [assigneeFilter, setAssigneeFilter] = useState("");
+ const [page, setPage] = useState(0);
+ const [pageSize, setPageSize] = useState(12);
+ const [sortBy, setSortBy] = useState("");
+ const [sortDir, setSortDir] = useState<"asc" | "desc" | "">("");
+
+ const debouncedSearch = useDebounce(search, 300);
+
+ const listParams = useMemo(
+ () => ({
+ search: debouncedSearch,
+ page: page + 1,
+ pageSize,
+ sortBy,
+ sortDir,
+ status: statusFilter,
+ assignee: assigneeFilter,
+ }),
+ [debouncedSearch, page, pageSize, sortBy, sortDir, statusFilter, assigneeFilter],
+ );
+
+ const { data, isLoading, isFetching, error } = useWorkOrdersList(listParams);
+ const { data: users = [] } = useWorkOrderUsers();
+
+ const rows = data?.items ?? [];
+ const totalCount = data?.totalCount ?? 0;
+
+ const handleSort = (column: string) => {
+ if (sortBy === column) {
+ setSortDir((current) => (current === "asc" ? "desc" : current === "desc" ? "" : "asc"));
+ if (sortDir === "desc") {
+ setSortBy("");
+ }
+ return;
+ }
+
+ setSortBy(column);
+ setSortDir("asc");
+ };
+
+ const sortIndicator = (column: string) => {
+ if (sortBy !== column) {
+ return "";
+ }
+ return sortDir === "desc" ? " ↓" : " ↑";
+ };
+
+ const handleStatusFilter = (status: string) => {
+ setStatusFilter((current) => (current === status ? "" : status));
+ setPage(0);
+ };
+
+ return (
+
+
+
+
+ Work Orders
+
+
+ {totalCount} total
+
+ {isFetching && !isLoading && }
+
+
+
+ {
+ setSearch(event.target.value);
+ setPage(0);
+ }}
+ placeholder="Search..."
+ className="min-w-[220px]"
+ />
+
+
+
+
+
+ {
+ setStatusFilter("");
+ setPage(0);
+ }}
+ />
+ {STATUS_OPTIONS.map((status) => (
+ handleStatusFilter(status)}
+ />
+ ))}
+
+
+ Assignee
+
+
+
+
+ {error && (
+
+ {error instanceof Error ? error.message : "Failed to load work orders"}
+
+ )}
+
+
+
+
+
+ handleSort("number")}
+ >
+ WO #{sortIndicator("number")}
+
+ handleSort("title")}
+ >
+ Title{sortIndicator("title")}
+
+ Location
+ Priority
+ Status
+ Created
+ Assigned To
+ Last Updated
+
+ Actions
+
+
+
+
+ {isLoading ? (
+
+
+
+
+
+ ) : rows.length === 0 ? (
+
+
+ No work orders found
+
+
+ ) : (
+ rows.map((row: WorkOrderListItem) => (
+
+
+
+
+ {row.title}
+ {row.location}
+ {row.priority}
+
+
+
+
+ {formatDateTime(row.createdDate)}
+
+ {row.assignedTo || "Unassigned"}
+
+ {formatDateTime(row.lastUpdated)}
+
+
+
+ navigate(`/workorders/${row.id}`)}
+ >
+
+
+
+
+ navigate(`/workorders/edit/${row.id}`)}
+ >
+
+
+
+
+
+ ))
+ )}
+
+
+
+
+ setPage(nextPage)}
+ rowsPerPage={pageSize}
+ onRowsPerPageChange={(event) => {
+ setPageSize(Number(event.target.value));
+ setPage(0);
+ }}
+ rowsPerPageOptions={[12, 24, 48]}
+ />
+
+ );
+}
diff --git a/src/app/(protected)/workorders/new.tsx b/src/app/(protected)/workorders/new.tsx
new file mode 100644
index 00000000..15308687
--- /dev/null
+++ b/src/app/(protected)/workorders/new.tsx
@@ -0,0 +1 @@
+export { default } from "./_components/work-order-form";
diff --git a/src/domain/work-orders/api/work-orders-api.ts b/src/domain/work-orders/api/work-orders-api.ts
new file mode 100644
index 00000000..5059ed57
--- /dev/null
+++ b/src/domain/work-orders/api/work-orders-api.ts
@@ -0,0 +1,256 @@
+import { API_PATHS } from "@/api/api-paths";
+import { apiDelete, apiGet, apiPost, apiPut } from "@/api/api";
+import { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response";
+import {
+ mapDispatchComment,
+ mapDispatchDetail,
+ mapDispatchSignoff,
+ mapDispatchToVendorPayload,
+ mapEmployeeOptions,
+ mapLocationOptions,
+ mapTaskListTemplates,
+ mapUpdateDispatchPayload,
+ mapVendorDropdownItems,
+ mapWorkOrderComment,
+ mapWorkOrderDetail,
+ mapWorkOrderFormToCreatePayload,
+ mapWorkOrderFormToUpdatePayload,
+ mapWorkOrderListItems,
+} from "@/domain/work-orders/mappers/work-order-mapper";
+import type { WorkOrderFormSchemaValues } from "@/domain/work-orders/schemas/work-order-schema";
+import type {
+ DispatchDetail,
+ DispatchToVendorPayload,
+ EmployeeOption,
+ LocationOption,
+ TaskListTemplate,
+ UpdateDispatchPayload,
+ VendorDropdownItem,
+ WorkOrderComment,
+ WorkOrderDetail,
+ WorkOrderFormValues,
+ WorkOrderUser,
+ WorkOrdersListParams,
+ WorkOrdersListResult,
+} from "@/domain/work-orders/types/work-order";
+
+function buildSearchParams(params: WorkOrdersListParams): Record {
+ const searchParams: Record = {
+ search: params.search ?? "",
+ page: params.page ?? 1,
+ pageSize: params.pageSize ?? 12,
+ };
+
+ if (params.sortBy) {
+ searchParams.sortBy = params.sortBy;
+ }
+ if (params.sortDir) {
+ searchParams.sortDir = params.sortDir;
+ }
+ if (params.status) {
+ searchParams.status = params.status;
+ }
+ if (params.assignee) {
+ searchParams.assignee = params.assignee;
+ }
+ if (params.locationId) {
+ searchParams.locationId = params.locationId;
+ }
+
+ return searchParams;
+}
+
+function mapUser(raw: unknown): WorkOrderUser {
+ const item = (typeof raw === "object" && raw !== null ? raw : {}) as Record;
+ const id = item.id ?? item.Id;
+ const name =
+ (typeof item.name === "string" ? item.name : undefined) ??
+ (typeof item.Name === "string" ? item.Name : undefined) ??
+ (typeof item.userName === "string" ? item.userName : "") ??
+ "";
+
+ return {
+ id: typeof id === "string" || typeof id === "number" ? id : "",
+ name,
+ };
+}
+
+export const workOrdersApi = {
+ getList: async (params: WorkOrdersListParams = {}): Promise => {
+ const searchParams = buildSearchParams(params);
+ const data = await apiGet(API_PATHS.workOrder.getList, { searchParams });
+ const paginated = handlePaginatedResponse(data, {
+ page: params.page,
+ pageSize: params.pageSize,
+ });
+
+ return {
+ items: mapWorkOrderListItems(paginated.items),
+ totalCount: paginated.totalCount,
+ page: paginated.page,
+ pageSize: paginated.pageSize,
+ };
+ },
+
+ fetchUsers: async (): Promise => {
+ const data = await apiGet(API_PATHS.user.list);
+ const users = handleApiResponse(data);
+ return Array.isArray(users) ? users.map(mapUser) : [];
+ },
+
+ getById: async (id: string | number): Promise => {
+ const data = await apiGet(`${API_PATHS.rest.workOrders}/${id}`);
+ return mapWorkOrderDetail(handleApiResponse(data));
+ },
+
+ create: async (
+ values: WorkOrderFormSchemaValues | WorkOrderFormValues,
+ ): Promise => {
+ const payload = mapWorkOrderFormToCreatePayload(values);
+ const data = await apiPost(API_PATHS.rest.workOrders, payload);
+ return mapWorkOrderDetail(handleApiResponse(data));
+ },
+
+ update: async (
+ id: string | number,
+ values: WorkOrderFormSchemaValues | WorkOrderFormValues,
+ ): Promise => {
+ const payload = mapWorkOrderFormToUpdatePayload(id, values);
+ const data = await apiPut(`${API_PATHS.rest.workOrders}/${id}`, payload);
+ return mapWorkOrderDetail(handleApiResponse(data));
+ },
+
+ delete: async (id: string | number): Promise => {
+ await apiDelete(`${API_PATHS.rest.workOrders}/${id}`);
+ },
+
+ addComment: async (
+ workOrderId: string | number,
+ text: string,
+ commentType: string,
+ ): Promise => {
+ const data = await apiPost(API_PATHS.workOrder.addCommentJson, {
+ WorkorderId: workOrderId,
+ Text: text,
+ CommentType: commentType,
+ });
+ return mapWorkOrderComment(handleApiResponse(data));
+ },
+
+ changeStatus: async (id: string | number, status: string): Promise => {
+ await apiPost(API_PATHS.workOrder.changeStatus, undefined, {
+ searchParams: { id, status },
+ });
+ },
+
+ changeAssignment: async (id: string | number, assignTo: string | number): Promise => {
+ await apiPost(API_PATHS.workOrder.changeAssignment, undefined, {
+ searchParams: { id, assignTo: assignTo || "" },
+ });
+ },
+
+ fetchLocations: async (): Promise => {
+ const data = await apiGet(API_PATHS.contact.getLocations);
+ const items = handleApiResponse(data);
+ return Array.isArray(items) ? mapLocationOptions(items) : [];
+ },
+
+ fetchEmployeesDropdown: async (): Promise => {
+ const data = await apiGet(API_PATHS.employee.dropdown);
+ const items = handleApiResponse(data);
+ return Array.isArray(items) ? mapEmployeeOptions(items) : [];
+ },
+
+ fetchVendorsDropdown: async (trade?: string, siteZip?: string): Promise => {
+ const searchParams: Record = {};
+ if (trade) {
+ searchParams.trade = trade;
+ }
+ if (siteZip) {
+ searchParams.siteZip = siteZip;
+ }
+
+ const data = await apiGet(API_PATHS.vendor.dropdown, {
+ searchParams: Object.keys(searchParams).length > 0 ? searchParams : undefined,
+ });
+ const items = handleApiResponse(data);
+ return Array.isArray(items) ? mapVendorDropdownItems(items) : [];
+ },
+
+ fetchTaskTemplates: async (): Promise => {
+ const data = await apiGet(API_PATHS.taskListTemplate.list);
+ const items = handleApiResponse(data);
+ return Array.isArray(items) ? mapTaskListTemplates(items) : [];
+ },
+
+ dispatchToVendor: async (payload: DispatchToVendorPayload): Promise => {
+ await apiPost(
+ API_PATHS.workOrder.dispatchToVendor,
+ mapDispatchToVendorPayload(payload),
+ );
+ },
+
+ getDispatchById: async (id: string | number): Promise => {
+ const data = await apiGet(`${API_PATHS.workOrder.getDispatch}/${id}`);
+ return mapDispatchDetail(handleApiResponse(data));
+ },
+
+ updateDispatch: async (payload: UpdateDispatchPayload): Promise => {
+ await apiPost(API_PATHS.workOrder.updateDispatch, mapUpdateDispatchPayload(payload));
+ },
+
+ addDispatchComment: async (
+ dispatchId: string | number,
+ text: string,
+ sendEmail = true,
+ ): Promise => {
+ const data = await apiPost(API_PATHS.workOrder.addDispatchComment, {
+ DispatchId: dispatchId,
+ Text: text,
+ SendEmail: sendEmail,
+ });
+ return mapDispatchComment(handleApiResponse(data));
+ },
+
+ updateChecklistItem: async (
+ id: string | number,
+ isCompleted: boolean,
+ completedBy: string,
+ ): Promise => {
+ await apiPost(API_PATHS.workOrder.updateChecklistItem, {
+ Id: id,
+ IsCompleted: isCompleted,
+ CompletedBy: completedBy,
+ });
+ },
+
+ addChecklistItem: async (dispatchId: string | number, itemText: string): Promise => {
+ await apiPost(API_PATHS.workOrder.addChecklistItem, {
+ DispatchId: dispatchId,
+ ItemText: itemText,
+ });
+ },
+
+ addDispatchSignoff: async (
+ dispatchId: string | number,
+ signoffType: string,
+ name: string,
+ signature: string,
+ signatureMethod: string,
+ ): Promise => {
+ const data = await apiPost(API_PATHS.workOrder.addDispatchSignoff, {
+ DispatchId: dispatchId,
+ SignoffType: signoffType,
+ Name: name,
+ Signature: signature,
+ SignatureMethod: signatureMethod,
+ });
+ return mapDispatchSignoff(handleApiResponse(data));
+ },
+
+ verifyDispatch: async (dispatchId: string | number): Promise => {
+ await apiPost(API_PATHS.workOrder.verifyDispatch, undefined, {
+ searchParams: { dispatchId },
+ });
+ },
+};
diff --git a/src/domain/work-orders/mappers/work-order-mapper.ts b/src/domain/work-orders/mappers/work-order-mapper.ts
new file mode 100644
index 00000000..a1022ac6
--- /dev/null
+++ b/src/domain/work-orders/mappers/work-order-mapper.ts
@@ -0,0 +1,443 @@
+import type {
+ DispatchChecklistItem,
+ DispatchComment,
+ DispatchDetail,
+ DispatchSignoff,
+ DispatchToVendorPayload,
+ DispatchWorkOrderRef,
+ EmployeeOption,
+ LocationOption,
+ TaskListTemplate,
+ UpdateDispatchPayload,
+ UpliftRequest,
+ VendorDropdownItem,
+ WorkOrderAuditEntry,
+ WorkOrderComment,
+ WorkOrderDetail,
+ WorkOrderDispatchSummary,
+ WorkOrderFormValues,
+ WorkOrderListItem,
+} from "@/domain/work-orders/types/work-order";
+import { toDateInputValue } from "@/lib/time-utils";
+
+function asRecord(raw: unknown): Record {
+ return typeof raw === "object" && raw !== null ? (raw as Record) : {};
+}
+
+function readString(record: Record, ...keys: string[]): string {
+ for (const key of keys) {
+ const value = record[key];
+ if (typeof value === "string") {
+ return value;
+ }
+ if (typeof value === "number") {
+ return String(value);
+ }
+ }
+ return "";
+}
+
+function readId(record: Record): string | number {
+ const value = record.id ?? record.Id;
+ if (typeof value === "string" || typeof value === "number") {
+ return value;
+ }
+ return "";
+}
+
+function readNumber(record: Record, ...keys: string[]): number | null {
+ for (const key of keys) {
+ const value = record[key];
+ if (typeof value === "number") {
+ return value;
+ }
+ if (typeof value === "string" && value !== "") {
+ const parsed = Number(value);
+ if (!Number.isNaN(parsed)) {
+ return parsed;
+ }
+ }
+ }
+ return null;
+}
+
+function readBool(record: Record, ...keys: string[]): boolean {
+ for (const key of keys) {
+ const value = record[key];
+ if (typeof value === "boolean") {
+ return value;
+ }
+ }
+ return false;
+}
+
+export function mapWorkOrderListItem(raw: unknown): WorkOrderListItem {
+ const item = asRecord(raw);
+
+ return {
+ id: readId(item),
+ number: readString(item, "internalWONumber", "number", "workOrderNumber"),
+ title: readString(item, "title", "Title"),
+ location: readString(item, "location", "Location", "locationName", "LocationName"),
+ priority: readString(item, "priority", "Priority"),
+ status: readString(item, "status", "Status"),
+ createdDate: readString(item, "createdDate", "CreatedDate"),
+ dueDate: readString(item, "dueDate", "DueDate"),
+ assignedTo: readString(item, "assignedTo", "AssignedTo", "employeeName", "EmployeeName"),
+ lastUpdated: readString(item, "lastUpdated", "LastUpdated"),
+ };
+}
+
+export function mapWorkOrderListItems(rawItems: unknown[]): WorkOrderListItem[] {
+ return rawItems.map(mapWorkOrderListItem);
+}
+
+export function mapWorkOrderComment(raw: unknown): WorkOrderComment {
+ const item = asRecord(raw);
+
+ return {
+ id: readId(item),
+ createdDate: readString(item, "createdDate", "CreatedDate"),
+ commenttext: readString(item, "commenttext", "Commenttext", "text", "Text"),
+ commentType: readString(item, "commentType", "CommentType"),
+ userName: readString(item, "userName", "UserName"),
+ commenter: readString(item, "commenter", "Commenter") || null,
+ recordType: readString(item, "recordType", "RecordType") || null,
+ };
+}
+
+export function mapWorkOrderAuditEntry(raw: unknown): WorkOrderAuditEntry {
+ const item = asRecord(raw);
+
+ return {
+ id: readId(item),
+ fieldName: readString(item, "fieldName", "FieldName"),
+ oldValue: readString(item, "oldValue", "OldValue"),
+ newValue: readString(item, "newValue", "NewValue"),
+ action: readString(item, "action", "Action"),
+ createdAt: readString(item, "createdAt", "CreatedAt"),
+ userName: readString(item, "userName", "UserName"),
+ };
+}
+
+export function mapWorkOrderDispatchSummary(raw: unknown): WorkOrderDispatchSummary {
+ const item = asRecord(raw);
+
+ return {
+ id: readId(item),
+ dispatchNumber: readString(item, "dispatchNumber", "DispatchNumber"),
+ poNumber: readString(item, "poNumber", "PoNumber", "PONumber"),
+ vendorName: readString(item, "vendorName", "VendorName"),
+ nteAmount: readNumber(item, "nteAmount", "NTEAmount"),
+ status: readString(item, "status", "Status"),
+ dispatchedAt: readString(item, "dispatchedAt", "DispatchedAt"),
+ };
+}
+
+export function mapWorkOrderDetail(raw: unknown): WorkOrderDetail {
+ const wo = asRecord(raw);
+ const locationId = wo.locationId ?? wo.LocationId;
+
+ return {
+ id: readId(wo),
+ internalWONumber: readString(wo, "internalWONumber", "InternalWONumber"),
+ workOrderNumber: readString(wo, "workerOrderNumber", "workOrderNumber", "WorkerOrderNumber"),
+ title: readString(wo, "title", "workerOrderTitle", "WorkerOrderTitle"),
+ location: readString(wo, "location", "Location"),
+ locationId: locationId != null ? String(locationId) : "",
+ locationZip: readString(wo, "locationZip", "LocationZip"),
+ assignedTo: readString(wo, "assignee", "assignedTo", "Assignee", "AssignedTo"),
+ dueDate: toDateInputValue(readString(wo, "dueDate", "DueDate")),
+ createdDate: toDateInputValue(readString(wo, "createdDate", "CreatedDate")),
+ createdDateFull: readString(wo, "createdDate", "CreatedDate"),
+ description: readString(wo, "description", "Description"),
+ priority: readString(wo, "priority", "Priority"),
+ status: readString(wo, "status", "Status") || "Open",
+ po: readString(wo, "po", "PO"),
+ tt: readString(wo, "tt", "TT"),
+ problem: readString(wo, "problem", "Problem"),
+ trade: readString(wo, "trade", "Trade"),
+ subTrade: readString(wo, "subTrade", "SubTrade"),
+ vendorNTE: wo.vendorNTE != null ? String(wo.vendorNTE) : "",
+ scheduledDate: toDateInputValue(readString(wo, "scheduledDate", "ScheduledDate")),
+ completedDate: toDateInputValue(readString(wo, "completedDate", "CompletedDate")),
+ source: readString(wo, "source", "Source"),
+ comments: Array.isArray(wo.comments)
+ ? wo.comments.map(mapWorkOrderComment)
+ : Array.isArray(wo.Comments)
+ ? wo.Comments.map(mapWorkOrderComment)
+ : [],
+ auditLog: Array.isArray(wo.auditLog)
+ ? wo.auditLog.map(mapWorkOrderAuditEntry)
+ : Array.isArray(wo.AuditLog)
+ ? wo.AuditLog.map(mapWorkOrderAuditEntry)
+ : [],
+ dispatches: Array.isArray(wo.dispatches)
+ ? wo.dispatches.map(mapWorkOrderDispatchSummary)
+ : Array.isArray(wo.Dispatches)
+ ? wo.Dispatches.map(mapWorkOrderDispatchSummary)
+ : [],
+ };
+}
+
+export function mapWorkOrderDetailToFormValues(detail: WorkOrderDetail): WorkOrderFormValues {
+ return {
+ internalWONumber: detail.internalWONumber,
+ workOrderNumber: detail.workOrderNumber,
+ title: detail.title,
+ locationId: detail.locationId,
+ priority: detail.priority,
+ status: detail.status,
+ createdDate: detail.createdDate,
+ dueDate: detail.dueDate,
+ scheduledDate: detail.scheduledDate,
+ completedDate: detail.completedDate,
+ assignedTo: detail.assignedTo,
+ description: detail.description,
+ po: detail.po,
+ tt: detail.tt,
+ problem: detail.problem,
+ trade: detail.trade,
+ subTrade: detail.subTrade,
+ vendorNTE: detail.vendorNTE,
+ source: detail.source,
+ };
+}
+
+export function mapWorkOrderFormToCreatePayload(
+ values: WorkOrderFormValues,
+): Record {
+ return {
+ workOrderNumber: values.workOrderNumber,
+ title: values.title,
+ locationId: values.locationId ? parseInt(values.locationId, 10) : null,
+ priority: values.priority || null,
+ status: values.status,
+ createdDate: values.createdDate || null,
+ dueDate: values.dueDate || null,
+ scheduledDate: values.scheduledDate || null,
+ completedDate: values.completedDate || null,
+ assignedTo: values.assignedTo || null,
+ description: values.description,
+ po: values.po || null,
+ tt: values.tt || null,
+ problem: values.problem || null,
+ trade: values.trade || null,
+ subTrade: values.subTrade || null,
+ vendorNTE: values.vendorNTE ? parseFloat(values.vendorNTE) : null,
+ source: values.source || null,
+ };
+}
+
+export function mapWorkOrderFormToUpdatePayload(
+ id: string | number,
+ values: WorkOrderFormValues,
+): Record {
+ return {
+ Id: id,
+ WorkerOrderNumber: values.workOrderNumber,
+ WorkerOrderTitle: values.title,
+ Description: values.description,
+ PO: values.po || null,
+ TT: values.tt || null,
+ AssignTo: values.assignedTo || null,
+ DueDate: values.dueDate || null,
+ Priority: values.priority || null,
+ Status: values.status,
+ LocationId: values.locationId ? parseInt(values.locationId, 10) : null,
+ Problem: values.problem || null,
+ Trade: values.trade || null,
+ SubTrade: values.subTrade || null,
+ VendorNTE: values.vendorNTE ? parseFloat(values.vendorNTE) : null,
+ ScheduledDate: values.scheduledDate || null,
+ CompletedDate: values.completedDate || null,
+ CreatedDate: values.createdDate || null,
+ Source: values.source || null,
+ };
+}
+
+export function mapLocationOption(raw: unknown): LocationOption {
+ const item = asRecord(raw);
+
+ return {
+ id: readId(item),
+ name: readString(item, "name", "Name", "locationName", "LocationName"),
+ };
+}
+
+export function mapLocationOptions(rawItems: unknown[]): LocationOption[] {
+ return rawItems.map(mapLocationOption);
+}
+
+export function mapEmployeeOption(raw: unknown): EmployeeOption {
+ const item = asRecord(raw);
+
+ return {
+ id: readId(item),
+ name:
+ readString(item, "name", "Name", "fullName", "FullName", "employeeName", "EmployeeName") ||
+ readString(item, "userName", "UserName"),
+ };
+}
+
+export function mapEmployeeOptions(rawItems: unknown[]): EmployeeOption[] {
+ return rawItems.map(mapEmployeeOption);
+}
+
+export function mapVendorDropdownItem(raw: unknown): VendorDropdownItem {
+ const item = asRecord(raw);
+
+ return {
+ id: readId(item),
+ companyName: readString(item, "companyName", "CompanyName"),
+ tradeSpecialties: readString(item, "tradeSpecialties", "TradeSpecialties"),
+ address: readString(item, "address", "Address"),
+ distanceMiles: readNumber(item, "distanceMiles", "DistanceMiles"),
+ };
+}
+
+export function mapVendorDropdownItems(rawItems: unknown[]): VendorDropdownItem[] {
+ return rawItems.map(mapVendorDropdownItem);
+}
+
+export function mapTaskListTemplate(raw: unknown): TaskListTemplate {
+ const item = asRecord(raw);
+ const items = item.items ?? item.Items;
+
+ return {
+ id: readId(item),
+ name: readString(item, "name", "Name"),
+ items: Array.isArray(items) ? items : [],
+ };
+}
+
+export function mapTaskListTemplates(rawItems: unknown[]): TaskListTemplate[] {
+ return rawItems.map(mapTaskListTemplate);
+}
+
+export function mapDispatchChecklistItem(raw: unknown): DispatchChecklistItem {
+ const item = asRecord(raw);
+
+ return {
+ id: readId(item),
+ itemText: readString(item, "itemText", "ItemText"),
+ isCompleted: readBool(item, "isCompleted", "IsCompleted"),
+ completedBy: readString(item, "completedBy", "CompletedBy") || null,
+ completedAt: readString(item, "completedAt", "CompletedAt") || null,
+ };
+}
+
+export function mapDispatchSignoff(raw: unknown): DispatchSignoff {
+ const item = asRecord(raw);
+
+ return {
+ id: readId(item),
+ signoffType: readString(item, "signoffType", "SignoffType"),
+ name: readString(item, "name", "Name"),
+ signature: readString(item, "signature", "Signature"),
+ signatureMethod: readString(item, "signatureMethod", "SignatureMethod"),
+ signedAt: readString(item, "signedAt", "SignedAt"),
+ };
+}
+
+export function mapDispatchComment(raw: unknown): DispatchComment {
+ const item = asRecord(raw);
+
+ return {
+ id: readId(item),
+ createdDate: readString(item, "createdDate", "CreatedDate"),
+ commenttext: readString(item, "commenttext", "Commenttext", "text", "Text"),
+ userName: readString(item, "userName", "UserName"),
+ commenter: readString(item, "commenter", "Commenter") || null,
+ };
+}
+
+export function mapDispatchWorkOrderRef(raw: unknown): DispatchWorkOrderRef {
+ const item = asRecord(raw);
+
+ return {
+ id: readId(item),
+ woNumber: readString(item, "woNumber", "WoNumber", "WONumber", "number", "Number"),
+ };
+}
+
+export function mapDispatchDetail(raw: unknown): DispatchDetail {
+ const item = asRecord(raw);
+ const checklistItems = item.checklistItems ?? item.ChecklistItems;
+ const signoffs = item.signoffs ?? item.Signoffs;
+ const comments = item.comments ?? item.Comments;
+ const workOrders = item.workOrders ?? item.WorkOrders;
+
+ return {
+ id: readId(item),
+ dispatchNumber: readString(item, "dispatchNumber", "DispatchNumber"),
+ poNumber: readString(item, "poNumber", "PoNumber", "PONumber"),
+ vendorName: readString(item, "vendorName", "VendorName"),
+ vendorEmail: readString(item, "vendorEmail", "VendorEmail"),
+ woNumber: readString(item, "woNumber", "WoNumber"),
+ status: readString(item, "status", "Status"),
+ nteAmount: readNumber(item, "nteAmount", "NTEAmount"),
+ scheduledDate: readString(item, "scheduledDate", "ScheduledDate"),
+ completedDate: readString(item, "completedDate", "CompletedDate"),
+ description: readString(item, "description", "Description"),
+ dispatchedAt: readString(item, "dispatchedAt", "DispatchedAt"),
+ acknowledgedAt: readString(item, "acknowledgedAt", "AcknowledgedAt"),
+ verifiedAt: readString(item, "verifiedAt", "VerifiedAt"),
+ verifiedBy: readString(item, "verifiedBy", "VerifiedBy"),
+ checklistItems: Array.isArray(checklistItems)
+ ? checklistItems.map(mapDispatchChecklistItem)
+ : [],
+ signoffs: Array.isArray(signoffs) ? signoffs.map(mapDispatchSignoff) : [],
+ comments: Array.isArray(comments) ? comments.map(mapDispatchComment) : [],
+ workOrders: Array.isArray(workOrders) ? workOrders.map(mapDispatchWorkOrderRef) : [],
+ };
+}
+
+export function mapDispatchToVendorPayload(
+ payload: DispatchToVendorPayload,
+): Record {
+ return {
+ WorkOrderIds: payload.workOrderIds,
+ VendorIds: payload.vendorIds,
+ NTEAmount: payload.nteAmount,
+ ScheduledDate: payload.scheduledDate,
+ Description: payload.description,
+ TaskListTemplateId: payload.taskListTemplateId,
+ CustomChecklistItems: payload.customChecklistItems,
+ };
+}
+
+export function mapUpdateDispatchPayload(payload: UpdateDispatchPayload): Record {
+ return {
+ Id: payload.id,
+ Status: payload.status,
+ NTEAmount: payload.nteAmount,
+ ScheduledDate: payload.scheduledDate,
+ CompletedDate: payload.completedDate,
+ Description: payload.description,
+ };
+}
+
+export function mapUpliftRequest(raw: unknown): UpliftRequest {
+ const item = asRecord(raw);
+
+ return {
+ id: readId(item),
+ status: readString(item, "status", "Status"),
+ currentNTE: readNumber(item, "currentNTE", "CurrentNTE"),
+ requestedNTE: readNumber(item, "requestedNTE", "RequestedNTE") ?? 0,
+ delta: readNumber(item, "delta", "Delta") ?? 0,
+ vendorReason: readString(item, "vendorReason", "VendorReason"),
+ requestedAt: readString(item, "requestedAt", "RequestedAt"),
+ requestedByVendorName: readString(item, "requestedByVendorName", "RequestedByVendorName"),
+ decidedAt: readString(item, "decidedAt", "DecidedAt"),
+ decidedByName: readString(item, "decidedByName", "DecidedByName"),
+ decisionNote: readString(item, "decisionNote", "DecisionNote"),
+ requiredTier: readNumber(item, "requiredTier", "RequiredTier") ?? 1,
+ canDecide: readBool(item, "canDecide", "CanDecide"),
+ };
+}
+
+export function mapUpliftRequests(rawItems: unknown[]): UpliftRequest[] {
+ return rawItems.map(mapUpliftRequest);
+}
diff --git a/src/domain/work-orders/schemas/work-order-schema.ts b/src/domain/work-orders/schemas/work-order-schema.ts
new file mode 100644
index 00000000..a675089b
--- /dev/null
+++ b/src/domain/work-orders/schemas/work-order-schema.ts
@@ -0,0 +1,36 @@
+import { z } from "zod";
+import {
+ WORK_ORDER_PRIORITY_OPTIONS,
+ WORK_ORDER_STATUS_OPTIONS,
+} from "@/domain/work-orders/types/work-order";
+
+export const workOrderFormSchema = z.object({
+ internalWONumber: z.string(),
+ workOrderNumber: z.string(),
+ title: z.string().min(1, "Title is required"),
+ locationId: z.string().min(1, "Location is required"),
+ priority: z
+ .string()
+ .min(1, "Priority is required")
+ .refine(
+ (value) =>
+ WORK_ORDER_PRIORITY_OPTIONS.includes(value as (typeof WORK_ORDER_PRIORITY_OPTIONS)[number]),
+ "Priority is required",
+ ),
+ status: z.enum(WORK_ORDER_STATUS_OPTIONS),
+ createdDate: z.string().min(1, "Created date is required"),
+ dueDate: z.string(),
+ scheduledDate: z.string(),
+ completedDate: z.string(),
+ assignedTo: z.string(),
+ description: z.string(),
+ po: z.string(),
+ tt: z.string(),
+ problem: z.string(),
+ trade: z.string(),
+ subTrade: z.string(),
+ vendorNTE: z.string(),
+ source: z.string(),
+});
+
+export type WorkOrderFormSchemaValues = z.infer;
diff --git a/src/domain/work-orders/types/work-order.ts b/src/domain/work-orders/types/work-order.ts
new file mode 100644
index 00000000..34f63bc5
--- /dev/null
+++ b/src/domain/work-orders/types/work-order.ts
@@ -0,0 +1,270 @@
+export interface WorkOrderListItem {
+ id: string | number;
+ number: string;
+ title: string;
+ location: string;
+ priority: string;
+ status: string;
+ createdDate: string;
+ dueDate: string;
+ assignedTo: string;
+ lastUpdated: string;
+}
+
+export interface WorkOrdersListParams {
+ search?: string;
+ page?: number;
+ pageSize?: number;
+ sortBy?: string;
+ sortDir?: string;
+ status?: string;
+ assignee?: string;
+ locationId?: string | number;
+}
+
+export interface WorkOrderUser {
+ id: string | number;
+ name: string;
+}
+
+export interface WorkOrdersListResult {
+ items: WorkOrderListItem[];
+ totalCount: number;
+ page: number;
+ pageSize: number;
+}
+
+export interface WorkOrderComment {
+ id: string | number;
+ createdDate: string;
+ commenttext: string;
+ commentType: string;
+ userName: string;
+ commenter: string | null;
+ recordType: string | null;
+}
+
+export interface WorkOrderAuditEntry {
+ id: string | number;
+ fieldName: string;
+ oldValue: string;
+ newValue: string;
+ action: string;
+ createdAt: string;
+ userName: string;
+}
+
+export interface WorkOrderDispatchSummary {
+ id: string | number;
+ dispatchNumber: string;
+ poNumber: string;
+ vendorName: string;
+ nteAmount: number | null;
+ status: string;
+ dispatchedAt: string;
+}
+
+export interface WorkOrderDetail {
+ id: string | number;
+ internalWONumber: string;
+ workOrderNumber: string;
+ title: string;
+ location: string;
+ locationId: string;
+ locationZip: string;
+ assignedTo: string;
+ dueDate: string;
+ createdDate: string;
+ createdDateFull: string;
+ description: string;
+ priority: string;
+ status: string;
+ po: string;
+ tt: string;
+ problem: string;
+ trade: string;
+ subTrade: string;
+ vendorNTE: string;
+ scheduledDate: string;
+ completedDate: string;
+ source: string;
+ comments: WorkOrderComment[];
+ auditLog: WorkOrderAuditEntry[];
+ dispatches: WorkOrderDispatchSummary[];
+}
+
+export interface WorkOrderFormValues {
+ internalWONumber: string;
+ workOrderNumber: string;
+ title: string;
+ locationId: string;
+ priority: string;
+ status: string;
+ createdDate: string;
+ dueDate: string;
+ scheduledDate: string;
+ completedDate: string;
+ assignedTo: string;
+ description: string;
+ po: string;
+ tt: string;
+ problem: string;
+ trade: string;
+ subTrade: string;
+ vendorNTE: string;
+ source: string;
+}
+
+export interface LocationOption {
+ id: string | number;
+ name: string;
+}
+
+export interface EmployeeOption {
+ id: string | number;
+ name: string;
+}
+
+export interface VendorDropdownItem {
+ id: string | number;
+ companyName: string;
+ tradeSpecialties: string;
+ address: string;
+ distanceMiles: number | null;
+}
+
+export interface TaskListTemplate {
+ id: string | number;
+ name: string;
+ items: { id?: string | number; itemText?: string }[];
+}
+
+export interface DispatchChecklistItem {
+ id: string | number;
+ itemText: string;
+ isCompleted: boolean;
+ completedBy: string | null;
+ completedAt: string | null;
+}
+
+export interface DispatchSignoff {
+ id: string | number;
+ signoffType: string;
+ name: string;
+ signature: string;
+ signatureMethod: string;
+ signedAt: string;
+}
+
+export interface DispatchComment {
+ id: string | number;
+ createdDate: string;
+ commenttext: string;
+ userName: string;
+ commenter: string | null;
+}
+
+export interface DispatchWorkOrderRef {
+ id: string | number;
+ woNumber: string;
+}
+
+export interface DispatchDetail {
+ id: string | number;
+ dispatchNumber: string;
+ poNumber: string;
+ vendorName: string;
+ vendorEmail: string;
+ woNumber: string;
+ status: string;
+ nteAmount: number | null;
+ scheduledDate: string;
+ completedDate: string;
+ description: string;
+ dispatchedAt: string;
+ acknowledgedAt: string;
+ verifiedAt: string;
+ verifiedBy: string;
+ checklistItems: DispatchChecklistItem[];
+ signoffs: DispatchSignoff[];
+ comments: DispatchComment[];
+ workOrders: DispatchWorkOrderRef[];
+}
+
+export interface DispatchToVendorPayload {
+ workOrderIds: (string | number)[];
+ vendorIds: (string | number)[];
+ nteAmount: number | null;
+ scheduledDate: string | null;
+ description: string;
+ taskListTemplateId: number | null;
+ customChecklistItems: string[] | null;
+}
+
+export interface UpdateDispatchPayload {
+ id: string | number;
+ status: string;
+ nteAmount: number | null;
+ scheduledDate: string | null;
+ completedDate: string | null;
+ description: string;
+}
+
+export interface UpliftRequest {
+ id: string | number;
+ status: string;
+ currentNTE: number | null;
+ requestedNTE: number;
+ delta: number;
+ vendorReason: string;
+ requestedAt: string;
+ requestedByVendorName: string;
+ decidedAt: string;
+ decidedByName: string;
+ decisionNote: string;
+ requiredTier: number;
+ canDecide: boolean;
+}
+
+export const WORK_ORDER_STATUS_OPTIONS = [
+ "Open",
+ "In Progress",
+ "On Hold",
+ "Done",
+ "Cancelled",
+] as const;
+
+export const WORK_ORDER_PRIORITY_OPTIONS = ["Sev 1", "Sev 2", "Sev 3", "Sev 4", "Sev 5"] as const;
+
+export const DISPATCH_STATUS_OPTIONS = [
+ "Sent",
+ "Acknowledged",
+ "In Progress",
+ "Completed",
+ "Verified",
+ "Cancelled",
+] as const;
+
+export function createDefaultWorkOrderFormValues(): WorkOrderFormValues {
+ return {
+ internalWONumber: "",
+ workOrderNumber: "",
+ title: "",
+ locationId: "",
+ priority: "",
+ status: "Open",
+ createdDate: new Date().toISOString().split("T")[0] ?? "",
+ dueDate: "",
+ scheduledDate: "",
+ completedDate: "",
+ assignedTo: "",
+ description: "",
+ po: "",
+ tt: "",
+ problem: "",
+ trade: "",
+ subTrade: "",
+ vendorNTE: "",
+ source: "",
+ };
+}
diff --git a/src/domain/work-orders/use-cases/use-work-order-detail.ts b/src/domain/work-orders/use-cases/use-work-order-detail.ts
new file mode 100644
index 00000000..5b451fc6
--- /dev/null
+++ b/src/domain/work-orders/use-cases/use-work-order-detail.ts
@@ -0,0 +1,67 @@
+import { useQuery, type UseQueryResult } from "@tanstack/react-query";
+import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
+import type {
+ DispatchDetail,
+ EmployeeOption,
+ LocationOption,
+ TaskListTemplate,
+ VendorDropdownItem,
+ WorkOrderDetail,
+} from "@/domain/work-orders/types/work-order";
+import { queryKeys } from "@/infra/query-key/query-key";
+import { requireQueryParam } from "@/lib/query/require-query-param";
+
+export function useWorkOrderDetail(
+ id: string | number | undefined,
+): UseQueryResult {
+ return useQuery({
+ queryKey: queryKeys.workOrders.detail(id ?? ""),
+ queryFn: () => workOrdersApi.getById(requireQueryParam(id, "Work order id")),
+ enabled: id !== undefined && id !== "",
+ });
+}
+
+export function useWorkOrderLocations(): UseQueryResult {
+ return useQuery({
+ queryKey: queryKeys.locations.dropdown(),
+ queryFn: () => workOrdersApi.fetchLocations(),
+ staleTime: 5 * 60 * 1000,
+ });
+}
+
+export function useWorkOrderEmployeesDropdown(): UseQueryResult {
+ return useQuery({
+ queryKey: [...queryKeys.employees.all, "dropdown"] as const,
+ queryFn: () => workOrdersApi.fetchEmployeesDropdown(),
+ staleTime: 5 * 60 * 1000,
+ });
+}
+
+export function useWorkOrderVendors(
+ trade?: string,
+ siteZip?: string,
+): UseQueryResult {
+ return useQuery({
+ queryKey: queryKeys.workOrders.vendors(trade, siteZip),
+ queryFn: () => workOrdersApi.fetchVendorsDropdown(trade, siteZip),
+ staleTime: 60 * 1000,
+ });
+}
+
+export function useTaskListTemplates(): UseQueryResult {
+ return useQuery({
+ queryKey: queryKeys.workOrders.taskTemplates(),
+ queryFn: () => workOrdersApi.fetchTaskTemplates(),
+ staleTime: 5 * 60 * 1000,
+ });
+}
+
+export function useDispatchDetail(
+ dispatchId: string | number | undefined,
+): UseQueryResult {
+ return useQuery({
+ queryKey: queryKeys.workOrders.dispatch(dispatchId ?? ""),
+ queryFn: () => workOrdersApi.getDispatchById(requireQueryParam(dispatchId, "Dispatch id")),
+ enabled: dispatchId !== undefined && dispatchId !== "",
+ });
+}
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
new file mode 100644
index 00000000..8c4f957f
--- /dev/null
+++ b/src/domain/work-orders/use-cases/use-work-order-mutations.ts
@@ -0,0 +1,268 @@
+import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
+import { useNavigate } from "react-router-dom";
+import { toast } from "react-toastify";
+import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
+import type { WorkOrderFormSchemaValues } from "@/domain/work-orders/schemas/work-order-schema";
+import type {
+ DispatchComment,
+ DispatchSignoff,
+ DispatchToVendorPayload,
+ UpdateDispatchPayload,
+ WorkOrderComment,
+ WorkOrderDetail,
+ WorkOrderFormValues,
+} from "@/domain/work-orders/types/work-order";
+import { queryKeys } from "@/infra/query-key/query-key";
+
+function invalidateWorkOrders(queryClient: ReturnType) {
+ void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
+}
+
+interface AddWorkOrderCommentInput {
+ text: string;
+ commentType: string;
+}
+
+interface ChangeWorkOrderAssignmentInput {
+ userId: string | number;
+ userName: string;
+}
+
+interface AddDispatchCommentInput {
+ text: string;
+ sendEmail: boolean;
+}
+
+interface UpdateChecklistItemInput {
+ itemId: string | number;
+ isCompleted: boolean;
+ completedBy: string;
+}
+
+interface AddDispatchSignoffInput {
+ signoffType: string;
+ name: string;
+ signature: string;
+ signatureMethod: string;
+}
+
+export function useCreateWorkOrder(): UseMutationResult<
+ WorkOrderDetail,
+ Error,
+ WorkOrderFormSchemaValues | WorkOrderFormValues
+> {
+ const queryClient = useQueryClient();
+ const navigate = useNavigate();
+
+ return useMutation({
+ mutationFn: (values: WorkOrderFormSchemaValues | WorkOrderFormValues) =>
+ workOrdersApi.create(values),
+ onSuccess: (data) => {
+ invalidateWorkOrders(queryClient);
+ toast.success("Work Order created successfully!");
+ navigate(`/workorders/${data.id}`);
+ },
+ onError: (error: Error) => {
+ toast.error(error.message || "Failed to create work order");
+ },
+ });
+}
+
+export function useUpdateWorkOrder(
+ id: string | number,
+): UseMutationResult {
+ const queryClient = useQueryClient();
+ const navigate = useNavigate();
+
+ return useMutation({
+ mutationFn: (values: WorkOrderFormSchemaValues | WorkOrderFormValues) =>
+ workOrdersApi.update(id, values),
+ onSuccess: () => {
+ invalidateWorkOrders(queryClient);
+ void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.detail(id) });
+ toast.success("Work Order updated successfully!");
+ navigate(`/workorders/${id}`);
+ },
+ onError: (error: Error) => {
+ toast.error(error.message || "Failed to update work order");
+ },
+ });
+}
+
+export function useAddWorkOrderComment(
+ workOrderId: string | number,
+): UseMutationResult {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: ({ text, commentType }: AddWorkOrderCommentInput) =>
+ workOrdersApi.addComment(workOrderId, text, commentType),
+ onSuccess: () => {
+ void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.detail(workOrderId) });
+ },
+ onError: (error: Error) => {
+ toast.error(error.message || "Failed to add comment");
+ },
+ });
+}
+
+export function useChangeWorkOrderStatus(
+ workOrderId: string | number,
+): UseMutationResult {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: (status: string) => workOrdersApi.changeStatus(workOrderId, status),
+ onSuccess: (_data, status) => {
+ void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.detail(workOrderId) });
+ invalidateWorkOrders(queryClient);
+ toast.success(`Status changed to ${status}`);
+ },
+ onError: (error: Error) => {
+ toast.error(error.message || "Failed to update status");
+ },
+ });
+}
+
+export function useChangeWorkOrderAssignment(
+ workOrderId: string | number,
+): UseMutationResult {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: ({ userId, userName }: ChangeWorkOrderAssignmentInput) =>
+ workOrdersApi.changeAssignment(workOrderId, userId).then(() => userName),
+ onSuccess: (userName) => {
+ void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.detail(workOrderId) });
+ invalidateWorkOrders(queryClient);
+ toast.success(`Assigned to ${userName || "Unassigned"}`);
+ },
+ onError: (error: Error) => {
+ toast.error(error.message || "Failed to update assignment");
+ },
+ });
+}
+
+export function useDispatchToVendor(
+ workOrderId: string | number,
+): UseMutationResult {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: workOrdersApi.dispatchToVendor,
+ onSuccess: () => {
+ void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.detail(workOrderId) });
+ invalidateWorkOrders(queryClient);
+ toast.success("Dispatched successfully");
+ },
+ onError: (error: Error) => {
+ toast.error(error.message || "Failed to dispatch");
+ },
+ });
+}
+
+export function useUpdateDispatch(
+ dispatchId: string | number,
+ workOrderId: string | number,
+): UseMutationResult {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: workOrdersApi.updateDispatch,
+ onSuccess: () => {
+ void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.dispatch(dispatchId) });
+ void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.detail(workOrderId) });
+ toast.success("Dispatch updated");
+ },
+ onError: (error: Error) => {
+ toast.error(error.message || "Failed to update dispatch");
+ },
+ });
+}
+
+export function useAddDispatchComment(
+ dispatchId: string | number,
+): UseMutationResult {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: ({ text, sendEmail }: AddDispatchCommentInput) =>
+ workOrdersApi.addDispatchComment(dispatchId, text, sendEmail),
+ onSuccess: () => {
+ void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.dispatch(dispatchId) });
+ },
+ onError: (error: Error) => {
+ toast.error(error.message || "Failed to send message");
+ },
+ });
+}
+
+export function useUpdateChecklistItem(
+ dispatchId: string | number,
+): UseMutationResult {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: ({ itemId, isCompleted, completedBy }: UpdateChecklistItemInput) =>
+ workOrdersApi.updateChecklistItem(itemId, isCompleted, completedBy),
+ onSuccess: () => {
+ void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.dispatch(dispatchId) });
+ },
+ onError: (error: Error) => {
+ toast.error(error.message || "Failed to update checklist item");
+ },
+ });
+}
+
+export function useAddChecklistItem(
+ dispatchId: string | number,
+): UseMutationResult {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: (itemText: string) => workOrdersApi.addChecklistItem(dispatchId, itemText),
+ onSuccess: () => {
+ void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.dispatch(dispatchId) });
+ },
+ onError: (error: Error) => {
+ toast.error(error.message || "Failed to add checklist item");
+ },
+ });
+}
+
+export function useAddDispatchSignoff(
+ dispatchId: string | number,
+): UseMutationResult {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: ({ signoffType, name, signature, signatureMethod }: AddDispatchSignoffInput) =>
+ workOrdersApi.addDispatchSignoff(dispatchId, signoffType, name, signature, signatureMethod),
+ onSuccess: (_data, variables) => {
+ void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.dispatch(dispatchId) });
+ toast.success(`${variables.signoffType} sign-off collected`);
+ },
+ onError: (error: Error) => {
+ toast.error(error.message || "Failed to save sign-off");
+ },
+ });
+}
+
+export function useVerifyDispatch(
+ dispatchId: string | number,
+ workOrderId: string | number,
+): UseMutationResult {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: () => workOrdersApi.verifyDispatch(dispatchId),
+ onSuccess: () => {
+ void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.dispatch(dispatchId) });
+ void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.detail(workOrderId) });
+ toast.success("Dispatch verified");
+ },
+ onError: (error: Error) => {
+ toast.error(error.message || "Failed to verify");
+ },
+ });
+}
diff --git a/src/domain/work-orders/use-cases/use-work-orders-list.ts b/src/domain/work-orders/use-cases/use-work-orders-list.ts
new file mode 100644
index 00000000..a0b6ef07
--- /dev/null
+++ b/src/domain/work-orders/use-cases/use-work-orders-list.ts
@@ -0,0 +1,26 @@
+import { keepPreviousData, useQuery, type UseQueryResult } from "@tanstack/react-query";
+import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
+import type {
+ WorkOrdersListParams,
+ WorkOrdersListResult,
+ WorkOrderUser,
+} from "@/domain/work-orders/types/work-order";
+import { queryKeys } from "@/infra/query-key/query-key";
+
+export function useWorkOrdersList(
+ params: WorkOrdersListParams,
+): UseQueryResult {
+ return useQuery({
+ queryKey: queryKeys.workOrders.list(params as Record),
+ queryFn: () => workOrdersApi.getList(params),
+ placeholderData: keepPreviousData,
+ });
+}
+
+export function useWorkOrderUsers(): UseQueryResult {
+ return useQuery({
+ queryKey: [...queryKeys.workOrders.all, "users"] as const,
+ queryFn: () => workOrdersApi.fetchUsers(),
+ staleTime: 5 * 60 * 1000,
+ });
+}
diff --git a/src/domain/work-orders/utils/is-dispatch-locked.ts b/src/domain/work-orders/utils/is-dispatch-locked.ts
new file mode 100644
index 00000000..41d3d82e
--- /dev/null
+++ b/src/domain/work-orders/utils/is-dispatch-locked.ts
@@ -0,0 +1,10 @@
+export function isDispatchLocked(dispatch: {
+ verifiedAt?: string | null;
+ status: string;
+}): boolean {
+ return (
+ Boolean(dispatch.verifiedAt) ||
+ dispatch.status === "Cancelled" ||
+ dispatch.status === "Verified"
+ );
+}