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