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

(cherry picked from commit a9ee990389abada03ecbd600ee63c5a052507118)
This commit is contained in:
Arthur Bassi 2026-07-27 14:48:17 -03:00
parent c2bd1c4e60
commit 175682e229
10 changed files with 166 additions and 92 deletions

View file

@ -31,6 +31,8 @@ import {
Typography, Typography,
} from "@mui/material"; } from "@mui/material";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; 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 { DispatchCreateModal } from "./_components/dispatch-create-modal";
import { DispatchDetailModal } from "./_components/dispatch-detail-modal"; import { DispatchDetailModal } from "./_components/dispatch-detail-modal";
import type { WorkOrderAuditEntry, WorkOrderComment } from "@/domain/work-orders/types/work-order"; 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 <DetailPageSkeleton variant="view" />;
}
if ((error || !wo) && !isLoading) {
return ( return (
<Box className="flex justify-center p-12"> <Box className="p-4">
<CircularProgress /> <QueryErrorPanel
message={error instanceof Error ? error.message : "Work order not found."}
onRetry={() => void refetch()}
/>
</Box> </Box>
); );
} }
if (error || !wo) { if (!wo) {
return ( return <DetailPageSkeleton variant="view" />;
<Box className="p-4">
<Alert severity="error">
{error instanceof Error ? error.message : "Work order not found."}
</Alert>
</Box>
);
} }
return ( return (

View file

@ -1,7 +1,6 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { import {
Box, Box,
Button,
Chip, Chip,
Dialog, Dialog,
DialogActions, DialogActions,
@ -10,24 +9,21 @@ import {
IconButton, IconButton,
MenuItem, MenuItem,
Radio, Radio,
Skeleton,
Stack, Stack,
TextField, TextField,
Typography, Typography,
} from "@mui/material"; } from "@mui/material";
import CloseIcon from "@mui/icons-material/Close"; 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 { useWorkOrdersList } from "@/domain/work-orders/use-cases/use-work-orders-list";
import { import {
useTaskListTemplates, useTaskListTemplates,
useWorkOrderVendors, useWorkOrderVendors,
} from "@/domain/work-orders/use-cases/use-work-order-detail"; } from "@/domain/work-orders/use-cases/use-work-order-detail";
import { useDispatchToVendor } from "@/domain/work-orders/use-cases/use-work-order-mutations"; import { useDispatchToVendor } from "@/domain/work-orders/use-cases/use-work-order-mutations";
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order"; import type { SelectedWorkOrderRef, WorkOrderDetail } from "@/domain/work-orders/types/work-order";
import { guardDialogClose } from "@/lib/dialog-guard";
interface SelectedWorkOrder {
id: string | number;
number: string;
title: string;
}
interface DispatchCreateModalProps { interface DispatchCreateModalProps {
workOrder: WorkOrderDetail; workOrder: WorkOrderDetail;
@ -48,7 +44,7 @@ export function DispatchCreateModal({
const [selectedTemplateId, setSelectedTemplateId] = useState(""); const [selectedTemplateId, setSelectedTemplateId] = useState("");
const [customItems, setCustomItems] = useState<string[]>([]); const [customItems, setCustomItems] = useState<string[]>([]);
const [newCustomItem, setNewCustomItem] = useState(""); const [newCustomItem, setNewCustomItem] = useState("");
const [selectedWOs, setSelectedWOs] = useState<SelectedWorkOrder[]>([ const [selectedWOs, setSelectedWOs] = useState<SelectedWorkOrderRef[]>([
{ {
id: workOrder.id, id: workOrder.id,
number: workOrder.internalWONumber, number: workOrder.internalWONumber,
@ -61,8 +57,10 @@ export function DispatchCreateModal({
workOrder.trade, workOrder.trade,
workOrder.locationZip, workOrder.locationZip,
); );
const { data: taskTemplates = [] } = useTaskListTemplates(); const { data: taskTemplates = [], isLoading: isLoadingTemplates } = useTaskListTemplates();
const dispatchMutation = useDispatchToVendor(workOrder.id); const dispatchMutation = useDispatchToVendor(workOrder.id);
const isPending = dispatchMutation.isPending;
const handleClose = guardDialogClose(isPending, onClose);
const { data: siteWorkOrdersData } = useWorkOrdersList({ const { data: siteWorkOrdersData } = useWorkOrdersList({
search: "", search: "",
@ -84,6 +82,7 @@ export function DispatchCreateModal({
vendors.filter( vendors.filter(
(vendor) => (vendor) =>
vendor.companyName.toLowerCase().includes(vendorSearch.toLowerCase()) || vendor.companyName.toLowerCase().includes(vendorSearch.toLowerCase()) ||
vendor.contactName.toLowerCase().includes(vendorSearch.toLowerCase()) ||
vendor.tradeSpecialties.toLowerCase().includes(vendorSearch.toLowerCase()), vendor.tradeSpecialties.toLowerCase().includes(vendorSearch.toLowerCase()),
), ),
[vendors, vendorSearch], [vendors, vendorSearch],
@ -128,13 +127,13 @@ export function DispatchCreateModal({
}; };
return ( return (
<Dialog open onClose={onClose} maxWidth="md" fullWidth> <Dialog open onClose={handleClose} maxWidth="md" fullWidth>
<DialogTitle className="flex items-center justify-between pr-2"> <DialogTitle className="flex items-center justify-between pr-2">
Dispatch{" "} Dispatch{" "}
{selectedWOs.length > 1 {selectedWOs.length > 1
? `${selectedWOs.length} Work Orders` ? `${selectedWOs.length} Work Orders`
: `Work Order ${workOrder.internalWONumber}`} : `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" /> <CloseIcon fontSize="small" />
</IconButton> </IconButton>
</DialogTitle> </DialogTitle>
@ -208,13 +207,11 @@ export function DispatchCreateModal({
className="mb-2" className="mb-2"
/> />
{isLoadingVendors ? ( {isLoadingVendors ? (
<Typography <Stack spacing={1} aria-busy="true" role="status" aria-label="Loading vendors">
sx={{ {Array.from({ length: 3 }, (_, i) => (
color: "text.secondary", <Skeleton key={i} variant="rounded" height={56} animation="wave" />
}} ))}
> </Stack>
Loading vendors...
</Typography>
) : filteredVendors.length === 0 ? ( ) : filteredVendors.length === 0 ? (
<Typography <Typography
sx={{ sx={{
@ -242,6 +239,17 @@ export function DispatchCreateModal({
<Typography variant="body2" className="font-semibold"> <Typography variant="body2" className="font-semibold">
{vendor.companyName} {vendor.companyName}
</Typography> </Typography>
{Boolean(vendor.contactName) && (
<Typography
variant="caption"
sx={{
color: "text.secondary",
display: "block",
}}
>
{vendor.contactName}
</Typography>
)}
{Boolean(vendor.tradeSpecialties) && ( {Boolean(vendor.tradeSpecialties) && (
<Typography <Typography
variant="caption" variant="caption"
@ -297,20 +305,25 @@ export function DispatchCreateModal({
/> />
</Stack> </Stack>
<TextField {isLoadingTemplates ? (
label="Task List Template" <Skeleton variant="rounded" height={56} animation="wave" aria-label="Loading templates" />
select ) : (
value={selectedTemplateId} <TextField
onChange={(event) => setSelectedTemplateId(event.target.value)} label="Task List Template"
fullWidth select
> value={selectedTemplateId}
<MenuItem value="">No checklist template</MenuItem> onChange={(event) => setSelectedTemplateId(event.target.value)}
{taskTemplates.map((template) => ( fullWidth
<MenuItem key={String(template.id)} value={String(template.id)}> disabled={isPending}
{template.name} ({template.items?.length ?? 0} items) >
</MenuItem> <MenuItem value="">No checklist template</MenuItem>
))} {taskTemplates.map((template) => (
</TextField> <MenuItem key={String(template.id)} value={String(template.id)}>
{template.name} ({template.items?.length ?? 0} items)
</MenuItem>
))}
</TextField>
)}
{customItems.length > 0 && ( {customItems.length > 0 && (
<Stack <Stack
@ -355,15 +368,16 @@ export function DispatchCreateModal({
/> />
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={onClose} disabled={dispatchMutation.isPending}> <Button variant="outline" onClick={onClose} disabled={isPending}>
Cancel Cancel
</Button> </Button>
<Button <Button
variant="contained"
onClick={handleSubmit} onClick={handleSubmit}
disabled={dispatchMutation.isPending || !selectedVendorId} disabled={!selectedVendorId}
loading={isPending}
loadingText="Dispatching…"
> >
{dispatchMutation.isPending ? "Dispatching..." : "Dispatch"} Dispatch
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>

View file

@ -6,6 +6,7 @@ type DispatchChecklistSectionProps = {
items: DispatchChecklistItem[]; items: DispatchChecklistItem[];
isLocked: boolean; isLocked: boolean;
isUpdating: boolean; isUpdating: boolean;
isAdding?: boolean;
newItem: string; newItem: string;
onNewItemChange: (value: string) => void; onNewItemChange: (value: string) => void;
onToggleItem: (itemId: string | number, isCompleted: boolean) => void; onToggleItem: (itemId: string | number, isCompleted: boolean) => void;
@ -16,6 +17,7 @@ export function DispatchChecklistSection({
items, items,
isLocked, isLocked,
isUpdating, isUpdating,
isAdding = false,
newItem, newItem,
onNewItemChange, onNewItemChange,
onToggleItem, onToggleItem,
@ -58,14 +60,16 @@ export function DispatchChecklistSection({
value={newItem} value={newItem}
onChange={(event) => onNewItemChange(event.target.value)} onChange={(event) => onNewItemChange(event.target.value)}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === "Enter" && newItem.trim()) { if (event.key === "Enter" && newItem.trim() && !isAdding) {
event.preventDefault(); event.preventDefault();
onAddItem(newItem.trim()); onAddItem(newItem.trim());
} }
}} }}
placeholder="Add checklist item..." placeholder={isAdding ? "Adding…" : "Add checklist item..."}
disabled={isAdding}
fullWidth fullWidth
className="mt-2" className="mt-2"
slotProps={{ htmlInput: { "aria-busy": isAdding || undefined } }}
/> />
)} )}
</Box> </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 { DetailField } from "@/components/common/detail-field";
import { Button } from "@/components/ui/button";
import { import {
DISPATCH_STATUS_OPTIONS, DISPATCH_STATUS_OPTIONS,
type DispatchDetail, type DispatchDetail,
@ -84,10 +85,12 @@ export function DispatchEditForm({
onChange={(event) => onChange({ ...form, description: event.target.value })} onChange={(event) => onChange({ ...form, description: event.target.value })}
/> />
<Stack direction="row" spacing={1} className="sm:col-span-2"> <Stack direction="row" spacing={1} className="sm:col-span-2">
<Button variant="contained" onClick={onSave} disabled={isSaving}> <Button onClick={onSave} loading={isSaving} loadingText="Saving…">
{isSaving ? "Saving..." : "Save"} Save
</Button>
<Button variant="outline" onClick={onCancel} disabled={isSaving}>
Cancel
</Button> </Button>
<Button onClick={onCancel}>Cancel</Button>
</Stack> </Stack>
</> </>
); );
@ -138,12 +141,10 @@ export function DispatchDetailsView({
)} )}
{!isLocked && ( {!isLocked && (
<Stack direction="row" spacing={1} className="sm:col-span-2"> <Stack direction="row" spacing={1} className="sm:col-span-2">
<Button color="error" variant="outlined" onClick={onCancelDispatch}> <Button variant="destructive" onClick={onCancelDispatch}>
Cancel Dispatch Cancel Dispatch
</Button> </Button>
<Button variant="contained" onClick={onEdit}> <Button onClick={onEdit}>Edit</Button>
Edit
</Button>
</Stack> </Stack>
)} )}
</> </>

View file

@ -5,12 +5,14 @@ import { formatDateTime } from "@/lib/time-utils";
type DispatchSignoffsSectionProps = { type DispatchSignoffsSectionProps = {
signoffs: DispatchSignoff[]; signoffs: DispatchSignoff[];
isLocked: boolean; isLocked: boolean;
isSigningOff?: boolean;
onCollectSignature: (type: "Customer" | "Vendor") => void; onCollectSignature: (type: "Customer" | "Vendor") => void;
}; };
export function DispatchSignoffsSection({ export function DispatchSignoffsSection({
signoffs, signoffs,
isLocked, isLocked,
isSigningOff = false,
onCollectSignature, onCollectSignature,
}: DispatchSignoffsSectionProps) { }: DispatchSignoffsSectionProps) {
return ( return (
@ -49,9 +51,9 @@ export function DispatchSignoffsSection({
variant="outlined" variant="outlined"
className="mt-2" className="mt-2"
onClick={() => onCollectSignature(type)} onClick={() => onCollectSignature(type)}
disabled={isLocked} disabled={isLocked || isSigningOff}
> >
{isLocked ? "Not signed" : "Collect Signature"} {isLocked ? "Not signed" : isSigningOff ? "Saving…" : "Collect Signature"}
</Button> </Button>
)} )}
</Box> </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 { CommentTimeline } from "@/components/common/comment-timeline";
import { mapDispatchCommentsToTimeline } from "@/components/common/comment-timeline-mappers"; import { mapDispatchCommentsToTimeline } from "@/components/common/comment-timeline-mappers";
import { Button } from "@/components/ui/button";
import type { DispatchComment } from "@/domain/work-orders/types/work-order"; import type { DispatchComment } from "@/domain/work-orders/types/work-order";
type DispatchVendorCommunicationProps = { type DispatchVendorCommunicationProps = {
@ -61,10 +62,11 @@ export function DispatchVendorCommunication({
/> />
<Button <Button
type="submit" type="submit"
variant="contained" disabled={isLocked || !commentText.trim()}
disabled={isLocked || isPending || !commentText.trim()} loading={isPending}
loadingText="Sending…"
> >
{isPending ? "Sending..." : "Send"} Send
</Button> </Button>
</Stack> </Stack>
</Box> </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"; import { formatDateTime } from "@/lib/time-utils";
type DispatchVerifyFooterProps = { type DispatchVerifyFooterProps = {
@ -34,10 +35,15 @@ export function DispatchVerifyFooter({
</Typography> </Typography>
)} )}
<Button <Button
variant="contained" disabled={!canVerify}
color="success" loading={isPending}
disabled={!canVerify || isPending} loadingText="Verifying…"
onClick={onVerify} onClick={onVerify}
sx={{
backgroundColor: "success.main",
color: "#fff",
"&:hover": { backgroundColor: "success.dark" },
}}
> >
Verify & Close Verify & Close
</Button> </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 { SignatureCapture } from "@/components/common/signature-capture";
import { useDispatchDetailController } from "@/app/(protected)/workorders/_hooks/use-dispatch-detail-controller"; 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 { DispatchChecklistSection } from "./dispatch-checklist-section";
import { DispatchDetailHeader } from "./dispatch-detail-header"; import { DispatchDetailHeader } from "./dispatch-detail-header";
import { DispatchDetailsGrid, DispatchDetailsView, DispatchEditForm } from "./dispatch-edit-form"; import { DispatchDetailsGrid, DispatchDetailsView, DispatchEditForm } from "./dispatch-edit-form";
@ -23,13 +25,12 @@ export function DispatchDetailModal({
onClose, onClose,
}: DispatchDetailModalProps) { }: DispatchDetailModalProps) {
const controller = useDispatchDetailController(dispatchId, workOrderId); const controller = useDispatchDetailController(dispatchId, workOrderId);
const handleClose = guardDialogClose(controller.isMutating, onClose);
if (controller.isLoading) { if (controller.isLoading && !controller.dispatch) {
return ( return (
<Dialog open onClose={onClose} maxWidth="md" fullWidth> <Dialog open onClose={onClose} maxWidth="md" fullWidth>
<DialogContent className="flex justify-center py-12"> <DispatchDetailSkeleton />
<CircularProgress />
</DialogContent>
</Dialog> </Dialog>
); );
} }
@ -42,9 +43,14 @@ export function DispatchDetailModal({
return ( return (
<> <>
<Dialog open onClose={onClose} maxWidth="md" fullWidth scroll="paper"> <Dialog open onClose={handleClose} maxWidth="md" fullWidth scroll="paper">
<DialogTitle> <DialogTitle>
<DispatchDetailHeader dispatch={dispatch} onClose={onClose} /> <DispatchDetailHeader
dispatch={dispatch}
onClose={() => {
if (!controller.isMutating) onClose();
}}
/>
</DialogTitle> </DialogTitle>
<DialogContent className="flex flex-col gap-6"> <DialogContent className="flex flex-col gap-6">
@ -72,6 +78,7 @@ export function DispatchDetailModal({
items={dispatch.checklistItems} items={dispatch.checklistItems}
isLocked={controller.isLocked} isLocked={controller.isLocked}
isUpdating={controller.updateChecklistMutation.isPending} isUpdating={controller.updateChecklistMutation.isPending}
isAdding={controller.addChecklistMutation.isPending}
newItem={controller.newChecklistItem} newItem={controller.newChecklistItem}
onNewItemChange={controller.setNewChecklistItem} onNewItemChange={controller.setNewChecklistItem}
onToggleItem={(itemId, isCompleted) => onToggleItem={(itemId, isCompleted) =>
@ -92,15 +99,20 @@ export function DispatchDetailModal({
<DispatchSignoffsSection <DispatchSignoffsSection
signoffs={dispatch.signoffs} signoffs={dispatch.signoffs}
isLocked={controller.isLocked} isLocked={controller.isLocked}
isSigningOff={controller.addSignoffMutation.isPending}
onCollectSignature={controller.setSigCaptureType} onCollectSignature={controller.setSigCaptureType}
/> />
<DispatchUpliftsSection {controller.isLoadingUplifts ? (
uplifts={controller.uplifts} <Skeleton variant="rounded" height={80} animation="wave" aria-label="Loading uplifts" />
isLocked={controller.isLocked} ) : (
onApprove={(uplift) => controller.setUpliftAction({ type: "approve", uplift })} <DispatchUpliftsSection
onDeny={(uplift) => controller.setUpliftAction({ type: "deny", uplift })} uplifts={controller.uplifts}
/> isLocked={controller.isLocked}
onApprove={(uplift) => controller.setUpliftAction({ type: "approve", uplift })}
onDeny={(uplift) => controller.setUpliftAction({ type: "deny", uplift })}
/>
)}
<DispatchVendorCommunication <DispatchVendorCommunication
comments={dispatch.comments} comments={dispatch.comments}
@ -127,7 +139,11 @@ export function DispatchDetailModal({
{controller.sigCaptureType != null && ( {controller.sigCaptureType != null && (
<SignatureCapture <SignatureCapture
title={`${controller.sigCaptureType} Sign-off`} title={`${controller.sigCaptureType} Sign-off`}
onClose={() => controller.setSigCaptureType(null)} onClose={() => {
if (!controller.addSignoffMutation.isPending) {
controller.setSigCaptureType(null);
}
}}
onSave={(signature, method, name) => { onSave={(signature, method, name) => {
controller.addSignoffMutation.mutate( controller.addSignoffMutation.mutate(
{ {
@ -148,7 +164,14 @@ export function DispatchDetailModal({
type={controller.upliftAction.type} type={controller.upliftAction.type}
requestedNTE={controller.upliftAction.uplift.requestedNTE} requestedNTE={controller.upliftAction.uplift.requestedNTE}
currentNTE={controller.upliftAction.uplift.currentNTE} currentNTE={controller.upliftAction.uplift.currentNTE}
onClose={() => controller.setUpliftAction(null)} onClose={() => {
if (
!controller.approveUpliftMutation.isPending &&
!controller.denyUpliftMutation.isPending
) {
controller.setUpliftAction(null);
}
}}
onConfirm={controller.handleUpliftConfirm} onConfirm={controller.handleUpliftConfirm}
isPending={ isPending={
controller.approveUpliftMutation.isPending || controller.denyUpliftMutation.isPending controller.approveUpliftMutation.isPending || controller.denyUpliftMutation.isPending

View file

@ -1,6 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { import {
Button,
Dialog, Dialog,
DialogActions, DialogActions,
DialogContent, DialogContent,
@ -8,6 +7,8 @@ import {
TextField, TextField,
Typography, Typography,
} from "@mui/material"; } from "@mui/material";
import { Button } from "@/components/ui/button";
import { guardDialogClose } from "@/lib/dialog-guard";
type UpliftDecisionDialogProps = { type UpliftDecisionDialogProps = {
open: boolean; open: boolean;
@ -31,17 +32,17 @@ export function UpliftDecisionDialog({
const [note, setNote] = useState(""); const [note, setNote] = useState("");
const handleClose = () => { const handleClose = () => {
if (isPending) return;
setNote(""); setNote("");
onClose(); onClose();
}; };
const handleConfirm = () => { const handleConfirm = () => {
onConfirm(note.trim()); onConfirm(note.trim());
setNote("");
}; };
return ( return (
<Dialog open={open} onClose={handleClose}> <Dialog open={open} onClose={guardDialogClose(isPending, handleClose)}>
<DialogTitle> <DialogTitle>
{type === "approve" ? `Approve uplift to $${requestedNTE.toFixed(2)}?` : "Deny Uplift"} {type === "approve" ? `Approve uplift to $${requestedNTE.toFixed(2)}?` : "Deny Uplift"}
</DialogTitle> </DialogTitle>
@ -60,14 +61,18 @@ export function UpliftDecisionDialog({
label={type === "deny" ? "Denial reason (required)" : "Optional note"} label={type === "deny" ? "Denial reason (required)" : "Optional note"}
value={note} value={note}
onChange={(event) => setNote(event.target.value)} onChange={(event) => setNote(event.target.value)}
disabled={isPending}
/> />
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={handleClose}>Cancel</Button> <Button variant="outline" onClick={handleClose} disabled={isPending}>
Cancel
</Button>
<Button <Button
variant="contained" variant={type === "approve" ? "default" : "destructive"}
color={type === "approve" ? "success" : "error"} disabled={type === "deny" ? !note.trim() : false}
disabled={type === "deny" ? !note.trim() || isPending : isPending} loading={isPending}
loadingText="Confirming…"
onClick={handleConfirm} onClick={handleConfirm}
> >
Confirm Confirm

View file

@ -52,8 +52,8 @@ export function useDispatchDetailController(
workOrderId: string | number, workOrderId: string | number,
) { ) {
const { user } = useAuthContext(); const { user } = useAuthContext();
const { data: dispatch, isLoading } = useDispatchDetail(dispatchId); const { data: dispatch, isLoading, isFetching, refetch } = useDispatchDetail(dispatchId);
const { data: uplifts = [] } = useUpliftsForDispatch(dispatchId); const { data: uplifts = [], isLoading: isLoadingUplifts } = useUpliftsForDispatch(dispatchId);
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [form, setForm] = useState<DispatchEditFormState>({ const [form, setForm] = useState<DispatchEditFormState>({
@ -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 { return {
dispatch, dispatch,
isLoading, isLoading,
isFetching,
refetch,
uplifts, uplifts,
isLoadingUplifts,
editing, editing,
form, form,
setForm, setForm,
@ -175,6 +188,7 @@ export function useDispatchDetailController(
upliftAction, upliftAction,
setUpliftAction, setUpliftAction,
isLocked, isLocked,
isMutating,
userName, userName,
verifyState, verifyState,
beginEditing, beginEditing,