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
(cherry picked from commit a9ee990389abada03ecbd600ee63c5a052507118)
This commit is contained in:
parent
c2bd1c4e60
commit
175682e229
10 changed files with 166 additions and 92 deletions
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue