diff --git a/src/app/(protected)/workorders/[id].tsx b/src/app/(protected)/workorders/[id].tsx
index e9fb5b56..b9721508 100644
--- a/src/app/(protected)/workorders/[id].tsx
+++ b/src/app/(protected)/workorders/[id].tsx
@@ -1,282 +1,21 @@
-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 { CommentMentionComposer } from "@/components/common/comment-mention-composer";
-import { CommentTimeline } from "@/components/common/comment-timeline";
-import { mapWorkOrderCommentsToTimeline } from "@/components/common/comment-timeline-mappers";
+import { useParams } from "react-router";
+import { Alert, Box, CircularProgress } from "@mui/material";
import { DispatchCreateModal } from "./_components/dispatch-create-modal";
import { DispatchDetailModal } from "./_components/dispatch-detail-modal";
-import type { WorkOrderAuditEntry } 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,
- 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 { extractMentionIds } from "@/domain/work-orders/utils/comment-mentions";
-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 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}
-
-
- ))}
-
- );
-}
+import { WorkOrderActivityPanel } from "./_components/view/work-order-activity-panel";
+import { WorkOrderDispatchesAccordion } from "./_components/view/work-order-dispatches-accordion";
+import { WorkOrderVendorEditorDialog } from "./_components/view/work-order-vendor-editor-dialog";
+import { WorkOrderViewAccordions } from "./_components/view/work-order-view-accordions";
+import { WorkOrderViewDetails } from "./_components/view/work-order-view-details";
+import { WorkOrderViewHeader } from "./_components/view/work-order-view-header";
+import { useWorkOrderViewPage } from "./_hooks/use-work-order-view-page";
export default function WorkOrderViewPage() {
const { id } = useParams();
- const navigate = useNavigate();
const workOrderId = id ?? "";
+ const view = useWorkOrderViewPage(workOrderId);
- 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 mentionUsers = useMemo(
- () => users.map((user) => ({ id: user.id, name: user.name })),
- [users],
- );
- const customerTimelineItems = useMemo(
- () => mapWorkOrderCommentsToTimeline(customerComments),
- [customerComments],
- );
- const internalTimelineItems = useMemo(
- () => mapWorkOrderCommentsToTimeline(internalComments),
- [internalComments],
- );
-
- const handleCommentSubmit = (event: React.FormEvent) => {
- event.preventDefault();
- if (!commentText.trim()) {
- return;
- }
- if (activeTab !== "customer" && activeTab !== "internal") {
- return;
- }
-
- const trimmedText = commentText.trim();
- const mentions = extractMentionIds(trimmedText);
-
- commentMutation.mutate(
- {
- text: trimmedText,
- mentions: mentions.length > 0 ? mentions : undefined,
- 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),
- },
- );
- };
-
- if (isLoading) {
+ if (view.isLoading) {
return (
@@ -284,11 +23,11 @@ export default function WorkOrderViewPage() {
);
}
- if (error || !wo) {
+ if (view.error || !view.wo) {
return (
- {error instanceof Error ? error.message : "Work order not found."}
+ {view.error instanceof Error ? view.error.message : "Work order not found."}
);
@@ -296,476 +35,69 @@ export default function WorkOrderViewPage() {
return (
-
-
- Work Orders
-
-
- {wo.internalWONumber}
-
-
-
-
-
- {wo.title}
-
- setStatusAnchor(event.currentTarget)}
- className="w-fit cursor-pointer"
- />
-
-
+ view.statusMutation.mutate(status)}
+ onOpenDispatch={() => view.setDispatchOpen(true)}
+ />
-
-
-
-
-
-
-
-
- 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"
- >
-
-
-
-
+ view.assignmentMutation.mutate({ userId, userName })}
+ onUnassign={() => view.assignmentMutation.mutate({ userId: "", userName: "" })}
+ />
- {activeTab === "customer" && (
-
- )}
- {activeTab === "internal" && (
-
- )}
- {activeTab === "audit" && }
+
- {activeTab === "internal" && (
-
-
-
-
- )}
-
- {dispatchOpen && (
+
+
+
+
+ {view.dispatchOpen && (
setDispatchOpen(false)}
- onDispatched={() => void refetch()}
+ workOrder={view.wo}
+ onClose={() => view.setDispatchOpen(false)}
+ onDispatched={() => void view.refetch()}
/>
)}
- {dispatchDetailId != null && (
- setDispatchDetailId(null)}
- onUpdated={() => void refetch()}
- />
- )}
-
-
- );
-}
-function Field({ label, value }: { label: string; value: string }) {
- return (
-
- view.setDispatchDetailId(null)}
+ onUpdated={() => void view.refetch()}
+ />
+ )}
+
+ {
+ if (!view.vendorMutation.isPending) view.setVendorEditor(null);
}}
- >
- {label}
-
- {value}
+ onSave={view.saveVendorEditor}
+ onEditorChange={view.setVendorEditor}
+ />
);
}
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..4c1742b5
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/view/work-order-activity-panel.tsx
@@ -0,0 +1,101 @@
+import { useMemo, useState } from "react";
+import { Box, Button, Paper, Tab, Tabs } from "@mui/material";
+import { CommentMentionComposer } from "@/components/common/comment-mention-composer";
+import { CommentTimeline } from "@/components/common/comment-timeline";
+import { mapWorkOrderCommentsToTimeline } from "@/components/common/comment-timeline-mappers";
+import type {
+ WorkOrderAuditEntry,
+ WorkOrderComment,
+} from "@/domain/work-orders/types/work-order-detail";
+import { WorkOrderAuditTimeline } from "@/app/(protected)/workorders/_components/view/work-order-audit-timeline";
+import { Text } from "@/components/ui/text";
+
+export type WorkOrderActivityTab = "customer" | "internal" | "audit";
+
+type MentionUser = { id: string | number; name: string };
+
+type WorkOrderActivityPanelProps = {
+ customerComments: WorkOrderComment[];
+ internalComments: WorkOrderComment[];
+ auditLog: WorkOrderAuditEntry[];
+ mentionUsers: MentionUser[];
+ isSubmitting: boolean;
+ onSubmitComment: (text: string) => void;
+};
+
+export function WorkOrderActivityPanel({
+ customerComments,
+ internalComments,
+ auditLog,
+ mentionUsers,
+ isSubmitting,
+ onSubmitComment,
+}: WorkOrderActivityPanelProps) {
+ const [activeTab, setActiveTab] = useState("customer");
+ const [commentText, setCommentText] = useState("");
+
+ const customerTimelineItems = useMemo(
+ () => mapWorkOrderCommentsToTimeline(customerComments),
+ [customerComments],
+ );
+ const internalTimelineItems = useMemo(
+ () => mapWorkOrderCommentsToTimeline(internalComments),
+ [internalComments],
+ );
+
+ return (
+
+
+ Activity
+
+ setActiveTab(value)}
+ className="mb-3"
+ >
+
+
+
+
+
+ {activeTab === "customer" && (
+
+ )}
+ {activeTab === "internal" && (
+
+ )}
+ {activeTab === "audit" && }
+
+ {activeTab === "internal" && (
+ {
+ event.preventDefault();
+ const trimmed = commentText.trim();
+ if (!trimmed) {
+ return;
+ }
+ onSubmitComment(trimmed);
+ setCommentText("");
+ }}
+ className="mt-4 flex flex-col gap-2"
+ >
+
+
+
+ )}
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/view/work-order-audit-timeline.tsx b/src/app/(protected)/workorders/_components/view/work-order-audit-timeline.tsx
new file mode 100644
index 00000000..46c7b374
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/view/work-order-audit-timeline.tsx
@@ -0,0 +1,38 @@
+import { useMemo } from "react";
+import { Box, Stack, Typography } from "@mui/material";
+import type { WorkOrderAuditEntry } from "@/domain/work-orders/types/work-order";
+import { formatDateTime } from "@/lib/time-utils";
+
+export function WorkOrderAuditTimeline({ 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-detail-field.tsx b/src/app/(protected)/workorders/_components/view/work-order-detail-field.tsx
new file mode 100644
index 00000000..1e9f5184
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/view/work-order-detail-field.tsx
@@ -0,0 +1,12 @@
+import { Box, Typography } from "@mui/material";
+
+export function WorkOrderDetailField({ label, value }: { label: string; value: string }) {
+ return (
+
+
+ {label}
+
+ {value}
+
+ );
+}
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..0d77c0f0
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/view/work-order-dispatches-accordion.tsx
@@ -0,0 +1,113 @@
+import {
+ Accordion,
+ AccordionDetails,
+ AccordionSummary,
+ Button,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
+ Typography,
+} from "@mui/material";
+import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
+import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail";
+import { formatDateTime, timeSince, waitTimeColor } from "@/lib/time-utils";
+
+type WorkOrderDispatchesAccordionProps = {
+ wo: WorkOrderDetail;
+ onOpenDispatchDetail: (dispatchId: string | number) => void;
+ onOpenVendorEditor: (
+ event: React.MouseEvent,
+ vendorId: string | number,
+ vendorName: string,
+ ) => void;
+};
+
+export function WorkOrderDispatchesAccordion({
+ wo,
+ onOpenDispatchDetail,
+ onOpenVendorEditor,
+}: WorkOrderDispatchesAccordionProps) {
+ return (
+
+ }>
+ Dispatches ({wo.dispatches.length})
+
+
+ {wo.dispatches.length ? (
+
+
+
+
+ Dispatch #
+ Vendor
+ NTE
+ Status
+ Date
+ Wait
+ Actions
+
+
+
+ {wo.dispatches.map((dispatch) => {
+ const waitColor = waitTimeColor(dispatch.dispatchedAt);
+ return (
+ onOpenDispatchDetail(dispatch.id)}
+ >
+ {dispatch.dispatchNumber || dispatch.poNumber}
+ {dispatch.vendorName}
+
+ {dispatch.nteAmount != null ? `$${dispatch.nteAmount.toFixed(2)}` : "—"}
+
+ {dispatch.status}
+
+ {dispatch.dispatchedAt ? formatDateTime(dispatch.dispatchedAt) : "—"}
+
+
+ {dispatch.status === "Sent" ? (
+
+ {timeSince(dispatch.dispatchedAt)}
+
+ ) : (
+ "—"
+ )}
+
+
+
+
+
+ );
+ })}
+
+
+
+ ) : (
+ No dispatches yet.
+ )}
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/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..503eadc2
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/view/work-order-vendor-editor-dialog.tsx
@@ -0,0 +1,135 @@
+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 type { VendorEditorState } from "./work-order-vendor-editor-types";
+import { formatPhoneMask, isVendorEditorValid } from "./work-order-vendor-editor-utils";
+
+type WorkOrderVendorEditorDialogProps = {
+ editor: VendorEditorState | null;
+ loading: boolean;
+ error: string;
+ isSaving: boolean;
+ onClose: () => void;
+ onSave: () => void;
+ onEditorChange: (
+ updater: (current: VendorEditorState | null) => VendorEditorState | null,
+ ) => void;
+};
+
+export function WorkOrderVendorEditorDialog({
+ editor,
+ loading,
+ error,
+ isSaving,
+ onClose,
+ onSave,
+ onEditorChange,
+}: WorkOrderVendorEditorDialogProps) {
+ return (
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/view/work-order-vendor-editor-types.ts b/src/app/(protected)/workorders/_components/view/work-order-vendor-editor-types.ts
new file mode 100644
index 00000000..d3b155cc
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/view/work-order-vendor-editor-types.ts
@@ -0,0 +1,11 @@
+import type { VendorPreferredContact } from "@/domain/vendors/types/vendor";
+
+export interface VendorEditorState {
+ vendorId: string | number;
+ vendorName: string;
+ contactName: string;
+ preferredContact: VendorPreferredContact;
+ phone: string;
+ email: string;
+ notes: string;
+}
diff --git a/src/app/(protected)/workorders/_components/view/work-order-vendor-editor-utils.ts b/src/app/(protected)/workorders/_components/view/work-order-vendor-editor-utils.ts
new file mode 100644
index 00000000..a73099ab
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/view/work-order-vendor-editor-utils.ts
@@ -0,0 +1,16 @@
+import type { VendorEditorState } from "./work-order-vendor-editor-types";
+
+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
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/view/work-order-view-accordions.tsx b/src/app/(protected)/workorders/_components/view/work-order-view-accordions.tsx
new file mode 100644
index 00000000..707d8d61
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/view/work-order-view-accordions.tsx
@@ -0,0 +1,66 @@
+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 { WorkOrderDetailField } from "@/app/(protected)/workorders/_components/view/work-order-detail-field";
+import { formatDateTime } from "@/lib/time-utils";
+
+export function WorkOrderViewAccordions({ wo }: { wo: WorkOrderDetail }) {
+ return (
+ <>
+
+ }>
+ Classification
+
+
+
+
+
+
+
+
+
+
+
+ }>
+ Schedule
+
+
+
+
+
+
+
+
+
+
+
+
+ }>
+ Financial
+
+
+
+
+
+
+
+
+
+
+
+ }>
+ Description
+
+
+ {wo.description || "No description provided."}
+
+
+ >
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/view/work-order-view-details.tsx b/src/app/(protected)/workorders/_components/view/work-order-view-details.tsx
new file mode 100644
index 00000000..bf283bfd
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/view/work-order-view-details.tsx
@@ -0,0 +1,73 @@
+import { Box, Button, Menu, MenuItem, Paper, Typography } from "@mui/material";
+import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
+import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail";
+import { WorkOrderDetailField } from "@/app/(protected)/workorders/_components/view/work-order-detail-field";
+import { Text } from "@/components/ui/text";
+
+type WorkOrderViewDetailsProps = {
+ wo: WorkOrderDetail;
+ users: WorkOrderUser[];
+ assignAnchor: HTMLElement | null;
+ onAssignAnchorChange: (anchor: HTMLElement | null) => void;
+ onAssign: (userId: string | number, userName: string) => void;
+ onUnassign: () => void;
+};
+
+export function WorkOrderViewDetails({
+ wo,
+ users,
+ assignAnchor,
+ onAssignAnchorChange,
+ onAssign,
+ onUnassign,
+}: WorkOrderViewDetailsProps) {
+ return (
+
+
+ Details
+
+
+
+
+
+
+
+
+ Assigned To
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/view/work-order-view-header.tsx b/src/app/(protected)/workorders/_components/view/work-order-view-header.tsx
new file mode 100644
index 00000000..101abf72
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/view/work-order-view-header.tsx
@@ -0,0 +1,95 @@
+import { Link as RouterLink, useNavigate } from "react-router";
+import { Breadcrumbs, Button, Chip, Link, Menu, MenuItem, Stack, Typography } from "@mui/material";
+import { WORK_ORDER_STATUS_OPTIONS } from "@/domain/work-orders/types/work-order";
+import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail";
+
+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 WorkOrderViewHeaderProps = {
+ wo: WorkOrderDetail;
+ workOrderId: string;
+ statusAnchor: HTMLElement | null;
+ onStatusAnchorChange: (anchor: HTMLElement | null) => void;
+ onStatusChange: (status: string) => void;
+ onOpenDispatch: () => void;
+};
+
+export function WorkOrderViewHeader({
+ wo,
+ workOrderId,
+ statusAnchor,
+ onStatusAnchorChange,
+ onStatusChange,
+ onOpenDispatch,
+}: WorkOrderViewHeaderProps) {
+ const navigate = useNavigate();
+
+ return (
+ <>
+
+
+ Work Orders
+
+ {wo.internalWONumber}
+
+
+
+
+ {wo.title}
+
+ onStatusAnchorChange(event.currentTarget)}
+ className="w-fit cursor-pointer"
+ />
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/app/(protected)/workorders/_hooks/use-work-order-view-page.ts b/src/app/(protected)/workorders/_hooks/use-work-order-view-page.ts
new file mode 100644
index 00000000..96876af9
--- /dev/null
+++ b/src/app/(protected)/workorders/_hooks/use-work-order-view-page.ts
@@ -0,0 +1,152 @@
+import { useMemo, useState } from "react";
+import type { VendorEditorState } from "@/app/(protected)/workorders/_components/view/work-order-vendor-editor-types";
+import { isVendorEditorValid } from "@/app/(protected)/workorders/_components/view/work-order-vendor-editor-utils";
+import { useWorkOrderDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
+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 { extractMentionIds } from "@/domain/work-orders/utils/comment-mentions";
+
+export function useWorkOrderViewPage(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 vendorMutation = useUpdateWorkOrderVendor(workOrderId);
+
+ 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 mentionUsers = useMemo(
+ () => users.map((user) => ({ id: user.id, name: user.name })),
+ [users],
+ );
+
+ const submitComment = (text: string) => {
+ const mentions = extractMentionIds(text);
+ commentMutation.mutate({
+ text,
+ mentions: mentions.length > 0 ? mentions : undefined,
+ commentType: "internal",
+ });
+ };
+
+ 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),
+ },
+ );
+ };
+
+ return {
+ wo,
+ isLoading,
+ error,
+ refetch,
+ users,
+ customerComments,
+ internalComments,
+ auditLog,
+ mentionUsers,
+ submitComment,
+ dispatchOpen,
+ setDispatchOpen,
+ dispatchDetailId,
+ setDispatchDetailId,
+ statusAnchor,
+ setStatusAnchor,
+ assignAnchor,
+ setAssignAnchor,
+ vendorEditor,
+ setVendorEditor,
+ vendorEditorLoading,
+ vendorEditorError,
+ openVendorEditor,
+ saveVendorEditor,
+ statusMutation,
+ assignmentMutation,
+ commentMutation,
+ vendorMutation,
+ };
+}