diff --git a/src/app/(protected)/workorders/[id].tsx b/src/app/(protected)/workorders/[id].tsx index f7350669..4a793d96 100644 --- a/src/app/(protected)/workorders/[id].tsx +++ b/src/app/(protected)/workorders/[id].tsx @@ -31,6 +31,8 @@ import { Typography, } from "@mui/material"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +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"; @@ -304,22 +306,23 @@ export default function WorkOrderViewPage() { ); }; - 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 ( diff --git a/src/app/(protected)/workorders/_components/dispatch-create-modal.tsx b/src/app/(protected)/workorders/_components/dispatch-create-modal.tsx index b7ab376c..30838735 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,24 +9,21 @@ 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 { useWorkOrdersList } from "@/domain/work-orders/use-cases/use-work-orders-list"; import { useTaskListTemplates, useWorkOrderVendors, } from "@/domain/work-orders/use-cases/use-work-order-detail"; import { useDispatchToVendor } from "@/domain/work-orders/use-cases/use-work-order-mutations"; -import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order"; - -interface SelectedWorkOrder { - id: string | number; - number: string; - title: string; -} +import type { SelectedWorkOrderRef, WorkOrderDetail } from "@/domain/work-orders/types/work-order"; +import { guardDialogClose } from "@/lib/dialog-guard"; interface DispatchCreateModalProps { workOrder: WorkOrderDetail; @@ -48,7 +44,7 @@ export function DispatchCreateModal({ const [selectedTemplateId, setSelectedTemplateId] = useState(""); const [customItems, setCustomItems] = useState([]); const [newCustomItem, setNewCustomItem] = useState(""); - const [selectedWOs, setSelectedWOs] = useState([ + const [selectedWOs, setSelectedWOs] = useState([ { id: workOrder.id, number: workOrder.internalWONumber, @@ -61,8 +57,10 @@ export function DispatchCreateModal({ 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 } = useWorkOrdersList({ search: "", @@ -84,6 +82,7 @@ export function DispatchCreateModal({ vendors.filter( (vendor) => vendor.companyName.toLowerCase().includes(vendorSearch.toLowerCase()) || + vendor.contactName.toLowerCase().includes(vendorSearch.toLowerCase()) || vendor.tradeSpecialties.toLowerCase().includes(vendorSearch.toLowerCase()), ), [vendors, vendorSearch], @@ -128,13 +127,13 @@ export function DispatchCreateModal({ }; return ( - + Dispatch{" "} {selectedWOs.length > 1 ? `${selectedWOs.length} Work Orders` : `Work Order ${workOrder.internalWONumber}`} - + @@ -208,13 +207,11 @@ export function DispatchCreateModal({ className="mb-2" /> {isLoadingVendors ? ( - - Loading vendors... - + + {Array.from({ length: 3 }, (_, i) => ( + + ))} + ) : filteredVendors.length === 0 ? ( {vendor.companyName} + {Boolean(vendor.contactName) && ( + + {vendor.contactName} + + )} {Boolean(vendor.tradeSpecialties) && ( - 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 && ( - 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 03ff527a..0f54f805 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; newItem: string; onNewItemChange: (value: string) => void; onToggleItem: (itemId: string | number, isCompleted: boolean) => void; @@ -16,6 +17,7 @@ export function DispatchChecklistSection({ items, isLocked, isUpdating, + isAdding = false, newItem, onNewItemChange, onToggleItem, @@ -58,14 +60,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" + slotProps={{ htmlInput: { "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 1a0a27f2..152eb807 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 00c5e177..c2526620 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 7189fe1e..015edcbd 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,14 @@ export function DispatchDetailModal({ return ( <> - + - + { + if (!controller.isMutating) onClose(); + }} + /> @@ -72,6 +78,7 @@ export function DispatchDetailModal({ items={dispatch.checklistItems} isLocked={controller.isLocked} isUpdating={controller.updateChecklistMutation.isPending} + isAdding={controller.addChecklistMutation.isPending} newItem={controller.newChecklistItem} onNewItemChange={controller.setNewChecklistItem} onToggleItem={(itemId, isCompleted) => @@ -92,15 +99,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( { @@ -148,7 +164,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..e3c89564 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,17 @@ 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 +61,18 @@ export function UpliftDecisionDialog({ label={type === "deny" ? "Denial reason (required)" : "Optional note"} value={note} onChange={(event) => setNote(event.target.value)} + disabled={isPending} /> - +