mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
refactor(work-orders): harden dispatch modals with loading and close guards
This commit is contained in:
parent
ed3bd71b5f
commit
5f25d1a774
10 changed files with 178 additions and 98 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue