From 42258713fae6d9bdd7a0de7c7c6f7576c6585008 Mon Sep 17 00:00:00 2001 From: "arthur.bassi" Date: Mon, 3 Aug 2026 14:36:19 -0300 Subject: [PATCH] refactor(workorders): split work order view page into view components --- src/app/(protected)/workorders/[id].tsx | 804 ++---------------- .../view/work-order-activity-panel.tsx | 101 +++ .../view/work-order-audit-timeline.tsx | 38 + .../view/work-order-detail-field.tsx | 12 + .../view/work-order-dispatches-accordion.tsx | 113 +++ .../view/work-order-vendor-editor-dialog.tsx | 135 +++ .../view/work-order-vendor-editor-types.ts | 11 + .../view/work-order-vendor-editor-utils.ts | 16 + .../view/work-order-view-accordions.tsx | 66 ++ .../view/work-order-view-details.tsx | 73 ++ .../view/work-order-view-header.tsx | 95 +++ .../_hooks/use-work-order-view-page.ts | 152 ++++ 12 files changed, 880 insertions(+), 736 deletions(-) create mode 100644 src/app/(protected)/workorders/_components/view/work-order-activity-panel.tsx create mode 100644 src/app/(protected)/workorders/_components/view/work-order-audit-timeline.tsx create mode 100644 src/app/(protected)/workorders/_components/view/work-order-detail-field.tsx create mode 100644 src/app/(protected)/workorders/_components/view/work-order-dispatches-accordion.tsx create mode 100644 src/app/(protected)/workorders/_components/view/work-order-vendor-editor-dialog.tsx create mode 100644 src/app/(protected)/workorders/_components/view/work-order-vendor-editor-types.ts create mode 100644 src/app/(protected)/workorders/_components/view/work-order-vendor-editor-utils.ts create mode 100644 src/app/(protected)/workorders/_components/view/work-order-view-accordions.tsx create mode 100644 src/app/(protected)/workorders/_components/view/work-order-view-details.tsx create mode 100644 src/app/(protected)/workorders/_components/view/work-order-view-header.tsx create mode 100644 src/app/(protected)/workorders/_hooks/use-work-order-view-page.ts 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" - /> - setStatusAnchor(null)} - > - {WORK_ORDER_STATUS_OPTIONS.map((status) => ( - { - setStatusAnchor(null); - if (status !== wo.status) { - statusMutation.mutate(status); - } - }} - > - {status} - - ))} - - + view.statusMutation.mutate(status)} + onOpenDispatch={() => view.setDispatchOpen(true)} + /> - - - - - - - - - Details - - - - - - - - - Assigned To - - - setAssignAnchor(null)} - > - { - setAssignAnchor(null); - assignmentMutation.mutate({ userId: "", userName: "" }); - }} - > - Unassigned - - {users.map((user) => ( - { - setAssignAnchor(null); - assignmentMutation.mutate({ userId: user.id, userName: user.name }); - }} - > - {user.name} - - ))} - - - - - - - - }> - - 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()} - /> - )} - { - if (!vendorMutation.isPending) setVendorEditor(null); - }} - fullWidth - maxWidth="sm" - > - Edit assigned vendor - - - - {vendorEditor?.vendorName} - - {Boolean(vendorEditorError) && {vendorEditorError}} - {vendorEditorLoading ? ( - - - - ) : ( - <> - - setVendorEditor((current) => - current ? { ...current, contactName: event.target.value } : current, - ) - } - slotProps={{ htmlInput: { maxLength: 200 } }} - /> - - setVendorEditor((current) => - current - ? { - ...current, - preferredContact: event.target.value as VendorPreferredContact, - } - : current, - ) - } - > - Phone - Email - Text - - - setVendorEditor((current) => - current - ? { ...current, phone: formatPhoneMask(event.target.value) } - : current, - ) - } - error={Boolean( - vendorEditor?.phone && !/^\(\d{3}\) \d{3}-\d{4}$/.test(vendorEditor.phone), - )} - helperText="Use (XXX) XXX-XXXX" - /> - - setVendorEditor((current) => - current ? { ...current, email: event.target.value } : current, - ) - } - /> - - setVendorEditor((current) => - current ? { ...current, notes: event.target.value } : current, - ) - } - slotProps={{ htmlInput: { maxLength: 2000 } }} - /> - - )} - - - - - - - -
- ); -} -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 ( + + Edit assigned vendor + + + + {editor?.vendorName} + + {Boolean(error) && {error}} + {loading ? ( + + + + ) : ( + <> + + onEditorChange((current) => + current ? { ...current, contactName: event.target.value } : current, + ) + } + slotProps={{ htmlInput: { maxLength: 200 } }} + /> + + onEditorChange((current) => + current + ? { + ...current, + preferredContact: event.target.value as VendorPreferredContact, + } + : current, + ) + } + > + Phone + Email + Text + + + onEditorChange((current) => + current ? { ...current, phone: formatPhoneMask(event.target.value) } : current, + ) + } + error={Boolean(editor?.phone && !/^\(\d{3}\) \d{3}-\d{4}$/.test(editor.phone))} + helperText="Use (XXX) XXX-XXXX" + /> + + onEditorChange((current) => + current ? { ...current, email: event.target.value } : current, + ) + } + /> + + onEditorChange((current) => + current ? { ...current, notes: event.target.value } : current, + ) + } + slotProps={{ htmlInput: { maxLength: 2000 } }} + /> + + )} + + + + + + + + ); +} 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 + + + onAssignAnchorChange(null)} + > + { + onAssignAnchorChange(null); + onUnassign(); + }} + > + Unassigned + + {users.map((user) => ( + { + onAssignAnchorChange(null); + onAssign(user.id, user.name); + }} + > + {user.name} + + ))} + + + + + + + ); +} 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" + /> + onStatusAnchorChange(null)} + > + {WORK_ORDER_STATUS_OPTIONS.map((status) => ( + { + onStatusAnchorChange(null); + if (status !== wo.status) { + onStatusChange(status); + } + }} + > + {status} + + ))} + + + + + + + + + + + ); +} 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, + }; +}