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 (
-
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 })}
/>
-
- {isSaving ? "Saving..." : "Save"}
+
+ Save
+
+
+ Cancel
- Cancel
>
);
@@ -138,12 +141,10 @@ export function DispatchDetailsView({
)}
{!isLocked && (
-
+
Cancel Dispatch
-
- Edit
-
+ Edit
)}
>
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({
/>
- {isPending ? "Sending..." : "Send"}
+ Send
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({
)}
Verify & Close
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}
/>
- Cancel
+
+ Cancel
+
Confirm
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 5c681db8..eb72e090 100644
--- a/src/app/(protected)/workorders/_hooks/use-dispatch-detail-controller.ts
+++ b/src/app/(protected)/workorders/_hooks/use-dispatch-detail-controller.ts
@@ -52,8 +52,8 @@ export function useDispatchDetailController(
workOrderId: string | number,
) {
const { user } = useAuthContext();
- const { data: dispatch, isLoading } = useDispatchDetail(dispatchId);
- const { data: uplifts = [] } = useUpliftsForDispatch(dispatchId);
+ const { data: dispatch, isLoading, isFetching, refetch } = useDispatchDetail(dispatchId);
+ const { data: uplifts = [], isLoading: isLoadingUplifts } = useUpliftsForDispatch(dispatchId);
const [editing, setEditing] = useState(false);
const [form, setForm] = useState({
@@ -157,10 +157,23 @@ export function useDispatchDetailController(
);
};
+ const isMutating =
+ updateDispatchMutation.isPending ||
+ addCommentMutation.isPending ||
+ updateChecklistMutation.isPending ||
+ addChecklistMutation.isPending ||
+ addSignoffMutation.isPending ||
+ verifyMutation.isPending ||
+ approveUpliftMutation.isPending ||
+ denyUpliftMutation.isPending;
+
return {
dispatch,
isLoading,
+ isFetching,
+ refetch,
uplifts,
+ isLoadingUplifts,
editing,
form,
setForm,
@@ -175,6 +188,7 @@ export function useDispatchDetailController(
upliftAction,
setUpliftAction,
isLocked,
+ isMutating,
userName,
verifyState,
beginEditing,