refactor(work-orders): harden dispatch modals with loading and close guards

This commit is contained in:
Arthur Bassi 2026-07-17 10:49:03 -03:00
parent ed3bd71b5f
commit 5f25d1a774
10 changed files with 178 additions and 98 deletions

View file

@ -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<string | number | null>(null);
if (isLoading) {
if (isLoading && !wo) {
return <DetailPageSkeleton variant="view" />;
}
if ((error || !wo) && !isLoading) {
return (
<Box className="flex justify-center p-12">
<CircularProgress />
<Box className="p-4">
<QueryErrorPanel
message={error instanceof Error ? error.message : "Work order not found."}
onRetry={() => void refetch()}
/>
</Box>
);
}
if (error || !wo) {
return (
<Box className="p-4">
<Alert severity="error">
{error instanceof Error ? error.message : "Work order not found."}
</Alert>
</Box>
);
if (!wo) {
return <DetailPageSkeleton variant="view" />;
}
return (
@ -78,18 +72,15 @@ export default function WorkOrderViewPage() {
</Typography>
<WorkOrderStatusMenu
status={wo.status}
disabled={statusMutation.isPending}
onStatusChange={(status) => statusMutation.mutate(status)}
/>
</Stack>
<Stack direction="row" spacing={1} sx={{ flexWrap: "wrap" }}>
<Button variant="contained" color="success" onClick={() => setDispatchOpen(true)}>
Dispatch
</Button>
<Button variant="contained" onClick={() => navigate(`/workorders/edit/${id}`)}>
Edit
</Button>
<Button variant="outlined" onClick={() => navigate("/workorders")}>
<Button onClick={() => setDispatchOpen(true)}>Dispatch</Button>
<Button onClick={() => navigate(`/workorders/edit/${id}`)}>Edit</Button>
<Button variant="outline" onClick={() => navigate("/workorders")}>
Back to List
</Button>
</Stack>
@ -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}

View file

@ -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 (
<Dialog open onClose={onClose} maxWidth="md" fullWidth>
<Dialog open onClose={handleClose} disableEscapeKeyDown={isPending} maxWidth="md" fullWidth>
<DialogTitle className="flex items-center justify-between pr-2">
Dispatch{" "}
{selectedWOs.length > 1
? `${selectedWOs.length} Work Orders`
: `Work Order ${workOrder.internalWONumber}`}
<IconButton aria-label="Close" onClick={onClose} size="small">
<IconButton aria-label="Close" onClick={onClose} size="small" disabled={isPending}>
<CloseIcon fontSize="small" />
</IconButton>
</DialogTitle>
@ -180,7 +185,11 @@ export function DispatchCreateModal({ workOrder, onClose }: DispatchCreateModalP
className="mb-2"
/>
{isLoadingVendors ? (
<Typography color="text.secondary">Loading vendors...</Typography>
<Stack spacing={1} aria-busy="true" role="status" aria-label="Loading vendors">
{Array.from({ length: 3 }, (_, i) => (
<Skeleton key={i} variant="rounded" height={56} animation="wave" />
))}
</Stack>
) : filteredVendors.length === 0 ? (
<Typography color="text.secondary">No vendors found</Typography>
) : (
@ -202,6 +211,11 @@ export function DispatchCreateModal({ workOrder, onClose }: DispatchCreateModalP
<Typography variant="body2" className="font-semibold">
{vendor.companyName}
</Typography>
{vendor.contactName && (
<Typography variant="caption" color="text.secondary" display="block">
{vendor.contactName}
</Typography>
)}
{vendor.tradeSpecialties && (
<Typography variant="caption" color="text.secondary" display="block">
{vendor.tradeSpecialties}
@ -243,20 +257,25 @@ export function DispatchCreateModal({ workOrder, onClose }: DispatchCreateModalP
/>
</Stack>
<TextField
label="Task List Template"
select
value={selectedTemplateId}
onChange={(event) => setSelectedTemplateId(event.target.value)}
fullWidth
>
<MenuItem value="">No checklist template</MenuItem>
{taskTemplates.map((template) => (
<MenuItem key={String(template.id)} value={String(template.id)}>
{template.name} ({template.items?.length ?? 0} items)
</MenuItem>
))}
</TextField>
{isLoadingTemplates ? (
<Skeleton variant="rounded" height={56} animation="wave" aria-label="Loading templates" />
) : (
<TextField
label="Task List Template"
select
value={selectedTemplateId}
onChange={(event) => setSelectedTemplateId(event.target.value)}
fullWidth
disabled={isPending}
>
<MenuItem value="">No checklist template</MenuItem>
{taskTemplates.map((template) => (
<MenuItem key={String(template.id)} value={String(template.id)}>
{template.name} ({template.items?.length ?? 0} items)
</MenuItem>
))}
</TextField>
)}
{customItems.length > 0 && (
<Stack direction="row" flexWrap="wrap" gap={1}>
@ -296,15 +315,16 @@ export function DispatchCreateModal({ workOrder, onClose }: DispatchCreateModalP
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={dispatchMutation.isPending}>
<Button variant="outline" onClick={onClose} disabled={isPending}>
Cancel
</Button>
<Button
variant="contained"
onClick={handleSubmit}
disabled={dispatchMutation.isPending || !selectedVendorId}
disabled={!selectedVendorId}
loading={isPending}
loadingText="Dispatching…"
>
{dispatchMutation.isPending ? "Dispatching..." : "Dispatch"}
Dispatch
</Button>
</DialogActions>
</Dialog>

View file

@ -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 }}
/>
)}
</Box>

View file

@ -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 })}
/>
<Stack direction="row" spacing={1} className="sm:col-span-2">
<Button variant="contained" onClick={onSave} disabled={isSaving}>
{isSaving ? "Saving..." : "Save"}
<Button onClick={onSave} loading={isSaving} loadingText="Saving…">
Save
</Button>
<Button variant="outline" onClick={onCancel} disabled={isSaving}>
Cancel
</Button>
<Button onClick={onCancel}>Cancel</Button>
</Stack>
</>
);
@ -138,12 +141,10 @@ export function DispatchDetailsView({
)}
{!isLocked && (
<Stack direction="row" spacing={1} className="sm:col-span-2">
<Button color="error" variant="outlined" onClick={onCancelDispatch}>
<Button variant="destructive" onClick={onCancelDispatch}>
Cancel Dispatch
</Button>
<Button variant="contained" onClick={onEdit}>
Edit
</Button>
<Button onClick={onEdit}>Edit</Button>
</Stack>
)}
</>

View file

@ -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"}
</Button>
)}
</Box>

View file

@ -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({
/>
<Button
type="submit"
variant="contained"
disabled={isLocked || isPending || !commentText.trim()}
disabled={isLocked || !commentText.trim()}
loading={isPending}
loadingText="Sending…"
>
{isPending ? "Sending..." : "Send"}
Send
</Button>
</Stack>
</Box>

View file

@ -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({
</Typography>
)}
<Button
variant="contained"
color="success"
disabled={!canVerify || isPending}
disabled={!canVerify}
loading={isPending}
loadingText="Verifying…"
onClick={onVerify}
sx={{
backgroundColor: "success.main",
color: "#fff",
"&:hover": { backgroundColor: "success.dark" },
}}
>
Verify & Close
</Button>

View file

@ -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 (
<Dialog open onClose={onClose} maxWidth="md" fullWidth>
<DialogContent className="flex justify-center py-12">
<CircularProgress />
</DialogContent>
<DispatchDetailSkeleton />
</Dialog>
);
}
@ -42,9 +43,21 @@ export function DispatchDetailModal({
return (
<>
<Dialog open onClose={onClose} maxWidth="md" fullWidth scroll="paper">
<Dialog
open
onClose={handleClose}
disableEscapeKeyDown={controller.isMutating}
maxWidth="md"
fullWidth
scroll="paper"
>
<DialogTitle>
<DispatchDetailHeader dispatch={dispatch} onClose={onClose} />
<DispatchDetailHeader
dispatch={dispatch}
onClose={() => {
if (!controller.isMutating) onClose();
}}
/>
</DialogTitle>
<DialogContent className="flex flex-col gap-6">
@ -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({
<DispatchSignoffsSection
signoffs={dispatch.signoffs}
isLocked={controller.isLocked}
isSigningOff={controller.addSignoffMutation.isPending}
onCollectSignature={controller.setSigCaptureType}
/>
<DispatchUpliftsSection
uplifts={controller.uplifts}
isLocked={controller.isLocked}
onApprove={(uplift) => controller.setUpliftAction({ type: "approve", uplift })}
onDeny={(uplift) => controller.setUpliftAction({ type: "deny", uplift })}
/>
{controller.isLoadingUplifts ? (
<Skeleton variant="rounded" height={80} animation="wave" aria-label="Loading uplifts" />
) : (
<DispatchUpliftsSection
uplifts={controller.uplifts}
isLocked={controller.isLocked}
onApprove={(uplift) => controller.setUpliftAction({ type: "approve", uplift })}
onDeny={(uplift) => controller.setUpliftAction({ type: "deny", uplift })}
/>
)}
<DispatchVendorCommunication
comments={dispatch.comments}
@ -128,7 +147,11 @@ export function DispatchDetailModal({
{controller.sigCaptureType && (
<SignatureCapture
title={`${controller.sigCaptureType} Sign-off`}
onClose={() => 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

View file

@ -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 (
<Dialog open={open} onClose={handleClose}>
<Dialog
open={open}
onClose={guardDialogClose(isPending, handleClose)}
disableEscapeKeyDown={isPending}
>
<DialogTitle>
{type === "approve" ? `Approve uplift to $${requestedNTE.toFixed(2)}?` : "Deny Uplift"}
</DialogTitle>
@ -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}
/>
</DialogContent>
<DialogActions>
<Button onClick={handleClose}>Cancel</Button>
<Button variant="outline" onClick={handleClose} disabled={isPending}>
Cancel
</Button>
<Button
variant="contained"
color={type === "approve" ? "success" : "error"}
disabled={type === "deny" ? !note.trim() || isPending : isPending}
variant={type === "approve" ? "default" : "destructive"}
disabled={type === "deny" ? !note.trim() : false}
loading={isPending}
loadingText="Confirming…"
onClick={handleConfirm}
>
Confirm

View file

@ -26,8 +26,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<DispatchEditFormState>({
@ -159,10 +159,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,
@ -177,6 +190,7 @@ export function useDispatchDetailController(
upliftAction,
setUpliftAction,
isLocked,
isMutating,
userName,
verifyState,
beginEditing,