From 5f25d1a774770a04fbde124d282253cd78df7978 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Fri, 17 Jul 2026 10:49:03 -0300 Subject: [PATCH] refactor(work-orders): harden dispatch modals with loading and close guards --- src/app/(protected)/workorders/[id].tsx | 54 +++++++-------- .../_components/dispatch-create-modal.tsx | 66 ++++++++++++------- .../dispatch-checklist-section.tsx | 8 ++- .../dispatch-detail/dispatch-edit-form.tsx | 17 ++--- .../dispatch-signoffs-section.tsx | 6 +- .../dispatch-vendor-communication.tsx | 10 +-- .../dispatch-verify-footer.tsx | 14 ++-- .../dispatch/dispatch-detail/index.tsx | 60 ++++++++++++----- .../uplift-decision-dialog.tsx | 23 +++++-- .../_hooks/use-dispatch-detail-controller.ts | 18 ++++- 10 files changed, 178 insertions(+), 98 deletions(-) diff --git a/src/app/(protected)/workorders/[id].tsx b/src/app/(protected)/workorders/[id].tsx index 9deeb752..78fcd4ad 100644 --- a/src/app/(protected)/workorders/[id].tsx +++ b/src/app/(protected)/workorders/[id].tsx @@ -1,16 +1,9 @@ -import { Link as RouterLink, useNavigate } from "react-router"; -import { - Alert, - Box, - Breadcrumbs, - Button, - CircularProgress, - Link, - Stack, - Typography, -} from "@mui/material"; +import { Link as RouterLink, useNavigate, useParams } from "react-router"; +import { Box, Breadcrumbs, Link, Stack, Typography } from "@mui/material"; import { useState } from "react"; -import { useParams } from "react-router"; +import { Button } from "@/components/ui/button"; +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 { WorkOrderDetailsSection } from "./_components/detail/work-order-detail-sections"; @@ -28,7 +21,7 @@ export default function WorkOrderViewPage() { const navigate = useNavigate(); const workOrderId = id ?? ""; - const { data: wo, isLoading, error } = useWorkOrderDetail(workOrderId); + const { data: wo, isLoading, error, refetch } = useWorkOrderDetail(workOrderId); const { data: users = [] } = useWorkOrderUsers(); const statusMutation = useChangeWorkOrderStatus(workOrderId); const assignmentMutation = useChangeWorkOrderAssignment(workOrderId); @@ -37,22 +30,23 @@ export default function WorkOrderViewPage() { const [dispatchOpen, setDispatchOpen] = useState(false); const [dispatchDetailId, setDispatchDetailId] = useState(null); - if (isLoading) { + if (isLoading && !wo) { + return ; + } + + if ((error || !wo) && !isLoading) { return ( - - + + void refetch()} + /> ); } - if (error || !wo) { - return ( - - - {error instanceof Error ? error.message : "Work order not found."} - - - ); + if (!wo) { + return ; } return ( @@ -78,18 +72,15 @@ export default function WorkOrderViewPage() { statusMutation.mutate(status)} /> - - - + + @@ -99,6 +90,7 @@ export default function WorkOrderViewPage() { wo={wo} users={users} onAssign={(userId, userName) => assignmentMutation.mutate({ userId, userName })} + isAssignPending={assignmentMutation.isPending} onDispatchClick={setDispatchDetailId} onCommentSubmit={(text, commentType) => commentMutation.mutate({ text, commentType })} isCommentPending={commentMutation.isPending} diff --git a/src/app/(protected)/workorders/_components/dispatch-create-modal.tsx b/src/app/(protected)/workorders/_components/dispatch-create-modal.tsx index 1ccbfcb8..cad420b9 100644 --- a/src/app/(protected)/workorders/_components/dispatch-create-modal.tsx +++ b/src/app/(protected)/workorders/_components/dispatch-create-modal.tsx @@ -1,7 +1,6 @@ import { useMemo, useState } from "react"; import { Box, - Button, Chip, Dialog, DialogActions, @@ -10,11 +9,13 @@ import { IconButton, MenuItem, Radio, + Skeleton, Stack, TextField, Typography, } from "@mui/material"; import CloseIcon from "@mui/icons-material/Close"; +import { Button } from "@/components/ui/button"; import { useDebounce } from "@/hooks/use-debounce"; import { useWorkOrdersByLocation } from "@/domain/work-orders/use-cases/use-work-orders-list"; import { @@ -23,6 +24,7 @@ import { } 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; @@ -53,8 +55,10 @@ export function DispatchCreateModal({ workOrder, onClose }: DispatchCreateModalP workOrder.trade, workOrder.locationZip, ); - const { data: taskTemplates = [] } = useTaskListTemplates(); + const { data: taskTemplates = [], isLoading: isLoadingTemplates } = useTaskListTemplates(); const dispatchMutation = useDispatchToVendor(workOrder.id); + const isPending = dispatchMutation.isPending; + const handleClose = guardDialogClose(isPending, onClose); const { data: siteWorkOrdersData } = useWorkOrdersByLocation( workOrder.locationId, @@ -72,6 +76,7 @@ export function DispatchCreateModal({ workOrder, onClose }: DispatchCreateModalP vendors.filter( (vendor) => vendor.companyName.toLowerCase().includes(vendorSearch.toLowerCase()) || + vendor.contactName.toLowerCase().includes(vendorSearch.toLowerCase()) || vendor.tradeSpecialties.toLowerCase().includes(vendorSearch.toLowerCase()), ), [vendors, vendorSearch], @@ -105,13 +110,13 @@ export function DispatchCreateModal({ workOrder, onClose }: DispatchCreateModalP }; return ( - + Dispatch{" "} {selectedWOs.length > 1 ? `${selectedWOs.length} Work Orders` : `Work Order ${workOrder.internalWONumber}`} - + @@ -180,7 +185,11 @@ export function DispatchCreateModal({ workOrder, onClose }: DispatchCreateModalP className="mb-2" /> {isLoadingVendors ? ( - Loading vendors... + + {Array.from({ length: 3 }, (_, i) => ( + + ))} + ) : filteredVendors.length === 0 ? ( No vendors found ) : ( @@ -202,6 +211,11 @@ export function DispatchCreateModal({ workOrder, onClose }: DispatchCreateModalP {vendor.companyName} + {vendor.contactName && ( + + {vendor.contactName} + + )} {vendor.tradeSpecialties && ( {vendor.tradeSpecialties} @@ -243,20 +257,25 @@ export function DispatchCreateModal({ workOrder, onClose }: DispatchCreateModalP /> - setSelectedTemplateId(event.target.value)} - fullWidth - > - No checklist template - {taskTemplates.map((template) => ( - - {template.name} ({template.items?.length ?? 0} items) - - ))} - + {isLoadingTemplates ? ( + + ) : ( + setSelectedTemplateId(event.target.value)} + fullWidth + disabled={isPending} + > + No checklist template + {taskTemplates.map((template) => ( + + {template.name} ({template.items?.length ?? 0} items) + + ))} + + )} {customItems.length > 0 && ( @@ -296,15 +315,16 @@ export function DispatchCreateModal({ workOrder, onClose }: DispatchCreateModalP - diff --git a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-checklist-section.tsx b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-checklist-section.tsx index efb88761..6957394d 100644 --- a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-checklist-section.tsx +++ b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-checklist-section.tsx @@ -6,6 +6,7 @@ type DispatchChecklistSectionProps = { items: DispatchChecklistItem[]; isLocked: boolean; isUpdating: boolean; + isAdding?: boolean; userName: string; newItem: string; onNewItemChange: (value: string) => void; @@ -17,6 +18,7 @@ export function DispatchChecklistSection({ items, isLocked, isUpdating, + isAdding = false, userName, newItem, onNewItemChange, @@ -60,14 +62,16 @@ export function DispatchChecklistSection({ value={newItem} onChange={(event) => onNewItemChange(event.target.value)} onKeyDown={(event) => { - if (event.key === "Enter" && newItem.trim()) { + if (event.key === "Enter" && newItem.trim() && !isAdding) { event.preventDefault(); onAddItem(newItem.trim()); } }} - placeholder="Add checklist item..." + placeholder={isAdding ? "Adding…" : "Add checklist item..."} + disabled={isAdding} fullWidth className="mt-2" + inputProps={{ "aria-busy": isAdding || undefined }} /> )} diff --git a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-edit-form.tsx b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-edit-form.tsx index a34baa47..5a03b130 100644 --- a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-edit-form.tsx +++ b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-edit-form.tsx @@ -1,5 +1,6 @@ -import { Box, Button, MenuItem, Stack, TextField } from "@mui/material"; +import { Box, MenuItem, Stack, TextField } from "@mui/material"; import { DetailField } from "@/components/common/detail-field"; +import { Button } from "@/components/ui/button"; import { DISPATCH_STATUS_OPTIONS, type DispatchDetail, @@ -84,10 +85,12 @@ export function DispatchEditForm({ onChange={(event) => onChange({ ...form, description: event.target.value })} /> - + - ); @@ -138,12 +141,10 @@ export function DispatchDetailsView({ )} {!isLocked && ( - - + )} diff --git a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-signoffs-section.tsx b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-signoffs-section.tsx index 57288b9b..3aff5e93 100644 --- a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-signoffs-section.tsx +++ b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-signoffs-section.tsx @@ -5,12 +5,14 @@ import { formatDateTime } from "@/lib/time-utils"; type DispatchSignoffsSectionProps = { signoffs: DispatchSignoff[]; isLocked: boolean; + isSigningOff?: boolean; onCollectSignature: (type: "Customer" | "Vendor") => void; }; export function DispatchSignoffsSection({ signoffs, isLocked, + isSigningOff = false, onCollectSignature, }: DispatchSignoffsSectionProps) { return ( @@ -49,9 +51,9 @@ export function DispatchSignoffsSection({ variant="outlined" className="mt-2" onClick={() => onCollectSignature(type)} - disabled={isLocked} + disabled={isLocked || isSigningOff} > - {isLocked ? "Not signed" : "Collect Signature"} + {isLocked ? "Not signed" : isSigningOff ? "Saving…" : "Collect Signature"} )} diff --git a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-vendor-communication.tsx b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-vendor-communication.tsx index 74cf4e1a..816b78a5 100644 --- a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-vendor-communication.tsx +++ b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-vendor-communication.tsx @@ -1,6 +1,7 @@ -import { Box, Button, Checkbox, FormControlLabel, Stack, TextField } from "@mui/material"; +import { Box, Checkbox, FormControlLabel, Stack, TextField } from "@mui/material"; import { CommentTimeline } from "@/components/common/comment-timeline"; import { mapDispatchCommentsToTimeline } from "@/components/common/comment-timeline-mappers"; +import { Button } from "@/components/ui/button"; import type { DispatchComment } from "@/domain/work-orders/types/work-order"; type DispatchVendorCommunicationProps = { @@ -61,10 +62,11 @@ export function DispatchVendorCommunication({ /> diff --git a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-verify-footer.tsx b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-verify-footer.tsx index 7ee7cb1e..4c8cb8a7 100644 --- a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-verify-footer.tsx +++ b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-verify-footer.tsx @@ -1,4 +1,5 @@ -import { Alert, Button, Stack, Typography } from "@mui/material"; +import { Alert, Stack, Typography } from "@mui/material"; +import { Button } from "@/components/ui/button"; import { formatDateTime } from "@/lib/time-utils"; type DispatchVerifyFooterProps = { @@ -34,10 +35,15 @@ export function DispatchVerifyFooter({ )} 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 8a9a4fb4..4de67010 100644 --- a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/index.tsx +++ b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/index.tsx @@ -1,6 +1,8 @@ -import { CircularProgress, Dialog, DialogContent, DialogTitle } from "@mui/material"; +import { Dialog, DialogContent, DialogTitle, Skeleton } 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 { DispatchDetailHeader } from "./dispatch-detail-header"; import { DispatchDetailsGrid, DispatchDetailsView, DispatchEditForm } from "./dispatch-edit-form"; @@ -23,13 +25,12 @@ export function DispatchDetailModal({ onClose, }: DispatchDetailModalProps) { const controller = useDispatchDetailController(dispatchId, workOrderId); + const handleClose = guardDialogClose(controller.isMutating, onClose); - if (controller.isLoading) { + if (controller.isLoading && !controller.dispatch) { return ( - - - + ); } @@ -42,9 +43,21 @@ export function DispatchDetailModal({ return ( <> - + - + { + if (!controller.isMutating) onClose(); + }} + /> @@ -72,6 +85,7 @@ export function DispatchDetailModal({ items={dispatch.checklistItems} isLocked={controller.isLocked} isUpdating={controller.updateChecklistMutation.isPending} + isAdding={controller.addChecklistMutation.isPending} userName={controller.userName} newItem={controller.newChecklistItem} onNewItemChange={controller.setNewChecklistItem} @@ -93,15 +107,20 @@ export function DispatchDetailModal({ - controller.setUpliftAction({ type: "approve", uplift })} - onDeny={(uplift) => controller.setUpliftAction({ type: "deny", uplift })} - /> + {controller.isLoadingUplifts ? ( + + ) : ( + controller.setUpliftAction({ type: "approve", uplift })} + onDeny={(uplift) => controller.setUpliftAction({ type: "deny", uplift })} + /> + )} controller.setSigCaptureType(null)} + onClose={() => { + if (!controller.addSignoffMutation.isPending) { + controller.setSigCaptureType(null); + } + }} onSave={(signature, method, name) => { controller.addSignoffMutation.mutate( { @@ -149,7 +172,14 @@ export function DispatchDetailModal({ type={controller.upliftAction.type} requestedNTE={controller.upliftAction.uplift.requestedNTE} currentNTE={controller.upliftAction.uplift.currentNTE} - onClose={() => controller.setUpliftAction(null)} + onClose={() => { + if ( + !controller.approveUpliftMutation.isPending && + !controller.denyUpliftMutation.isPending + ) { + controller.setUpliftAction(null); + } + }} onConfirm={controller.handleUpliftConfirm} isPending={ controller.approveUpliftMutation.isPending || controller.denyUpliftMutation.isPending diff --git a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/uplift-decision-dialog.tsx b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/uplift-decision-dialog.tsx index c8e7cf2f..39482814 100644 --- a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/uplift-decision-dialog.tsx +++ b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/uplift-decision-dialog.tsx @@ -1,6 +1,5 @@ import { useState } from "react"; import { - Button, Dialog, DialogActions, DialogContent, @@ -8,6 +7,8 @@ import { TextField, Typography, } from "@mui/material"; +import { Button } from "@/components/ui/button"; +import { guardDialogClose } from "@/lib/dialog-guard"; type UpliftDecisionDialogProps = { open: boolean; @@ -31,17 +32,21 @@ export function UpliftDecisionDialog({ const [note, setNote] = useState(""); const handleClose = () => { + if (isPending) return; setNote(""); onClose(); }; const handleConfirm = () => { onConfirm(note.trim()); - setNote(""); }; return ( - + {type === "approve" ? `Approve uplift to $${requestedNTE.toFixed(2)}?` : "Deny Uplift"} @@ -60,14 +65,18 @@ export function UpliftDecisionDialog({ label={type === "deny" ? "Denial reason (required)" : "Optional note"} value={note} onChange={(event) => setNote(event.target.value)} + disabled={isPending} /> - +