mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-06 05:22:04 +00:00
feat: add work orders module with dispatch modals
This commit is contained in:
parent
77e128af5a
commit
a27e7607d3
15 changed files with 3561 additions and 0 deletions
479
src/app/(protected)/workorders/[id].tsx
Normal file
479
src/app/(protected)/workorders/[id].tsx
Normal file
|
|
@ -0,0 +1,479 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { Link as RouterLink, useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionDetails,
|
||||
AccordionSummary,
|
||||
Alert,
|
||||
Box,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Link,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Stack,
|
||||
Tab,
|
||||
Tabs,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||
import { DispatchCreateModal } from "./_components/dispatch-create-modal";
|
||||
import { DispatchDetailModal } from "./_components/dispatch-detail-modal";
|
||||
import type { WorkOrderAuditEntry, WorkOrderComment } from "@/domain/work-orders/types/work-order";
|
||||
import { WORK_ORDER_STATUS_OPTIONS } from "@/domain/work-orders/types/work-order";
|
||||
import { useWorkOrderDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||
import {
|
||||
useAddWorkOrderComment,
|
||||
useChangeWorkOrderAssignment,
|
||||
useChangeWorkOrderStatus,
|
||||
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
||||
import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
|
||||
import { formatDateTime, timeSince, waitTimeColor } from "@/lib/time-utils";
|
||||
|
||||
const STATUS_COLORS: Record<
|
||||
string,
|
||||
"default" | "primary" | "secondary" | "error" | "info" | "success" | "warning"
|
||||
> = {
|
||||
Open: "primary",
|
||||
"In Progress": "info",
|
||||
"On Hold": "warning",
|
||||
Done: "success",
|
||||
Cancelled: "default",
|
||||
};
|
||||
|
||||
type ActivityTab = "customer" | "internal" | "audit";
|
||||
|
||||
function CommentTimeline({ comments }: { comments: WorkOrderComment[] }) {
|
||||
const sorted = useMemo(
|
||||
() =>
|
||||
[...comments].sort(
|
||||
(a, b) => new Date(a.createdDate).getTime() - new Date(b.createdDate).getTime(),
|
||||
),
|
||||
[comments],
|
||||
);
|
||||
|
||||
if (!sorted.length) {
|
||||
return <Typography color="text.secondary">No comments yet.</Typography>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack spacing={2} className="max-h-72 overflow-y-auto">
|
||||
{sorted.map((comment) => (
|
||||
<Box key={String(comment.id)} className="rounded border border-border p-3">
|
||||
<Stack direction="row" justifyContent="space-between" className="mb-1">
|
||||
<Typography variant="caption" className="font-semibold">
|
||||
{comment.userName || comment.commenter || "System"}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{comment.createdDate ? formatDateTime(comment.createdDate) : ""}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography variant="body2">{comment.commenttext}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function AuditTimeline({ entries }: { entries: WorkOrderAuditEntry[] }) {
|
||||
const sorted = useMemo(
|
||||
() =>
|
||||
[...entries].sort(
|
||||
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
|
||||
),
|
||||
[entries],
|
||||
);
|
||||
|
||||
if (!sorted.length) {
|
||||
return <Typography color="text.secondary">No changes recorded.</Typography>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack spacing={2} className="max-h-72 overflow-y-auto">
|
||||
{sorted.map((entry) => (
|
||||
<Box key={String(entry.id)} className="rounded border border-border p-3">
|
||||
<Stack direction="row" justifyContent="space-between" className="mb-1">
|
||||
<Typography variant="caption" className="font-semibold">
|
||||
{entry.userName || "System"}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{entry.createdAt ? formatDateTime(entry.createdAt) : ""}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography variant="body2">
|
||||
<strong>{entry.fieldName}</strong> {entry.oldValue} → {entry.newValue}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WorkOrderViewPage() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const workOrderId = id ?? "";
|
||||
|
||||
const { data: wo, isLoading, error, refetch } = useWorkOrderDetail(workOrderId);
|
||||
const { data: users = [] } = useWorkOrderUsers();
|
||||
const statusMutation = useChangeWorkOrderStatus(workOrderId);
|
||||
const assignmentMutation = useChangeWorkOrderAssignment(workOrderId);
|
||||
const commentMutation = useAddWorkOrderComment(workOrderId);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<ActivityTab>("customer");
|
||||
const [commentText, setCommentText] = useState("");
|
||||
const [dispatchOpen, setDispatchOpen] = useState(false);
|
||||
const [dispatchDetailId, setDispatchDetailId] = useState<string | number | null>(null);
|
||||
const [statusAnchor, setStatusAnchor] = useState<HTMLElement | null>(null);
|
||||
const [assignAnchor, setAssignAnchor] = useState<HTMLElement | null>(null);
|
||||
|
||||
const customerComments = useMemo(
|
||||
() => (wo?.comments ?? []).filter((comment) => comment.commentType === "customer"),
|
||||
[wo?.comments],
|
||||
);
|
||||
const internalComments = useMemo(
|
||||
() => (wo?.comments ?? []).filter((comment) => comment.commentType === "internal"),
|
||||
[wo?.comments],
|
||||
);
|
||||
const auditLog = wo?.auditLog ?? [];
|
||||
|
||||
const handleCommentSubmit = (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!commentText.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
commentMutation.mutate(
|
||||
{ text: commentText.trim(), commentType: activeTab },
|
||||
{ onSuccess: () => setCommentText("") },
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box className="flex justify-center p-12">
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !wo) {
|
||||
return (
|
||||
<Box className="p-4">
|
||||
<Alert severity="error">
|
||||
{error instanceof Error ? error.message : "Work order not found."}
|
||||
</Alert>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className="flex flex-col gap-4 p-4">
|
||||
<Breadcrumbs>
|
||||
<Link component={RouterLink} to="/workorders" underline="hover">
|
||||
Work Orders
|
||||
</Link>
|
||||
<Typography color="text.primary">{wo.internalWONumber}</Typography>
|
||||
</Breadcrumbs>
|
||||
|
||||
<Stack
|
||||
direction={{ xs: "column", md: "row" }}
|
||||
spacing={2}
|
||||
justifyContent="space-between"
|
||||
alignItems={{ xs: "stretch", md: "flex-start" }}
|
||||
>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="h5" component="h1">
|
||||
{wo.title}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={wo.status}
|
||||
color={STATUS_COLORS[wo.status] ?? "default"}
|
||||
onClick={(event) => setStatusAnchor(event.currentTarget)}
|
||||
className="w-fit cursor-pointer"
|
||||
/>
|
||||
<Menu
|
||||
anchorEl={statusAnchor}
|
||||
open={Boolean(statusAnchor)}
|
||||
onClose={() => setStatusAnchor(null)}
|
||||
>
|
||||
{WORK_ORDER_STATUS_OPTIONS.map((status) => (
|
||||
<MenuItem
|
||||
key={status}
|
||||
selected={status === wo.status}
|
||||
onClick={() => {
|
||||
setStatusAnchor(null);
|
||||
if (status !== wo.status) {
|
||||
statusMutation.mutate(status);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{status}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" spacing={1} 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")}>
|
||||
Back to List
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Paper variant="outlined" className="p-4">
|
||||
<Typography variant="subtitle1" className="mb-3 font-semibold">
|
||||
Details
|
||||
</Typography>
|
||||
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Field label="Internal WO" value={wo.internalWONumber} />
|
||||
<Field label="Customer WO" value={wo.workOrderNumber || "—"} />
|
||||
<Field label="Priority" value={wo.priority || "—"} />
|
||||
<Field label="Location" value={wo.location || "—"} />
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" display="block">
|
||||
Assigned To
|
||||
</Typography>
|
||||
<Button size="small" onClick={(event) => setAssignAnchor(event.currentTarget)}>
|
||||
{wo.assignedTo || "Unassigned"}
|
||||
</Button>
|
||||
<Menu
|
||||
anchorEl={assignAnchor}
|
||||
open={Boolean(assignAnchor)}
|
||||
onClose={() => setAssignAnchor(null)}
|
||||
>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setAssignAnchor(null);
|
||||
assignmentMutation.mutate({ userId: "", userName: "" });
|
||||
}}
|
||||
>
|
||||
Unassigned
|
||||
</MenuItem>
|
||||
{users.map((user) => (
|
||||
<MenuItem
|
||||
key={String(user.id)}
|
||||
selected={wo.assignedTo === user.name}
|
||||
onClick={() => {
|
||||
setAssignAnchor(null);
|
||||
assignmentMutation.mutate({ userId: user.id, userName: user.name });
|
||||
}}
|
||||
>
|
||||
{user.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</Box>
|
||||
<Field label="Source" value={wo.source || "—"} />
|
||||
<Field label="TT" value={wo.tt || "—"} />
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography fontWeight={600}>Classification</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<Field label="Problem" value={wo.problem || "—"} />
|
||||
<Field label="Trade" value={wo.trade || "—"} />
|
||||
<Field label="Sub-Trade" value={wo.subTrade || "—"} />
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography fontWeight={600}>Schedule</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Field
|
||||
label="Created"
|
||||
value={wo.createdDateFull ? formatDateTime(wo.createdDateFull) : "—"}
|
||||
/>
|
||||
<Field label="Due Date" value={wo.dueDate || "—"} />
|
||||
<Field label="Scheduled" value={wo.scheduledDate || "—"} />
|
||||
<Field label="Completed" value={wo.completedDate || "—"} />
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography fontWeight={600}>Financial</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<Field
|
||||
label="Vendor NTE"
|
||||
value={wo.vendorNTE ? `$${parseFloat(wo.vendorNTE).toFixed(2)}` : "—"}
|
||||
/>
|
||||
<Field label="Customer PO" value={wo.po || "—"} />
|
||||
<Field label="Customer NTE" value="Not connected" />
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
<Accordion defaultExpanded={Boolean(wo.dispatches.length)}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography fontWeight={600}>Dispatches ({wo.dispatches.length})</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
{wo.dispatches.length ? (
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Dispatch #</TableCell>
|
||||
<TableCell>Vendor</TableCell>
|
||||
<TableCell>NTE</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell>Date</TableCell>
|
||||
<TableCell>Wait</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{wo.dispatches.map((dispatch) => {
|
||||
const waitColor = waitTimeColor(dispatch.dispatchedAt);
|
||||
return (
|
||||
<TableRow
|
||||
key={String(dispatch.id)}
|
||||
hover
|
||||
className="cursor-pointer"
|
||||
onClick={() => setDispatchDetailId(dispatch.id)}
|
||||
>
|
||||
<TableCell>{dispatch.dispatchNumber || dispatch.poNumber}</TableCell>
|
||||
<TableCell>{dispatch.vendorName}</TableCell>
|
||||
<TableCell>
|
||||
{dispatch.nteAmount != null ? `$${dispatch.nteAmount.toFixed(2)}` : "—"}
|
||||
</TableCell>
|
||||
<TableCell>{dispatch.status}</TableCell>
|
||||
<TableCell>
|
||||
{dispatch.dispatchedAt ? formatDateTime(dispatch.dispatchedAt) : "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{dispatch.status === "Sent" ? (
|
||||
<Typography
|
||||
variant="caption"
|
||||
className={
|
||||
waitColor === "green"
|
||||
? "text-green-600"
|
||||
: waitColor === "yellow"
|
||||
? "text-yellow-600"
|
||||
: "text-red-600"
|
||||
}
|
||||
>
|
||||
{timeSince(dispatch.dispatchedAt)}
|
||||
</Typography>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
) : (
|
||||
<Typography color="text.secondary">No dispatches yet.</Typography>
|
||||
)}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography fontWeight={600}>Description</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Typography>{wo.description || "No description provided."}</Typography>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
<Paper variant="outlined" className="p-4">
|
||||
<Typography variant="subtitle1" className="mb-3 font-semibold">
|
||||
Activity
|
||||
</Typography>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={(_event, value: ActivityTab) => setActiveTab(value)}
|
||||
className="mb-3"
|
||||
>
|
||||
<Tab value="customer" label={`Customer (${customerComments.length})`} />
|
||||
<Tab value="internal" label={`Internal (${internalComments.length})`} />
|
||||
<Tab value="audit" label={`Audit Log (${auditLog.length})`} />
|
||||
</Tabs>
|
||||
|
||||
{activeTab === "customer" && <CommentTimeline comments={customerComments} />}
|
||||
{activeTab === "internal" && <CommentTimeline comments={internalComments} />}
|
||||
{activeTab === "audit" && <AuditTimeline entries={auditLog} />}
|
||||
|
||||
{activeTab === "internal" && (
|
||||
<Box component="form" onSubmit={handleCommentSubmit} className="mt-4 flex flex-col gap-2">
|
||||
<TextField
|
||||
multiline
|
||||
rows={2}
|
||||
value={commentText}
|
||||
onChange={(event) => setCommentText(event.target.value)}
|
||||
placeholder="Type a comment..."
|
||||
disabled={commentMutation.isPending}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
className="self-end"
|
||||
disabled={commentMutation.isPending || !commentText.trim()}
|
||||
>
|
||||
{commentMutation.isPending ? "Sending..." : "Send"}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{dispatchOpen && (
|
||||
<DispatchCreateModal
|
||||
workOrder={wo}
|
||||
onClose={() => setDispatchOpen(false)}
|
||||
onDispatched={() => void refetch()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{dispatchDetailId && (
|
||||
<DispatchDetailModal
|
||||
dispatchId={dispatchDetailId}
|
||||
workOrderId={workOrderId}
|
||||
onClose={() => setDispatchDetailId(null)}
|
||||
onUpdated={() => void refetch()}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" display="block">
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="body2">{value}</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,341 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
IconButton,
|
||||
MenuItem,
|
||||
Radio,
|
||||
Stack,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import { useWorkOrdersList } from "@/domain/work-orders/use-cases/use-work-orders-list";
|
||||
import {
|
||||
useTaskListTemplates,
|
||||
useWorkOrderVendors,
|
||||
} from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||
import { useDispatchToVendor } from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
||||
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order";
|
||||
|
||||
interface SelectedWorkOrder {
|
||||
id: string | number;
|
||||
number: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface DispatchCreateModalProps {
|
||||
workOrder: WorkOrderDetail;
|
||||
onClose: () => void;
|
||||
onDispatched: () => void;
|
||||
}
|
||||
|
||||
export function DispatchCreateModal({
|
||||
workOrder,
|
||||
onClose,
|
||||
onDispatched,
|
||||
}: DispatchCreateModalProps) {
|
||||
const [selectedVendorId, setSelectedVendorId] = useState<string | number | null>(null);
|
||||
const [nteAmount, setNteAmount] = useState(workOrder.vendorNTE || "");
|
||||
const [scheduledDate, setScheduledDate] = useState("");
|
||||
const [description, setDescription] = useState(workOrder.description || "");
|
||||
const [vendorSearch, setVendorSearch] = useState("");
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState("");
|
||||
const [customItems, setCustomItems] = useState<string[]>([]);
|
||||
const [newCustomItem, setNewCustomItem] = useState("");
|
||||
const [selectedWOs, setSelectedWOs] = useState<SelectedWorkOrder[]>([
|
||||
{
|
||||
id: workOrder.id,
|
||||
number: workOrder.internalWONumber,
|
||||
title: workOrder.title,
|
||||
},
|
||||
]);
|
||||
const [woSearch, setWoSearch] = useState("");
|
||||
|
||||
const { data: vendors = [], isLoading: isLoadingVendors } = useWorkOrderVendors(
|
||||
workOrder.trade,
|
||||
workOrder.locationZip,
|
||||
);
|
||||
const { data: taskTemplates = [] } = useTaskListTemplates();
|
||||
const dispatchMutation = useDispatchToVendor(workOrder.id);
|
||||
|
||||
const { data: siteWorkOrdersData } = useWorkOrdersList({
|
||||
search: "",
|
||||
page: 1,
|
||||
pageSize: 500,
|
||||
sortBy: "number",
|
||||
sortDir: "asc",
|
||||
locationId: workOrder.locationId,
|
||||
});
|
||||
|
||||
const siteWorkOrders = useMemo(
|
||||
() =>
|
||||
(siteWorkOrdersData?.items ?? []).filter((item) => String(item.id) !== String(workOrder.id)),
|
||||
[siteWorkOrdersData?.items, workOrder.id],
|
||||
);
|
||||
|
||||
const filteredVendors = useMemo(
|
||||
() =>
|
||||
vendors.filter(
|
||||
(vendor) =>
|
||||
vendor.companyName.toLowerCase().includes(vendorSearch.toLowerCase()) ||
|
||||
vendor.tradeSpecialties.toLowerCase().includes(vendorSearch.toLowerCase()),
|
||||
),
|
||||
[vendors, vendorSearch],
|
||||
);
|
||||
|
||||
const filteredSiteWOs = useMemo(
|
||||
() =>
|
||||
siteWorkOrders
|
||||
.filter((item) => !selectedWOs.some((selected) => String(selected.id) === String(item.id)))
|
||||
.filter(
|
||||
(item) =>
|
||||
!woSearch ||
|
||||
item.number.includes(woSearch) ||
|
||||
item.title.toLowerCase().includes(woSearch.toLowerCase()),
|
||||
)
|
||||
.slice(0, 10),
|
||||
[siteWorkOrders, selectedWOs, woSearch],
|
||||
);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!selectedVendorId) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatchMutation.mutate(
|
||||
{
|
||||
workOrderIds: selectedWOs.map((wo) => wo.id),
|
||||
vendorIds: [selectedVendorId],
|
||||
nteAmount: nteAmount ? parseFloat(nteAmount) : null,
|
||||
scheduledDate: scheduledDate || null,
|
||||
description,
|
||||
taskListTemplateId: selectedTemplateId ? parseInt(selectedTemplateId, 10) : null,
|
||||
customChecklistItems: customItems.length > 0 ? customItems : null,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
onDispatched();
|
||||
onClose();
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onClose={onClose} 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">
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent className="flex flex-col gap-4">
|
||||
<Box>
|
||||
<Typography variant="subtitle2" className="mb-2">
|
||||
Work Orders ({selectedWOs.length})
|
||||
</Typography>
|
||||
<Stack direction="row" flexWrap="wrap" gap={1}>
|
||||
{selectedWOs.map((wo) => (
|
||||
<Chip
|
||||
key={String(wo.id)}
|
||||
label={`${wo.number} — ${wo.title}`}
|
||||
onDelete={
|
||||
String(wo.id) !== String(workOrder.id)
|
||||
? () => setSelectedWOs((prev) => prev.filter((item) => item.id !== wo.id))
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
{siteWorkOrders.length > 0 && (
|
||||
<Box className="mt-3">
|
||||
<TextField
|
||||
size="small"
|
||||
value={woSearch}
|
||||
onChange={(event) => setWoSearch(event.target.value)}
|
||||
placeholder="Search to filter..."
|
||||
fullWidth
|
||||
className="mb-2"
|
||||
/>
|
||||
<PaperList>
|
||||
{filteredSiteWOs.map((item) => (
|
||||
<Box
|
||||
key={String(item.id)}
|
||||
className="cursor-pointer rounded px-2 py-1 hover:bg-muted/50"
|
||||
onClick={() => {
|
||||
setSelectedWOs((prev) => [
|
||||
...prev,
|
||||
{ id: item.id, number: item.number, title: item.title },
|
||||
]);
|
||||
setWoSearch("");
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2">
|
||||
<strong>{item.number}</strong> — {item.title}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</PaperList>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle2" className="mb-2">
|
||||
Select Vendor
|
||||
</Typography>
|
||||
<TextField
|
||||
size="small"
|
||||
value={vendorSearch}
|
||||
onChange={(event) => setVendorSearch(event.target.value)}
|
||||
placeholder="Search vendors..."
|
||||
fullWidth
|
||||
className="mb-2"
|
||||
/>
|
||||
{isLoadingVendors ? (
|
||||
<Typography color="text.secondary">Loading vendors...</Typography>
|
||||
) : filteredVendors.length === 0 ? (
|
||||
<Typography color="text.secondary">No vendors found</Typography>
|
||||
) : (
|
||||
<Stack spacing={1} className="max-h-60 overflow-y-auto">
|
||||
{filteredVendors.map((vendor) => (
|
||||
<Box
|
||||
key={String(vendor.id)}
|
||||
className={`flex cursor-pointer items-start gap-2 rounded border p-2 ${
|
||||
selectedVendorId === vendor.id
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-border"
|
||||
}`}
|
||||
onClick={() =>
|
||||
setSelectedVendorId((current) => (current === vendor.id ? null : vendor.id))
|
||||
}
|
||||
>
|
||||
<Radio checked={selectedVendorId === vendor.id} readOnly size="small" />
|
||||
<Box className="flex-1">
|
||||
<Typography variant="body2" className="font-semibold">
|
||||
{vendor.companyName}
|
||||
</Typography>
|
||||
{vendor.tradeSpecialties && (
|
||||
<Typography variant="caption" color="text.secondary" display="block">
|
||||
{vendor.tradeSpecialties}
|
||||
</Typography>
|
||||
)}
|
||||
{vendor.address && (
|
||||
<Typography variant="caption" color="text.secondary" display="block">
|
||||
{vendor.address}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
{vendor.distanceMiles != null && (
|
||||
<Typography variant="caption" className="whitespace-nowrap">
|
||||
{vendor.distanceMiles} miles
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||
<TextField
|
||||
label="NTE Amount"
|
||||
type="number"
|
||||
value={nteAmount}
|
||||
onChange={(event) => setNteAmount(event.target.value)}
|
||||
inputProps={{ step: "0.01", min: 0 }}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label="Vendor Scheduled Date"
|
||||
type="date"
|
||||
value={scheduledDate}
|
||||
onChange={(event) => setScheduledDate(event.target.value)}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
fullWidth
|
||||
/>
|
||||
</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>
|
||||
|
||||
{customItems.length > 0 && (
|
||||
<Stack direction="row" flexWrap="wrap" gap={1}>
|
||||
{customItems.map((item, index) => (
|
||||
<Chip
|
||||
key={`${item}-${index}`}
|
||||
label={item}
|
||||
onDelete={() => setCustomItems((prev) => prev.filter((_, i) => i !== index))}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
value={newCustomItem}
|
||||
onChange={(event) => setNewCustomItem(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && newCustomItem.trim()) {
|
||||
event.preventDefault();
|
||||
setCustomItems((prev) => [...prev, newCustomItem.trim()]);
|
||||
setNewCustomItem("");
|
||||
}
|
||||
}}
|
||||
placeholder="Add custom checklist item..."
|
||||
fullWidth
|
||||
size="small"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Description"
|
||||
multiline
|
||||
rows={4}
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} disabled={dispatchMutation.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleSubmit}
|
||||
disabled={dispatchMutation.isPending || !selectedVendorId}
|
||||
>
|
||||
{dispatchMutation.isPending ? "Dispatching..." : "Dispatch"}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function PaperList({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Box className="max-h-40 overflow-y-auto rounded border border-border p-1">{children}</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,698 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
FormControlLabel,
|
||||
IconButton,
|
||||
MenuItem,
|
||||
Stack,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { SignatureCapture } from "@/components/common/signature-capture";
|
||||
import {
|
||||
useApproveUplift,
|
||||
useDenyUplift,
|
||||
useUpliftsForDispatch,
|
||||
} from "@/domain/uplifts/use-cases/use-uplift-actions";
|
||||
import { DISPATCH_STATUS_OPTIONS } from "@/domain/work-orders/types/work-order";
|
||||
import { useDispatchDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||
import {
|
||||
useAddChecklistItem,
|
||||
useAddDispatchComment,
|
||||
useAddDispatchSignoff,
|
||||
useUpdateChecklistItem,
|
||||
useUpdateDispatch,
|
||||
useVerifyDispatch,
|
||||
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
||||
import { isDispatchLocked } from "@/domain/work-orders/utils/is-dispatch-locked";
|
||||
import { useAuthContext } from "@/providers/auth-context";
|
||||
import {
|
||||
formatDateTime,
|
||||
timeBetween,
|
||||
timeSince,
|
||||
toDateInputValue,
|
||||
waitTimeColor,
|
||||
} from "@/lib/time-utils";
|
||||
|
||||
interface DispatchDetailModalProps {
|
||||
dispatchId: string | number;
|
||||
workOrderId: string | number;
|
||||
onClose: () => void;
|
||||
onUpdated: () => void;
|
||||
}
|
||||
|
||||
export function DispatchDetailModal({
|
||||
dispatchId,
|
||||
workOrderId,
|
||||
onClose,
|
||||
onUpdated,
|
||||
}: DispatchDetailModalProps) {
|
||||
const { user } = useAuthContext();
|
||||
const { data: dispatch, isLoading } = useDispatchDetail(dispatchId);
|
||||
const { data: uplifts = [] } = useUpliftsForDispatch(dispatchId);
|
||||
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
status: "",
|
||||
nteAmount: "",
|
||||
scheduledDate: "",
|
||||
completedDate: "",
|
||||
description: "",
|
||||
});
|
||||
const [newChecklistItem, setNewChecklistItem] = useState("");
|
||||
const [commentText, setCommentText] = useState("");
|
||||
const [sendEmail, setSendEmail] = useState(true);
|
||||
const [sigCaptureType, setSigCaptureType] = useState<string | null>(null);
|
||||
|
||||
const updateDispatchMutation = useUpdateDispatch(dispatchId, workOrderId);
|
||||
const addCommentMutation = useAddDispatchComment(dispatchId);
|
||||
const updateChecklistMutation = useUpdateChecklistItem(dispatchId);
|
||||
const addChecklistMutation = useAddChecklistItem(dispatchId);
|
||||
const addSignoffMutation = useAddDispatchSignoff(dispatchId);
|
||||
const verifyMutation = useVerifyDispatch(dispatchId, workOrderId);
|
||||
const approveUpliftMutation = useApproveUplift(dispatchId);
|
||||
const denyUpliftMutation = useDenyUplift(dispatchId);
|
||||
|
||||
const userName =
|
||||
(typeof user?.name === "string" ? user.name : undefined) ??
|
||||
(typeof user?.fullname === "string" ? user.fullname : undefined) ??
|
||||
"User";
|
||||
|
||||
const beginEditing = () => {
|
||||
if (dispatch) {
|
||||
setForm({
|
||||
status: dispatch.status || "",
|
||||
nteAmount: dispatch.nteAmount != null ? String(dispatch.nteAmount) : "",
|
||||
scheduledDate: toDateInputValue(dispatch.scheduledDate),
|
||||
completedDate: toDateInputValue(dispatch.completedDate),
|
||||
description: dispatch.description || "",
|
||||
});
|
||||
}
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const verifyState = useMemo(() => {
|
||||
if (!dispatch) {
|
||||
return { canVerify: false, missing: [] as string[] };
|
||||
}
|
||||
|
||||
const checklistTotal = dispatch.checklistItems.length;
|
||||
const checklistDone = dispatch.checklistItems.filter((item) => item.isCompleted).length;
|
||||
const signoffCount = dispatch.signoffs.length;
|
||||
const missing: string[] = [];
|
||||
|
||||
if (checklistTotal > 0 && checklistDone < checklistTotal) {
|
||||
missing.push(`${checklistTotal - checklistDone} checklist item(s)`);
|
||||
}
|
||||
|
||||
if (signoffCount < 2) {
|
||||
const types = dispatch.signoffs.map((signoff) => signoff.signoffType);
|
||||
if (!types.includes("Customer")) {
|
||||
missing.push("Customer sign-off");
|
||||
}
|
||||
if (!types.includes("Vendor")) {
|
||||
missing.push("Vendor sign-off");
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
missing,
|
||||
canVerify: missing.length === 0 && (checklistTotal > 0 || signoffCount >= 2),
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
const handleSave = () => {
|
||||
updateDispatchMutation.mutate(
|
||||
{
|
||||
id: dispatchId,
|
||||
status: form.status,
|
||||
nteAmount: form.nteAmount ? parseFloat(form.nteAmount) : null,
|
||||
scheduledDate: form.scheduledDate || null,
|
||||
completedDate: form.completedDate || null,
|
||||
description: form.description,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setEditing(false);
|
||||
onUpdated();
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleComment = (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!commentText.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
addCommentMutation.mutate(
|
||||
{ text: commentText.trim(), sendEmail },
|
||||
{
|
||||
onSuccess: () => setCommentText(""),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleApproveUplift = (upliftId: string | number, requestedNTE: number) => {
|
||||
const note = window.prompt("Optional approval note:", "") || "";
|
||||
if (!window.confirm(`Approve uplift to $${requestedNTE.toFixed(2)}?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
approveUpliftMutation.mutate({ id: upliftId, note }, { onSuccess: onUpdated });
|
||||
};
|
||||
|
||||
const handleDenyUplift = (upliftId: string | number) => {
|
||||
const note = window.prompt("Denial reason (required):", "");
|
||||
if (!note?.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
denyUpliftMutation.mutate({ id: upliftId, note: note.trim() }, { onSuccess: onUpdated });
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Dialog open onClose={onClose} maxWidth="md" fullWidth>
|
||||
<DialogContent className="flex justify-center py-12">
|
||||
<CircularProgress />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
if (!dispatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isLocked = isDispatchLocked(dispatch);
|
||||
const waitColor = waitTimeColor(dispatch.dispatchedAt);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open onClose={onClose} maxWidth="md" fullWidth scroll="paper">
|
||||
<DialogTitle className="flex items-start justify-between gap-4 pr-2">
|
||||
<Box>
|
||||
<Typography variant="h6">
|
||||
{dispatch.dispatchNumber || dispatch.poNumber} · {dispatch.vendorName}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
PO: {dispatch.poNumber} ·{" "}
|
||||
{dispatch.workOrders.length > 0
|
||||
? `${dispatch.workOrders.length} WO(s): ${dispatch.workOrders.map((wo) => wo.woNumber).join(", ")}`
|
||||
: `WO: ${dispatch.woNumber}`}{" "}
|
||||
· {dispatch.vendorEmail}
|
||||
</Typography>
|
||||
</Box>
|
||||
<IconButton aria-label="Close" onClick={onClose} size="small">
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent className="flex flex-col gap-6">
|
||||
<Box className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{editing ? (
|
||||
<>
|
||||
<TextField
|
||||
label="Status"
|
||||
select
|
||||
size="small"
|
||||
value={form.status}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, status: event.target.value }))
|
||||
}
|
||||
>
|
||||
{DISPATCH_STATUS_OPTIONS.map((status) => (
|
||||
<MenuItem key={status} value={status}>
|
||||
{status}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
label="NTE Amount"
|
||||
type="number"
|
||||
size="small"
|
||||
value={form.nteAmount}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, nteAmount: event.target.value }))
|
||||
}
|
||||
inputProps={{ step: "0.01" }}
|
||||
/>
|
||||
<TextField
|
||||
label="Scheduled Date"
|
||||
type="date"
|
||||
size="small"
|
||||
value={form.scheduledDate}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, scheduledDate: event.target.value }))
|
||||
}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
/>
|
||||
<TextField
|
||||
label="Completed Date"
|
||||
type="date"
|
||||
size="small"
|
||||
value={form.completedDate}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, completedDate: event.target.value }))
|
||||
}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
/>
|
||||
<TextField
|
||||
label="Description"
|
||||
multiline
|
||||
rows={3}
|
||||
size="small"
|
||||
className="sm:col-span-2"
|
||||
value={form.description}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, description: event.target.value }))
|
||||
}
|
||||
/>
|
||||
<Stack direction="row" spacing={1} className="sm:col-span-2">
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleSave}
|
||||
disabled={updateDispatchMutation.isPending}
|
||||
>
|
||||
{updateDispatchMutation.isPending ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
<Button onClick={() => setEditing(false)}>Cancel</Button>
|
||||
</Stack>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DetailField label="Status" value={dispatch.status} />
|
||||
<DetailField
|
||||
label="NTE"
|
||||
value={dispatch.nteAmount != null ? `$${dispatch.nteAmount.toFixed(2)}` : "—"}
|
||||
/>
|
||||
<DetailField
|
||||
label="Scheduled"
|
||||
value={toDateInputValue(dispatch.scheduledDate) || "—"}
|
||||
/>
|
||||
<DetailField
|
||||
label="Completed"
|
||||
value={toDateInputValue(dispatch.completedDate) || "—"}
|
||||
/>
|
||||
<DetailField
|
||||
label="Dispatched"
|
||||
value={dispatch.dispatchedAt ? formatDateTime(dispatch.dispatchedAt) : "—"}
|
||||
/>
|
||||
<DetailField
|
||||
label="Accepted"
|
||||
value={
|
||||
dispatch.acknowledgedAt
|
||||
? `${formatDateTime(dispatch.acknowledgedAt)} (after ${timeBetween(dispatch.dispatchedAt, dispatch.acknowledgedAt)})`
|
||||
: dispatch.status === "Sent"
|
||||
? `Awaiting acceptance · ${timeSince(dispatch.dispatchedAt)}`
|
||||
: "—"
|
||||
}
|
||||
highlight={
|
||||
dispatch.status === "Sent" && !dispatch.acknowledgedAt ? waitColor : undefined
|
||||
}
|
||||
/>
|
||||
<DetailField label="Vendor Email" value={dispatch.vendorEmail || "—"} />
|
||||
{dispatch.description && (
|
||||
<DetailField
|
||||
label="Description"
|
||||
value={dispatch.description}
|
||||
className="sm:col-span-2"
|
||||
/>
|
||||
)}
|
||||
{!isLocked && (
|
||||
<Stack direction="row" spacing={1} className="sm:col-span-2">
|
||||
<Button
|
||||
color="error"
|
||||
variant="outlined"
|
||||
onClick={() => {
|
||||
if (!window.confirm("Are you sure you want to cancel this dispatch?")) {
|
||||
return;
|
||||
}
|
||||
updateDispatchMutation.mutate(
|
||||
{
|
||||
id: dispatchId,
|
||||
status: "Cancelled",
|
||||
nteAmount: dispatch.nteAmount,
|
||||
scheduledDate: toDateInputValue(dispatch.scheduledDate) || null,
|
||||
completedDate: toDateInputValue(dispatch.completedDate) || null,
|
||||
description: dispatch.description,
|
||||
},
|
||||
{ onSuccess: onUpdated },
|
||||
);
|
||||
}}
|
||||
>
|
||||
Cancel Dispatch
|
||||
</Button>
|
||||
<Button variant="contained" onClick={beginEditing}>
|
||||
Edit
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{(dispatch.checklistItems.length > 0 || !isLocked) && (
|
||||
<Box>
|
||||
<Typography variant="subtitle1" className="mb-2 font-semibold">
|
||||
Checklist ({dispatch.checklistItems.filter((item) => item.isCompleted).length}/
|
||||
{dispatch.checklistItems.length})
|
||||
</Typography>
|
||||
<Stack spacing={1}>
|
||||
{dispatch.checklistItems.map((item) => (
|
||||
<Stack key={String(item.id)} direction="row" spacing={1} alignItems="center">
|
||||
<Checkbox
|
||||
checked={item.isCompleted}
|
||||
disabled={isLocked || updateChecklistMutation.isPending}
|
||||
onChange={() =>
|
||||
updateChecklistMutation.mutate({
|
||||
itemId: item.id,
|
||||
isCompleted: !item.isCompleted,
|
||||
completedBy: userName,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Box className="flex-1">
|
||||
<Typography variant="body2">{item.itemText}</Typography>
|
||||
{item.isCompleted && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{item.completedBy}
|
||||
{item.completedAt ? ` · ${formatDateTime(item.completedAt)}` : ""}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
{!isLocked && (
|
||||
<TextField
|
||||
size="small"
|
||||
value={newChecklistItem}
|
||||
onChange={(event) => setNewChecklistItem(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && newChecklistItem.trim()) {
|
||||
event.preventDefault();
|
||||
addChecklistMutation.mutate(newChecklistItem.trim(), {
|
||||
onSuccess: () => setNewChecklistItem(""),
|
||||
});
|
||||
}
|
||||
}}
|
||||
placeholder="Add checklist item..."
|
||||
fullWidth
|
||||
className="mt-2"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle1" className="mb-2 font-semibold">
|
||||
Sign-offs
|
||||
</Typography>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||
{(["Customer", "Vendor"] as const).map((type) => {
|
||||
const signoff = dispatch.signoffs.find((item) => item.signoffType === type);
|
||||
return (
|
||||
<Box key={type} className="flex-1 rounded border border-border p-3">
|
||||
<Typography variant="subtitle2">{type}</Typography>
|
||||
{signoff ? (
|
||||
<Box className="mt-2">
|
||||
{signoff.signatureMethod === "drawn" && signoff.signature && (
|
||||
<img
|
||||
src={signoff.signature}
|
||||
alt="Signature"
|
||||
className="mb-2 max-h-20 max-w-full"
|
||||
/>
|
||||
)}
|
||||
{signoff.signatureMethod === "typed" && (
|
||||
<Typography variant="h6" className="italic">
|
||||
{signoff.signature}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{signoff.name}
|
||||
{signoff.signedAt ? ` · ${formatDateTime(signoff.signedAt)}` : ""}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
className="mt-2"
|
||||
onClick={() => setSigCaptureType(type)}
|
||||
disabled={isLocked}
|
||||
>
|
||||
{isLocked ? "Not signed" : "Collect Signature"}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
{dispatch.signoffs.length >= 2 && (
|
||||
<Chip label="All Signed ✓" color="success" size="small" className="mt-2" />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle1" className="mb-2 font-semibold">
|
||||
NTE Uplift Requests
|
||||
</Typography>
|
||||
{uplifts.length === 0 ? (
|
||||
<Typography color="text.secondary">No uplift requests.</Typography>
|
||||
) : (
|
||||
<Stack spacing={2}>
|
||||
{uplifts.map((uplift) => {
|
||||
const canDecide = uplift.canDecide && uplift.status === "Pending" && !isLocked;
|
||||
|
||||
return (
|
||||
<Box key={String(uplift.id)} className="rounded border border-border p-3">
|
||||
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap">
|
||||
<Chip label={uplift.status} size="small" />
|
||||
{uplift.requiredTier === 2 && (
|
||||
<Typography variant="caption">Tier 2 · Manager approval</Typography>
|
||||
)}
|
||||
<Typography variant="body2">
|
||||
${Number(uplift.currentNTE ?? 0).toFixed(2)} → $
|
||||
{Number(uplift.requestedNTE).toFixed(2)} (+$
|
||||
{Number(uplift.delta).toFixed(2)})
|
||||
</Typography>
|
||||
</Stack>
|
||||
{uplift.vendorReason && (
|
||||
<Typography variant="body2" className="mt-1">
|
||||
<strong>Reason:</strong> {uplift.vendorReason}
|
||||
</Typography>
|
||||
)}
|
||||
{(uplift.requestedByVendorName || uplift.requestedAt) && (
|
||||
<Typography variant="body2" color="text.secondary" className="mt-1">
|
||||
Requested by {uplift.requestedByVendorName || "vendor"}
|
||||
{uplift.requestedAt ? ` · ${formatDateTime(uplift.requestedAt)}` : ""}
|
||||
</Typography>
|
||||
)}
|
||||
{uplift.status !== "Pending" &&
|
||||
(uplift.decidedByName || uplift.decidedAt || uplift.decisionNote) && (
|
||||
<Box className="mt-1">
|
||||
{(uplift.decidedByName || uplift.decidedAt) && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{uplift.status} by {uplift.decidedByName || "—"}
|
||||
{uplift.decidedAt ? ` · ${formatDateTime(uplift.decidedAt)}` : ""}
|
||||
</Typography>
|
||||
)}
|
||||
{uplift.decisionNote && (
|
||||
<Typography variant="body2" className="mt-0.5">
|
||||
<strong>Note:</strong> {uplift.decisionNote}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
{uplift.status === "Pending" && (
|
||||
<Stack direction="row" spacing={1} className="mt-2">
|
||||
<Tooltip
|
||||
title={!canDecide ? `Requires Tier ${uplift.requiredTier} role` : ""}
|
||||
>
|
||||
<span>
|
||||
<Button
|
||||
size="small"
|
||||
color="success"
|
||||
variant="contained"
|
||||
disabled={!canDecide}
|
||||
onClick={() => handleApproveUplift(uplift.id, uplift.requestedNTE)}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
title={!canDecide ? `Requires Tier ${uplift.requiredTier} role` : ""}
|
||||
>
|
||||
<span>
|
||||
<Button
|
||||
size="small"
|
||||
color="error"
|
||||
variant="contained"
|
||||
disabled={!canDecide}
|
||||
onClick={() => handleDenyUplift(uplift.id)}
|
||||
>
|
||||
Deny
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle1" className="mb-2 font-semibold">
|
||||
Vendor Communication
|
||||
</Typography>
|
||||
<Box className="mb-3 max-h-48 overflow-y-auto rounded border border-border p-2">
|
||||
{dispatch.comments.length === 0 ? (
|
||||
<Typography color="text.secondary">No messages yet.</Typography>
|
||||
) : (
|
||||
<Stack spacing={2}>
|
||||
{dispatch.comments.map((comment) => (
|
||||
<Box key={String(comment.id)}>
|
||||
<Stack direction="row" justifyContent="space-between">
|
||||
<Typography variant="caption" className="font-semibold">
|
||||
{comment.userName || comment.commenter || "System"}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{comment.createdDate ? formatDateTime(comment.createdDate) : ""}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography variant="body2">{comment.commenttext}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
<Box component="form" onSubmit={handleComment}>
|
||||
<TextField
|
||||
multiline
|
||||
rows={2}
|
||||
value={commentText}
|
||||
onChange={(event) => setCommentText(event.target.value)}
|
||||
placeholder="Message vendor..."
|
||||
fullWidth
|
||||
disabled={isLocked || addCommentMutation.isPending}
|
||||
/>
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
className="mt-2"
|
||||
>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={sendEmail}
|
||||
onChange={(event) => setSendEmail(event.target.checked)}
|
||||
disabled={isLocked}
|
||||
/>
|
||||
}
|
||||
label="Send email"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
disabled={isLocked || addCommentMutation.isPending || !commentText.trim()}
|
||||
>
|
||||
{addCommentMutation.isPending ? "Sending..." : "Send"}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{dispatch.verifiedAt ? (
|
||||
<Alert severity="success">
|
||||
Verified by {dispatch.verifiedBy} on {formatDateTime(dispatch.verifiedAt)}
|
||||
</Alert>
|
||||
) : (
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
{verifyState.missing.length > 0 && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Missing: {verifyState.missing.join(", ")}
|
||||
</Typography>
|
||||
)}
|
||||
<Button
|
||||
variant="contained"
|
||||
color="success"
|
||||
disabled={!verifyState.canVerify || verifyMutation.isPending}
|
||||
onClick={() => verifyMutation.mutate(undefined, { onSuccess: onUpdated })}
|
||||
>
|
||||
Verify & Close
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{sigCaptureType && (
|
||||
<SignatureCapture
|
||||
title={`${sigCaptureType} Sign-off`}
|
||||
onClose={() => setSigCaptureType(null)}
|
||||
onSave={(signature, method, name) => {
|
||||
addSignoffMutation.mutate(
|
||||
{
|
||||
signoffType: sigCaptureType,
|
||||
name,
|
||||
signature,
|
||||
signatureMethod: method,
|
||||
},
|
||||
{ onSuccess: () => setSigCaptureType(null) },
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailField({
|
||||
label,
|
||||
value,
|
||||
className,
|
||||
highlight,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
className?: string;
|
||||
highlight?: "green" | "yellow" | "red" | "";
|
||||
}) {
|
||||
const colorClass =
|
||||
highlight === "green"
|
||||
? "text-green-600"
|
||||
: highlight === "yellow"
|
||||
? "text-yellow-600"
|
||||
: highlight === "red"
|
||||
? "text-red-600"
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Box className={className}>
|
||||
<Typography variant="caption" color="text.secondary" display="block">
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="body2" className={colorClass}>
|
||||
{value}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
355
src/app/(protected)/workorders/_components/work-order-form.tsx
Normal file
355
src/app/(protected)/workorders/_components/work-order-form.tsx
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
import { useEffect, useMemo } from "react";
|
||||
import { Controller, useForm, useWatch } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Link as RouterLink, useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
Alert,
|
||||
Autocomplete,
|
||||
Box,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Link,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Stack,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
|
||||
import { mapWorkOrderDetailToFormValues } from "@/domain/work-orders/mappers/work-order-mapper";
|
||||
import {
|
||||
workOrderFormSchema,
|
||||
type WorkOrderFormSchemaValues,
|
||||
} from "@/domain/work-orders/schemas/work-order-schema";
|
||||
import {
|
||||
createDefaultWorkOrderFormValues,
|
||||
WORK_ORDER_PRIORITY_OPTIONS,
|
||||
WORK_ORDER_STATUS_OPTIONS,
|
||||
type LocationOption,
|
||||
} from "@/domain/work-orders/types/work-order";
|
||||
import {
|
||||
useWorkOrderDetail,
|
||||
useWorkOrderEmployeesDropdown,
|
||||
useWorkOrderLocations,
|
||||
} from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||
import {
|
||||
useCreateWorkOrder,
|
||||
useUpdateWorkOrder,
|
||||
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
||||
|
||||
function SectionTitle({ children }: { children: string }) {
|
||||
return (
|
||||
<Typography variant="subtitle2" className="mb-3 font-semibold uppercase tracking-wide">
|
||||
{children}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WorkOrderFormPage() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const isEdit = Boolean(id);
|
||||
|
||||
const { data: detail, isLoading: isLoadingDetail, error: detailError } = useWorkOrderDetail(id);
|
||||
const { data: locations = [], isLoading: isLoadingLocations } = useWorkOrderLocations();
|
||||
const { data: employees = [] } = useWorkOrderEmployeesDropdown();
|
||||
const { data: problems = [] } = useDropdownOptionsByCategory("Problem");
|
||||
const createMutation = useCreateWorkOrder();
|
||||
const updateMutation = useUpdateWorkOrder(id ?? "");
|
||||
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = useForm<WorkOrderFormSchemaValues>({
|
||||
resolver: zodResolver(workOrderFormSchema),
|
||||
defaultValues: createDefaultWorkOrderFormValues() as WorkOrderFormSchemaValues,
|
||||
});
|
||||
|
||||
const tradeValue = useWatch({ control, name: "trade" });
|
||||
|
||||
const { data: subTrades = [] } = useDropdownOptionsByCategory("SubTrade", {
|
||||
parent: tradeValue || undefined,
|
||||
enabled: Boolean(tradeValue),
|
||||
});
|
||||
const { data: trades = [] } = useDropdownOptionsByCategory("Trade");
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit && detail) {
|
||||
reset(mapWorkOrderDetailToFormValues(detail) as WorkOrderFormSchemaValues);
|
||||
}
|
||||
}, [detail, isEdit, reset]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!tradeValue) {
|
||||
setValue("subTrade", "");
|
||||
}
|
||||
}, [tradeValue, setValue]);
|
||||
|
||||
const locationOptions = useMemo(() => locations, [locations]);
|
||||
|
||||
const isSaving = createMutation.isPending || updateMutation.isPending;
|
||||
const isLoading = isEdit && isLoadingDetail;
|
||||
|
||||
const onSubmit = (values: WorkOrderFormSchemaValues) => {
|
||||
if (isEdit && id) {
|
||||
updateMutation.mutate(values);
|
||||
return;
|
||||
}
|
||||
createMutation.mutate(values);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
navigate(isEdit && id ? `/workorders/${id}` : "/workorders");
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box className="flex justify-center p-12">
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (detailError) {
|
||||
return (
|
||||
<Box className="p-4">
|
||||
<Alert severity="error">
|
||||
{detailError instanceof Error ? detailError.message : "Failed to load work order"}
|
||||
</Alert>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className="flex flex-col gap-4 p-4">
|
||||
<Breadcrumbs>
|
||||
<Link component={RouterLink} to="/workorders" underline="hover">
|
||||
Work Orders
|
||||
</Link>
|
||||
<Typography color="text.primary">{isEdit ? "Edit" : "Create"}</Typography>
|
||||
</Breadcrumbs>
|
||||
|
||||
<Typography variant="h5" component="h1">
|
||||
{isEdit ? `Edit Work Order ${detail?.internalWONumber ?? id}` : "Create a Work Order"}
|
||||
</Typography>
|
||||
|
||||
<Paper variant="outlined" className="p-6">
|
||||
<Box component="form" onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-8">
|
||||
<Box>
|
||||
<SectionTitle>Work Order Details</SectionTitle>
|
||||
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||
<TextField
|
||||
{...register("workOrderNumber")}
|
||||
label="Customer WO"
|
||||
placeholder="Customer work order number"
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
{...register("title")}
|
||||
label="Title"
|
||||
required
|
||||
error={Boolean(errors.title)}
|
||||
helperText={errors.title?.message}
|
||||
fullWidth
|
||||
/>
|
||||
</Stack>
|
||||
<Stack direction={{ xs: "column", md: "row" }} spacing={2} className="mt-4">
|
||||
<Controller
|
||||
name="locationId"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Autocomplete
|
||||
options={locationOptions}
|
||||
loading={isLoadingLocations}
|
||||
getOptionLabel={(option: LocationOption) => option.name}
|
||||
value={locationOptions.find((loc) => String(loc.id) === field.value) ?? null}
|
||||
onChange={(_event, option) => field.onChange(option ? String(option.id) : "")}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="Location"
|
||||
required
|
||||
error={Boolean(errors.locationId)}
|
||||
helperText={errors.locationId?.message}
|
||||
/>
|
||||
)}
|
||||
className="flex-1"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<TextField
|
||||
{...register("source")}
|
||||
label="Source"
|
||||
placeholder="e.g., Amazon APM, Manual"
|
||||
fullWidth
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<SectionTitle>Classification</SectionTitle>
|
||||
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||
<TextField {...register("problem")} label="Problem" select fullWidth>
|
||||
<MenuItem value="">Select Problem</MenuItem>
|
||||
{problems.map((option) => (
|
||||
<MenuItem key={String(option.id)} value={option.value}>
|
||||
{option.value}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField {...register("trade")} label="Trade" select fullWidth>
|
||||
<MenuItem value="">Select Trade</MenuItem>
|
||||
{trades.map((option) => (
|
||||
<MenuItem key={String(option.id)} value={option.value}>
|
||||
{option.value}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
{...register("subTrade")}
|
||||
label="Sub-Trade"
|
||||
select
|
||||
disabled={!tradeValue}
|
||||
fullWidth
|
||||
>
|
||||
<MenuItem value="">
|
||||
{tradeValue ? "Select Sub-Trade" : "Select a Trade first"}
|
||||
</MenuItem>
|
||||
{subTrades.map((option) => (
|
||||
<MenuItem key={String(option.id)} value={option.value}>
|
||||
{option.value}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<SectionTitle>Schedule & Priority</SectionTitle>
|
||||
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||
<TextField
|
||||
{...register("priority")}
|
||||
label="Priority"
|
||||
select
|
||||
required
|
||||
error={Boolean(errors.priority)}
|
||||
helperText={errors.priority?.message}
|
||||
fullWidth
|
||||
>
|
||||
<MenuItem value="">Select Priority</MenuItem>
|
||||
{WORK_ORDER_PRIORITY_OPTIONS.map((priority) => (
|
||||
<MenuItem key={priority} value={priority}>
|
||||
{priority}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField {...register("status")} label="Status" select required fullWidth>
|
||||
{WORK_ORDER_STATUS_OPTIONS.map((status) => (
|
||||
<MenuItem key={status} value={status}>
|
||||
{status}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<Controller
|
||||
name="assignedTo"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Autocomplete
|
||||
freeSolo
|
||||
options={employees.map((employee) => employee.name)}
|
||||
value={field.value}
|
||||
onChange={(_event, value) => field.onChange(value ?? "")}
|
||||
onInputChange={(_event, value) => field.onChange(value)}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} label="Assigned To" placeholder="Assigned to" />
|
||||
)}
|
||||
className="flex-1"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} className="mt-4">
|
||||
<TextField
|
||||
{...register("createdDate")}
|
||||
label="Created"
|
||||
type="date"
|
||||
required
|
||||
error={Boolean(errors.createdDate)}
|
||||
helperText={errors.createdDate?.message}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
{...register("dueDate")}
|
||||
label="Due Date"
|
||||
type="date"
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
{...register("scheduledDate")}
|
||||
label="Scheduled"
|
||||
type="date"
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
{...register("completedDate")}
|
||||
label="Completed"
|
||||
type="date"
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
fullWidth
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<SectionTitle>Financial</SectionTitle>
|
||||
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||
<TextField
|
||||
{...register("vendorNTE")}
|
||||
label="Vendor NTE"
|
||||
type="number"
|
||||
inputProps={{ step: "0.01", min: 0 }}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField {...register("po")} label="Customer PO" fullWidth />
|
||||
<TextField
|
||||
{...register("tt")}
|
||||
label="TT"
|
||||
placeholder="Trouble ticket number"
|
||||
fullWidth
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<SectionTitle>Description</SectionTitle>
|
||||
<TextField
|
||||
{...register("description")}
|
||||
label="Description"
|
||||
multiline
|
||||
rows={8}
|
||||
placeholder="Detailed description of the work to be performed"
|
||||
fullWidth
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Stack direction="row" spacing={2} justifyContent="flex-end">
|
||||
<Button variant="outlined" onClick={handleCancel} disabled={isSaving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="contained" disabled={isSaving}>
|
||||
{isSaving ? "Saving..." : isEdit ? "Update" : "Create"}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
1
src/app/(protected)/workorders/edit/[id].tsx
Normal file
1
src/app/(protected)/workorders/edit/[id].tsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from "../_components/work-order-form";
|
||||
310
src/app/(protected)/workorders/index.tsx
Normal file
310
src/app/(protected)/workorders/index.tsx
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
||||
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
|
||||
import { WorkOrderStatusBadge } from "@/components/domain/status-badge";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
FormControl,
|
||||
IconButton,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TablePagination,
|
||||
TableRow,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { useDebounce } from "@/hooks/use-debounce";
|
||||
import type { WorkOrderListItem } from "@/domain/work-orders/types/work-order";
|
||||
import {
|
||||
useWorkOrderUsers,
|
||||
useWorkOrdersList,
|
||||
} from "@/domain/work-orders/use-cases/use-work-orders-list";
|
||||
|
||||
const STATUS_OPTIONS = ["Open", "In Progress", "On Hold", "Done", "Cancelled"] as const;
|
||||
|
||||
function formatDateTime(value: string): string {
|
||||
if (!value) {
|
||||
return "—";
|
||||
}
|
||||
|
||||
const normalized = value.endsWith("Z") ? value : `${value}Z`;
|
||||
const date = new Date(normalized);
|
||||
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const datePart = date.toLocaleDateString("en-CA");
|
||||
const timePart = date.toLocaleTimeString(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
const tz = date.toLocaleTimeString(undefined, { timeZoneName: "short" }).split(" ").pop();
|
||||
|
||||
return `${datePart} ${timePart} ${tz ?? ""}`.trim();
|
||||
}
|
||||
|
||||
export default function WorkOrdersListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [search, setSearch] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const [assigneeFilter, setAssigneeFilter] = useState("");
|
||||
const [page, setPage] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(12);
|
||||
const [sortBy, setSortBy] = useState("");
|
||||
const [sortDir, setSortDir] = useState<"asc" | "desc" | "">("");
|
||||
|
||||
const debouncedSearch = useDebounce(search, 300);
|
||||
|
||||
const listParams = useMemo(
|
||||
() => ({
|
||||
search: debouncedSearch,
|
||||
page: page + 1,
|
||||
pageSize,
|
||||
sortBy,
|
||||
sortDir,
|
||||
status: statusFilter,
|
||||
assignee: assigneeFilter,
|
||||
}),
|
||||
[debouncedSearch, page, pageSize, sortBy, sortDir, statusFilter, assigneeFilter],
|
||||
);
|
||||
|
||||
const { data, isLoading, isFetching, error } = useWorkOrdersList(listParams);
|
||||
const { data: users = [] } = useWorkOrderUsers();
|
||||
|
||||
const rows = data?.items ?? [];
|
||||
const totalCount = data?.totalCount ?? 0;
|
||||
|
||||
const handleSort = (column: string) => {
|
||||
if (sortBy === column) {
|
||||
setSortDir((current) => (current === "asc" ? "desc" : current === "desc" ? "" : "asc"));
|
||||
if (sortDir === "desc") {
|
||||
setSortBy("");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setSortBy(column);
|
||||
setSortDir("asc");
|
||||
};
|
||||
|
||||
const sortIndicator = (column: string) => {
|
||||
if (sortBy !== column) {
|
||||
return "";
|
||||
}
|
||||
return sortDir === "desc" ? " ↓" : " ↑";
|
||||
};
|
||||
|
||||
const handleStatusFilter = (status: string) => {
|
||||
setStatusFilter((current) => (current === status ? "" : status));
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="flex flex-col gap-4 p-4">
|
||||
<Stack
|
||||
direction={{ xs: "column", md: "row" }}
|
||||
spacing={2}
|
||||
alignItems={{ xs: "stretch", md: "center" }}
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<Stack direction="row" spacing={2} alignItems="baseline">
|
||||
<Typography variant="h5" component="h1">
|
||||
Work Orders
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{totalCount} total
|
||||
</Typography>
|
||||
{isFetching && !isLoading && <CircularProgress size={16} />}
|
||||
</Stack>
|
||||
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems="center">
|
||||
<TextField
|
||||
size="small"
|
||||
value={search}
|
||||
onChange={(event) => {
|
||||
setSearch(event.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
placeholder="Search..."
|
||||
className="min-w-[220px]"
|
||||
/>
|
||||
<Button variant="contained" onClick={() => navigate("/workorders/new")}>
|
||||
+ New Work Order
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap alignItems="center">
|
||||
<Chip
|
||||
label="All"
|
||||
clickable
|
||||
color={statusFilter === "" ? "primary" : "default"}
|
||||
variant={statusFilter === "" ? "filled" : "outlined"}
|
||||
onClick={() => {
|
||||
setStatusFilter("");
|
||||
setPage(0);
|
||||
}}
|
||||
/>
|
||||
{STATUS_OPTIONS.map((status) => (
|
||||
<Chip
|
||||
key={status}
|
||||
label={status}
|
||||
clickable
|
||||
color={statusFilter === status ? "primary" : "default"}
|
||||
variant={statusFilter === status ? "filled" : "outlined"}
|
||||
onClick={() => handleStatusFilter(status)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<FormControl size="small" className="min-w-[180px]">
|
||||
<InputLabel id="assignee-filter-label">Assignee</InputLabel>
|
||||
<Select
|
||||
labelId="assignee-filter-label"
|
||||
label="Assignee"
|
||||
value={assigneeFilter}
|
||||
onChange={(event) => {
|
||||
setAssigneeFilter(event.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
>
|
||||
<MenuItem value="">All Assignees</MenuItem>
|
||||
<MenuItem value="__unassigned">Unassigned</MenuItem>
|
||||
{users.map((user) => (
|
||||
<MenuItem key={String(user.id)} value={user.name}>
|
||||
{user.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error">
|
||||
{error instanceof Error ? error.message : "Failed to load work orders"}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell
|
||||
className="cursor-pointer whitespace-nowrap font-semibold"
|
||||
onClick={() => handleSort("number")}
|
||||
>
|
||||
WO #{sortIndicator("number")}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="cursor-pointer font-semibold"
|
||||
onClick={() => handleSort("title")}
|
||||
>
|
||||
Title{sortIndicator("title")}
|
||||
</TableCell>
|
||||
<TableCell className="font-semibold">Location</TableCell>
|
||||
<TableCell className="font-semibold">Priority</TableCell>
|
||||
<TableCell className="font-semibold">Status</TableCell>
|
||||
<TableCell className="font-semibold">Created</TableCell>
|
||||
<TableCell className="font-semibold">Assigned To</TableCell>
|
||||
<TableCell className="font-semibold">Last Updated</TableCell>
|
||||
<TableCell align="right" className="font-semibold">
|
||||
Actions
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} align="center" className="py-10">
|
||||
<CircularProgress size={28} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} align="center" className="py-10">
|
||||
<Typography color="text.secondary">No work orders found</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
rows.map((row: WorkOrderListItem) => (
|
||||
<TableRow key={String(row.id)} hover>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="text"
|
||||
size="small"
|
||||
className="font-semibold"
|
||||
onClick={() => navigate(`/workorders/${row.id}`)}
|
||||
>
|
||||
{row.number || row.id}
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell>{row.title}</TableCell>
|
||||
<TableCell>{row.location}</TableCell>
|
||||
<TableCell>{row.priority}</TableCell>
|
||||
<TableCell>
|
||||
<WorkOrderStatusBadge status={row.status || "Open"} />
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap">
|
||||
{formatDateTime(row.createdDate)}
|
||||
</TableCell>
|
||||
<TableCell>{row.assignedTo || "Unassigned"}</TableCell>
|
||||
<TableCell className="whitespace-nowrap">
|
||||
{formatDateTime(row.lastUpdated)}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Tooltip title="View">
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="View work order"
|
||||
onClick={() => navigate(`/workorders/${row.id}`)}
|
||||
>
|
||||
<VisibilityOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Edit">
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="Edit work order"
|
||||
onClick={() => navigate(`/workorders/edit/${row.id}`)}
|
||||
>
|
||||
<EditOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={totalCount}
|
||||
page={page}
|
||||
onPageChange={(_event, nextPage) => setPage(nextPage)}
|
||||
rowsPerPage={pageSize}
|
||||
onRowsPerPageChange={(event) => {
|
||||
setPageSize(Number(event.target.value));
|
||||
setPage(0);
|
||||
}}
|
||||
rowsPerPageOptions={[12, 24, 48]}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
1
src/app/(protected)/workorders/new.tsx
Normal file
1
src/app/(protected)/workorders/new.tsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./_components/work-order-form";
|
||||
256
src/domain/work-orders/api/work-orders-api.ts
Normal file
256
src/domain/work-orders/api/work-orders-api.ts
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
import { API_PATHS } from "@/api/api-paths";
|
||||
import { apiDelete, apiGet, apiPost, apiPut } from "@/api/api";
|
||||
import { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response";
|
||||
import {
|
||||
mapDispatchComment,
|
||||
mapDispatchDetail,
|
||||
mapDispatchSignoff,
|
||||
mapDispatchToVendorPayload,
|
||||
mapEmployeeOptions,
|
||||
mapLocationOptions,
|
||||
mapTaskListTemplates,
|
||||
mapUpdateDispatchPayload,
|
||||
mapVendorDropdownItems,
|
||||
mapWorkOrderComment,
|
||||
mapWorkOrderDetail,
|
||||
mapWorkOrderFormToCreatePayload,
|
||||
mapWorkOrderFormToUpdatePayload,
|
||||
mapWorkOrderListItems,
|
||||
} from "@/domain/work-orders/mappers/work-order-mapper";
|
||||
import type { WorkOrderFormSchemaValues } from "@/domain/work-orders/schemas/work-order-schema";
|
||||
import type {
|
||||
DispatchDetail,
|
||||
DispatchToVendorPayload,
|
||||
EmployeeOption,
|
||||
LocationOption,
|
||||
TaskListTemplate,
|
||||
UpdateDispatchPayload,
|
||||
VendorDropdownItem,
|
||||
WorkOrderComment,
|
||||
WorkOrderDetail,
|
||||
WorkOrderFormValues,
|
||||
WorkOrderUser,
|
||||
WorkOrdersListParams,
|
||||
WorkOrdersListResult,
|
||||
} from "@/domain/work-orders/types/work-order";
|
||||
|
||||
function buildSearchParams(params: WorkOrdersListParams): Record<string, string | number> {
|
||||
const searchParams: Record<string, string | number> = {
|
||||
search: params.search ?? "",
|
||||
page: params.page ?? 1,
|
||||
pageSize: params.pageSize ?? 12,
|
||||
};
|
||||
|
||||
if (params.sortBy) {
|
||||
searchParams.sortBy = params.sortBy;
|
||||
}
|
||||
if (params.sortDir) {
|
||||
searchParams.sortDir = params.sortDir;
|
||||
}
|
||||
if (params.status) {
|
||||
searchParams.status = params.status;
|
||||
}
|
||||
if (params.assignee) {
|
||||
searchParams.assignee = params.assignee;
|
||||
}
|
||||
if (params.locationId) {
|
||||
searchParams.locationId = params.locationId;
|
||||
}
|
||||
|
||||
return searchParams;
|
||||
}
|
||||
|
||||
function mapUser(raw: unknown): WorkOrderUser {
|
||||
const item = (typeof raw === "object" && raw !== null ? raw : {}) as Record<string, unknown>;
|
||||
const id = item.id ?? item.Id;
|
||||
const name =
|
||||
(typeof item.name === "string" ? item.name : undefined) ??
|
||||
(typeof item.Name === "string" ? item.Name : undefined) ??
|
||||
(typeof item.userName === "string" ? item.userName : "") ??
|
||||
"";
|
||||
|
||||
return {
|
||||
id: typeof id === "string" || typeof id === "number" ? id : "",
|
||||
name,
|
||||
};
|
||||
}
|
||||
|
||||
export const workOrdersApi = {
|
||||
getList: async (params: WorkOrdersListParams = {}): Promise<WorkOrdersListResult> => {
|
||||
const searchParams = buildSearchParams(params);
|
||||
const data = await apiGet<unknown>(API_PATHS.workOrder.getList, { searchParams });
|
||||
const paginated = handlePaginatedResponse<unknown>(data, {
|
||||
page: params.page,
|
||||
pageSize: params.pageSize,
|
||||
});
|
||||
|
||||
return {
|
||||
items: mapWorkOrderListItems(paginated.items),
|
||||
totalCount: paginated.totalCount,
|
||||
page: paginated.page,
|
||||
pageSize: paginated.pageSize,
|
||||
};
|
||||
},
|
||||
|
||||
fetchUsers: async (): Promise<WorkOrderUser[]> => {
|
||||
const data = await apiGet<unknown>(API_PATHS.user.list);
|
||||
const users = handleApiResponse<unknown[]>(data);
|
||||
return Array.isArray(users) ? users.map(mapUser) : [];
|
||||
},
|
||||
|
||||
getById: async (id: string | number): Promise<WorkOrderDetail> => {
|
||||
const data = await apiGet<unknown>(`${API_PATHS.rest.workOrders}/${id}`);
|
||||
return mapWorkOrderDetail(handleApiResponse(data));
|
||||
},
|
||||
|
||||
create: async (
|
||||
values: WorkOrderFormSchemaValues | WorkOrderFormValues,
|
||||
): Promise<WorkOrderDetail> => {
|
||||
const payload = mapWorkOrderFormToCreatePayload(values);
|
||||
const data = await apiPost<unknown>(API_PATHS.rest.workOrders, payload);
|
||||
return mapWorkOrderDetail(handleApiResponse(data));
|
||||
},
|
||||
|
||||
update: async (
|
||||
id: string | number,
|
||||
values: WorkOrderFormSchemaValues | WorkOrderFormValues,
|
||||
): Promise<WorkOrderDetail> => {
|
||||
const payload = mapWorkOrderFormToUpdatePayload(id, values);
|
||||
const data = await apiPut<unknown>(`${API_PATHS.rest.workOrders}/${id}`, payload);
|
||||
return mapWorkOrderDetail(handleApiResponse(data));
|
||||
},
|
||||
|
||||
delete: async (id: string | number): Promise<void> => {
|
||||
await apiDelete<unknown>(`${API_PATHS.rest.workOrders}/${id}`);
|
||||
},
|
||||
|
||||
addComment: async (
|
||||
workOrderId: string | number,
|
||||
text: string,
|
||||
commentType: string,
|
||||
): Promise<WorkOrderComment> => {
|
||||
const data = await apiPost<unknown>(API_PATHS.workOrder.addCommentJson, {
|
||||
WorkorderId: workOrderId,
|
||||
Text: text,
|
||||
CommentType: commentType,
|
||||
});
|
||||
return mapWorkOrderComment(handleApiResponse(data));
|
||||
},
|
||||
|
||||
changeStatus: async (id: string | number, status: string): Promise<void> => {
|
||||
await apiPost<unknown>(API_PATHS.workOrder.changeStatus, undefined, {
|
||||
searchParams: { id, status },
|
||||
});
|
||||
},
|
||||
|
||||
changeAssignment: async (id: string | number, assignTo: string | number): Promise<void> => {
|
||||
await apiPost<unknown>(API_PATHS.workOrder.changeAssignment, undefined, {
|
||||
searchParams: { id, assignTo: assignTo || "" },
|
||||
});
|
||||
},
|
||||
|
||||
fetchLocations: async (): Promise<LocationOption[]> => {
|
||||
const data = await apiGet<unknown>(API_PATHS.contact.getLocations);
|
||||
const items = handleApiResponse<unknown[]>(data);
|
||||
return Array.isArray(items) ? mapLocationOptions(items) : [];
|
||||
},
|
||||
|
||||
fetchEmployeesDropdown: async (): Promise<EmployeeOption[]> => {
|
||||
const data = await apiGet<unknown>(API_PATHS.employee.dropdown);
|
||||
const items = handleApiResponse<unknown[]>(data);
|
||||
return Array.isArray(items) ? mapEmployeeOptions(items) : [];
|
||||
},
|
||||
|
||||
fetchVendorsDropdown: async (trade?: string, siteZip?: string): Promise<VendorDropdownItem[]> => {
|
||||
const searchParams: Record<string, string> = {};
|
||||
if (trade) {
|
||||
searchParams.trade = trade;
|
||||
}
|
||||
if (siteZip) {
|
||||
searchParams.siteZip = siteZip;
|
||||
}
|
||||
|
||||
const data = await apiGet<unknown>(API_PATHS.vendor.dropdown, {
|
||||
searchParams: Object.keys(searchParams).length > 0 ? searchParams : undefined,
|
||||
});
|
||||
const items = handleApiResponse<unknown[]>(data);
|
||||
return Array.isArray(items) ? mapVendorDropdownItems(items) : [];
|
||||
},
|
||||
|
||||
fetchTaskTemplates: async (): Promise<TaskListTemplate[]> => {
|
||||
const data = await apiGet<unknown>(API_PATHS.taskListTemplate.list);
|
||||
const items = handleApiResponse<unknown[]>(data);
|
||||
return Array.isArray(items) ? mapTaskListTemplates(items) : [];
|
||||
},
|
||||
|
||||
dispatchToVendor: async (payload: DispatchToVendorPayload): Promise<void> => {
|
||||
await apiPost<unknown>(
|
||||
API_PATHS.workOrder.dispatchToVendor,
|
||||
mapDispatchToVendorPayload(payload),
|
||||
);
|
||||
},
|
||||
|
||||
getDispatchById: async (id: string | number): Promise<DispatchDetail> => {
|
||||
const data = await apiGet<unknown>(`${API_PATHS.workOrder.getDispatch}/${id}`);
|
||||
return mapDispatchDetail(handleApiResponse(data));
|
||||
},
|
||||
|
||||
updateDispatch: async (payload: UpdateDispatchPayload): Promise<void> => {
|
||||
await apiPost<unknown>(API_PATHS.workOrder.updateDispatch, mapUpdateDispatchPayload(payload));
|
||||
},
|
||||
|
||||
addDispatchComment: async (
|
||||
dispatchId: string | number,
|
||||
text: string,
|
||||
sendEmail = true,
|
||||
): Promise<DispatchDetail["comments"][number]> => {
|
||||
const data = await apiPost<unknown>(API_PATHS.workOrder.addDispatchComment, {
|
||||
DispatchId: dispatchId,
|
||||
Text: text,
|
||||
SendEmail: sendEmail,
|
||||
});
|
||||
return mapDispatchComment(handleApiResponse(data));
|
||||
},
|
||||
|
||||
updateChecklistItem: async (
|
||||
id: string | number,
|
||||
isCompleted: boolean,
|
||||
completedBy: string,
|
||||
): Promise<void> => {
|
||||
await apiPost<unknown>(API_PATHS.workOrder.updateChecklistItem, {
|
||||
Id: id,
|
||||
IsCompleted: isCompleted,
|
||||
CompletedBy: completedBy,
|
||||
});
|
||||
},
|
||||
|
||||
addChecklistItem: async (dispatchId: string | number, itemText: string): Promise<void> => {
|
||||
await apiPost<unknown>(API_PATHS.workOrder.addChecklistItem, {
|
||||
DispatchId: dispatchId,
|
||||
ItemText: itemText,
|
||||
});
|
||||
},
|
||||
|
||||
addDispatchSignoff: async (
|
||||
dispatchId: string | number,
|
||||
signoffType: string,
|
||||
name: string,
|
||||
signature: string,
|
||||
signatureMethod: string,
|
||||
): Promise<DispatchDetail["signoffs"][number]> => {
|
||||
const data = await apiPost<unknown>(API_PATHS.workOrder.addDispatchSignoff, {
|
||||
DispatchId: dispatchId,
|
||||
SignoffType: signoffType,
|
||||
Name: name,
|
||||
Signature: signature,
|
||||
SignatureMethod: signatureMethod,
|
||||
});
|
||||
return mapDispatchSignoff(handleApiResponse(data));
|
||||
},
|
||||
|
||||
verifyDispatch: async (dispatchId: string | number): Promise<void> => {
|
||||
await apiPost<unknown>(API_PATHS.workOrder.verifyDispatch, undefined, {
|
||||
searchParams: { dispatchId },
|
||||
});
|
||||
},
|
||||
};
|
||||
443
src/domain/work-orders/mappers/work-order-mapper.ts
Normal file
443
src/domain/work-orders/mappers/work-order-mapper.ts
Normal file
|
|
@ -0,0 +1,443 @@
|
|||
import type {
|
||||
DispatchChecklistItem,
|
||||
DispatchComment,
|
||||
DispatchDetail,
|
||||
DispatchSignoff,
|
||||
DispatchToVendorPayload,
|
||||
DispatchWorkOrderRef,
|
||||
EmployeeOption,
|
||||
LocationOption,
|
||||
TaskListTemplate,
|
||||
UpdateDispatchPayload,
|
||||
UpliftRequest,
|
||||
VendorDropdownItem,
|
||||
WorkOrderAuditEntry,
|
||||
WorkOrderComment,
|
||||
WorkOrderDetail,
|
||||
WorkOrderDispatchSummary,
|
||||
WorkOrderFormValues,
|
||||
WorkOrderListItem,
|
||||
} from "@/domain/work-orders/types/work-order";
|
||||
import { toDateInputValue } from "@/lib/time-utils";
|
||||
|
||||
function asRecord(raw: unknown): Record<string, unknown> {
|
||||
return typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
function readString(record: Record<string, unknown>, ...keys: string[]): string {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function readId(record: Record<string, unknown>): string | number {
|
||||
const value = record.id ?? record.Id;
|
||||
if (typeof value === "string" || typeof value === "number") {
|
||||
return value;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function readNumber(record: Record<string, unknown>, ...keys: string[]): number | null {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (typeof value === "number") {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string" && value !== "") {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readBool(record: Record<string, unknown>, ...keys: string[]): boolean {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function mapWorkOrderListItem(raw: unknown): WorkOrderListItem {
|
||||
const item = asRecord(raw);
|
||||
|
||||
return {
|
||||
id: readId(item),
|
||||
number: readString(item, "internalWONumber", "number", "workOrderNumber"),
|
||||
title: readString(item, "title", "Title"),
|
||||
location: readString(item, "location", "Location", "locationName", "LocationName"),
|
||||
priority: readString(item, "priority", "Priority"),
|
||||
status: readString(item, "status", "Status"),
|
||||
createdDate: readString(item, "createdDate", "CreatedDate"),
|
||||
dueDate: readString(item, "dueDate", "DueDate"),
|
||||
assignedTo: readString(item, "assignedTo", "AssignedTo", "employeeName", "EmployeeName"),
|
||||
lastUpdated: readString(item, "lastUpdated", "LastUpdated"),
|
||||
};
|
||||
}
|
||||
|
||||
export function mapWorkOrderListItems(rawItems: unknown[]): WorkOrderListItem[] {
|
||||
return rawItems.map(mapWorkOrderListItem);
|
||||
}
|
||||
|
||||
export function mapWorkOrderComment(raw: unknown): WorkOrderComment {
|
||||
const item = asRecord(raw);
|
||||
|
||||
return {
|
||||
id: readId(item),
|
||||
createdDate: readString(item, "createdDate", "CreatedDate"),
|
||||
commenttext: readString(item, "commenttext", "Commenttext", "text", "Text"),
|
||||
commentType: readString(item, "commentType", "CommentType"),
|
||||
userName: readString(item, "userName", "UserName"),
|
||||
commenter: readString(item, "commenter", "Commenter") || null,
|
||||
recordType: readString(item, "recordType", "RecordType") || null,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapWorkOrderAuditEntry(raw: unknown): WorkOrderAuditEntry {
|
||||
const item = asRecord(raw);
|
||||
|
||||
return {
|
||||
id: readId(item),
|
||||
fieldName: readString(item, "fieldName", "FieldName"),
|
||||
oldValue: readString(item, "oldValue", "OldValue"),
|
||||
newValue: readString(item, "newValue", "NewValue"),
|
||||
action: readString(item, "action", "Action"),
|
||||
createdAt: readString(item, "createdAt", "CreatedAt"),
|
||||
userName: readString(item, "userName", "UserName"),
|
||||
};
|
||||
}
|
||||
|
||||
export function mapWorkOrderDispatchSummary(raw: unknown): WorkOrderDispatchSummary {
|
||||
const item = asRecord(raw);
|
||||
|
||||
return {
|
||||
id: readId(item),
|
||||
dispatchNumber: readString(item, "dispatchNumber", "DispatchNumber"),
|
||||
poNumber: readString(item, "poNumber", "PoNumber", "PONumber"),
|
||||
vendorName: readString(item, "vendorName", "VendorName"),
|
||||
nteAmount: readNumber(item, "nteAmount", "NTEAmount"),
|
||||
status: readString(item, "status", "Status"),
|
||||
dispatchedAt: readString(item, "dispatchedAt", "DispatchedAt"),
|
||||
};
|
||||
}
|
||||
|
||||
export function mapWorkOrderDetail(raw: unknown): WorkOrderDetail {
|
||||
const wo = asRecord(raw);
|
||||
const locationId = wo.locationId ?? wo.LocationId;
|
||||
|
||||
return {
|
||||
id: readId(wo),
|
||||
internalWONumber: readString(wo, "internalWONumber", "InternalWONumber"),
|
||||
workOrderNumber: readString(wo, "workerOrderNumber", "workOrderNumber", "WorkerOrderNumber"),
|
||||
title: readString(wo, "title", "workerOrderTitle", "WorkerOrderTitle"),
|
||||
location: readString(wo, "location", "Location"),
|
||||
locationId: locationId != null ? String(locationId) : "",
|
||||
locationZip: readString(wo, "locationZip", "LocationZip"),
|
||||
assignedTo: readString(wo, "assignee", "assignedTo", "Assignee", "AssignedTo"),
|
||||
dueDate: toDateInputValue(readString(wo, "dueDate", "DueDate")),
|
||||
createdDate: toDateInputValue(readString(wo, "createdDate", "CreatedDate")),
|
||||
createdDateFull: readString(wo, "createdDate", "CreatedDate"),
|
||||
description: readString(wo, "description", "Description"),
|
||||
priority: readString(wo, "priority", "Priority"),
|
||||
status: readString(wo, "status", "Status") || "Open",
|
||||
po: readString(wo, "po", "PO"),
|
||||
tt: readString(wo, "tt", "TT"),
|
||||
problem: readString(wo, "problem", "Problem"),
|
||||
trade: readString(wo, "trade", "Trade"),
|
||||
subTrade: readString(wo, "subTrade", "SubTrade"),
|
||||
vendorNTE: wo.vendorNTE != null ? String(wo.vendorNTE) : "",
|
||||
scheduledDate: toDateInputValue(readString(wo, "scheduledDate", "ScheduledDate")),
|
||||
completedDate: toDateInputValue(readString(wo, "completedDate", "CompletedDate")),
|
||||
source: readString(wo, "source", "Source"),
|
||||
comments: Array.isArray(wo.comments)
|
||||
? wo.comments.map(mapWorkOrderComment)
|
||||
: Array.isArray(wo.Comments)
|
||||
? wo.Comments.map(mapWorkOrderComment)
|
||||
: [],
|
||||
auditLog: Array.isArray(wo.auditLog)
|
||||
? wo.auditLog.map(mapWorkOrderAuditEntry)
|
||||
: Array.isArray(wo.AuditLog)
|
||||
? wo.AuditLog.map(mapWorkOrderAuditEntry)
|
||||
: [],
|
||||
dispatches: Array.isArray(wo.dispatches)
|
||||
? wo.dispatches.map(mapWorkOrderDispatchSummary)
|
||||
: Array.isArray(wo.Dispatches)
|
||||
? wo.Dispatches.map(mapWorkOrderDispatchSummary)
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function mapWorkOrderDetailToFormValues(detail: WorkOrderDetail): WorkOrderFormValues {
|
||||
return {
|
||||
internalWONumber: detail.internalWONumber,
|
||||
workOrderNumber: detail.workOrderNumber,
|
||||
title: detail.title,
|
||||
locationId: detail.locationId,
|
||||
priority: detail.priority,
|
||||
status: detail.status,
|
||||
createdDate: detail.createdDate,
|
||||
dueDate: detail.dueDate,
|
||||
scheduledDate: detail.scheduledDate,
|
||||
completedDate: detail.completedDate,
|
||||
assignedTo: detail.assignedTo,
|
||||
description: detail.description,
|
||||
po: detail.po,
|
||||
tt: detail.tt,
|
||||
problem: detail.problem,
|
||||
trade: detail.trade,
|
||||
subTrade: detail.subTrade,
|
||||
vendorNTE: detail.vendorNTE,
|
||||
source: detail.source,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapWorkOrderFormToCreatePayload(
|
||||
values: WorkOrderFormValues,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
workOrderNumber: values.workOrderNumber,
|
||||
title: values.title,
|
||||
locationId: values.locationId ? parseInt(values.locationId, 10) : null,
|
||||
priority: values.priority || null,
|
||||
status: values.status,
|
||||
createdDate: values.createdDate || null,
|
||||
dueDate: values.dueDate || null,
|
||||
scheduledDate: values.scheduledDate || null,
|
||||
completedDate: values.completedDate || null,
|
||||
assignedTo: values.assignedTo || null,
|
||||
description: values.description,
|
||||
po: values.po || null,
|
||||
tt: values.tt || null,
|
||||
problem: values.problem || null,
|
||||
trade: values.trade || null,
|
||||
subTrade: values.subTrade || null,
|
||||
vendorNTE: values.vendorNTE ? parseFloat(values.vendorNTE) : null,
|
||||
source: values.source || null,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapWorkOrderFormToUpdatePayload(
|
||||
id: string | number,
|
||||
values: WorkOrderFormValues,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
Id: id,
|
||||
WorkerOrderNumber: values.workOrderNumber,
|
||||
WorkerOrderTitle: values.title,
|
||||
Description: values.description,
|
||||
PO: values.po || null,
|
||||
TT: values.tt || null,
|
||||
AssignTo: values.assignedTo || null,
|
||||
DueDate: values.dueDate || null,
|
||||
Priority: values.priority || null,
|
||||
Status: values.status,
|
||||
LocationId: values.locationId ? parseInt(values.locationId, 10) : null,
|
||||
Problem: values.problem || null,
|
||||
Trade: values.trade || null,
|
||||
SubTrade: values.subTrade || null,
|
||||
VendorNTE: values.vendorNTE ? parseFloat(values.vendorNTE) : null,
|
||||
ScheduledDate: values.scheduledDate || null,
|
||||
CompletedDate: values.completedDate || null,
|
||||
CreatedDate: values.createdDate || null,
|
||||
Source: values.source || null,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapLocationOption(raw: unknown): LocationOption {
|
||||
const item = asRecord(raw);
|
||||
|
||||
return {
|
||||
id: readId(item),
|
||||
name: readString(item, "name", "Name", "locationName", "LocationName"),
|
||||
};
|
||||
}
|
||||
|
||||
export function mapLocationOptions(rawItems: unknown[]): LocationOption[] {
|
||||
return rawItems.map(mapLocationOption);
|
||||
}
|
||||
|
||||
export function mapEmployeeOption(raw: unknown): EmployeeOption {
|
||||
const item = asRecord(raw);
|
||||
|
||||
return {
|
||||
id: readId(item),
|
||||
name:
|
||||
readString(item, "name", "Name", "fullName", "FullName", "employeeName", "EmployeeName") ||
|
||||
readString(item, "userName", "UserName"),
|
||||
};
|
||||
}
|
||||
|
||||
export function mapEmployeeOptions(rawItems: unknown[]): EmployeeOption[] {
|
||||
return rawItems.map(mapEmployeeOption);
|
||||
}
|
||||
|
||||
export function mapVendorDropdownItem(raw: unknown): VendorDropdownItem {
|
||||
const item = asRecord(raw);
|
||||
|
||||
return {
|
||||
id: readId(item),
|
||||
companyName: readString(item, "companyName", "CompanyName"),
|
||||
tradeSpecialties: readString(item, "tradeSpecialties", "TradeSpecialties"),
|
||||
address: readString(item, "address", "Address"),
|
||||
distanceMiles: readNumber(item, "distanceMiles", "DistanceMiles"),
|
||||
};
|
||||
}
|
||||
|
||||
export function mapVendorDropdownItems(rawItems: unknown[]): VendorDropdownItem[] {
|
||||
return rawItems.map(mapVendorDropdownItem);
|
||||
}
|
||||
|
||||
export function mapTaskListTemplate(raw: unknown): TaskListTemplate {
|
||||
const item = asRecord(raw);
|
||||
const items = item.items ?? item.Items;
|
||||
|
||||
return {
|
||||
id: readId(item),
|
||||
name: readString(item, "name", "Name"),
|
||||
items: Array.isArray(items) ? items : [],
|
||||
};
|
||||
}
|
||||
|
||||
export function mapTaskListTemplates(rawItems: unknown[]): TaskListTemplate[] {
|
||||
return rawItems.map(mapTaskListTemplate);
|
||||
}
|
||||
|
||||
export function mapDispatchChecklistItem(raw: unknown): DispatchChecklistItem {
|
||||
const item = asRecord(raw);
|
||||
|
||||
return {
|
||||
id: readId(item),
|
||||
itemText: readString(item, "itemText", "ItemText"),
|
||||
isCompleted: readBool(item, "isCompleted", "IsCompleted"),
|
||||
completedBy: readString(item, "completedBy", "CompletedBy") || null,
|
||||
completedAt: readString(item, "completedAt", "CompletedAt") || null,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapDispatchSignoff(raw: unknown): DispatchSignoff {
|
||||
const item = asRecord(raw);
|
||||
|
||||
return {
|
||||
id: readId(item),
|
||||
signoffType: readString(item, "signoffType", "SignoffType"),
|
||||
name: readString(item, "name", "Name"),
|
||||
signature: readString(item, "signature", "Signature"),
|
||||
signatureMethod: readString(item, "signatureMethod", "SignatureMethod"),
|
||||
signedAt: readString(item, "signedAt", "SignedAt"),
|
||||
};
|
||||
}
|
||||
|
||||
export function mapDispatchComment(raw: unknown): DispatchComment {
|
||||
const item = asRecord(raw);
|
||||
|
||||
return {
|
||||
id: readId(item),
|
||||
createdDate: readString(item, "createdDate", "CreatedDate"),
|
||||
commenttext: readString(item, "commenttext", "Commenttext", "text", "Text"),
|
||||
userName: readString(item, "userName", "UserName"),
|
||||
commenter: readString(item, "commenter", "Commenter") || null,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapDispatchWorkOrderRef(raw: unknown): DispatchWorkOrderRef {
|
||||
const item = asRecord(raw);
|
||||
|
||||
return {
|
||||
id: readId(item),
|
||||
woNumber: readString(item, "woNumber", "WoNumber", "WONumber", "number", "Number"),
|
||||
};
|
||||
}
|
||||
|
||||
export function mapDispatchDetail(raw: unknown): DispatchDetail {
|
||||
const item = asRecord(raw);
|
||||
const checklistItems = item.checklistItems ?? item.ChecklistItems;
|
||||
const signoffs = item.signoffs ?? item.Signoffs;
|
||||
const comments = item.comments ?? item.Comments;
|
||||
const workOrders = item.workOrders ?? item.WorkOrders;
|
||||
|
||||
return {
|
||||
id: readId(item),
|
||||
dispatchNumber: readString(item, "dispatchNumber", "DispatchNumber"),
|
||||
poNumber: readString(item, "poNumber", "PoNumber", "PONumber"),
|
||||
vendorName: readString(item, "vendorName", "VendorName"),
|
||||
vendorEmail: readString(item, "vendorEmail", "VendorEmail"),
|
||||
woNumber: readString(item, "woNumber", "WoNumber"),
|
||||
status: readString(item, "status", "Status"),
|
||||
nteAmount: readNumber(item, "nteAmount", "NTEAmount"),
|
||||
scheduledDate: readString(item, "scheduledDate", "ScheduledDate"),
|
||||
completedDate: readString(item, "completedDate", "CompletedDate"),
|
||||
description: readString(item, "description", "Description"),
|
||||
dispatchedAt: readString(item, "dispatchedAt", "DispatchedAt"),
|
||||
acknowledgedAt: readString(item, "acknowledgedAt", "AcknowledgedAt"),
|
||||
verifiedAt: readString(item, "verifiedAt", "VerifiedAt"),
|
||||
verifiedBy: readString(item, "verifiedBy", "VerifiedBy"),
|
||||
checklistItems: Array.isArray(checklistItems)
|
||||
? checklistItems.map(mapDispatchChecklistItem)
|
||||
: [],
|
||||
signoffs: Array.isArray(signoffs) ? signoffs.map(mapDispatchSignoff) : [],
|
||||
comments: Array.isArray(comments) ? comments.map(mapDispatchComment) : [],
|
||||
workOrders: Array.isArray(workOrders) ? workOrders.map(mapDispatchWorkOrderRef) : [],
|
||||
};
|
||||
}
|
||||
|
||||
export function mapDispatchToVendorPayload(
|
||||
payload: DispatchToVendorPayload,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
WorkOrderIds: payload.workOrderIds,
|
||||
VendorIds: payload.vendorIds,
|
||||
NTEAmount: payload.nteAmount,
|
||||
ScheduledDate: payload.scheduledDate,
|
||||
Description: payload.description,
|
||||
TaskListTemplateId: payload.taskListTemplateId,
|
||||
CustomChecklistItems: payload.customChecklistItems,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapUpdateDispatchPayload(payload: UpdateDispatchPayload): Record<string, unknown> {
|
||||
return {
|
||||
Id: payload.id,
|
||||
Status: payload.status,
|
||||
NTEAmount: payload.nteAmount,
|
||||
ScheduledDate: payload.scheduledDate,
|
||||
CompletedDate: payload.completedDate,
|
||||
Description: payload.description,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapUpliftRequest(raw: unknown): UpliftRequest {
|
||||
const item = asRecord(raw);
|
||||
|
||||
return {
|
||||
id: readId(item),
|
||||
status: readString(item, "status", "Status"),
|
||||
currentNTE: readNumber(item, "currentNTE", "CurrentNTE"),
|
||||
requestedNTE: readNumber(item, "requestedNTE", "RequestedNTE") ?? 0,
|
||||
delta: readNumber(item, "delta", "Delta") ?? 0,
|
||||
vendorReason: readString(item, "vendorReason", "VendorReason"),
|
||||
requestedAt: readString(item, "requestedAt", "RequestedAt"),
|
||||
requestedByVendorName: readString(item, "requestedByVendorName", "RequestedByVendorName"),
|
||||
decidedAt: readString(item, "decidedAt", "DecidedAt"),
|
||||
decidedByName: readString(item, "decidedByName", "DecidedByName"),
|
||||
decisionNote: readString(item, "decisionNote", "DecisionNote"),
|
||||
requiredTier: readNumber(item, "requiredTier", "RequiredTier") ?? 1,
|
||||
canDecide: readBool(item, "canDecide", "CanDecide"),
|
||||
};
|
||||
}
|
||||
|
||||
export function mapUpliftRequests(rawItems: unknown[]): UpliftRequest[] {
|
||||
return rawItems.map(mapUpliftRequest);
|
||||
}
|
||||
36
src/domain/work-orders/schemas/work-order-schema.ts
Normal file
36
src/domain/work-orders/schemas/work-order-schema.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { z } from "zod";
|
||||
import {
|
||||
WORK_ORDER_PRIORITY_OPTIONS,
|
||||
WORK_ORDER_STATUS_OPTIONS,
|
||||
} from "@/domain/work-orders/types/work-order";
|
||||
|
||||
export const workOrderFormSchema = z.object({
|
||||
internalWONumber: z.string(),
|
||||
workOrderNumber: z.string(),
|
||||
title: z.string().min(1, "Title is required"),
|
||||
locationId: z.string().min(1, "Location is required"),
|
||||
priority: z
|
||||
.string()
|
||||
.min(1, "Priority is required")
|
||||
.refine(
|
||||
(value) =>
|
||||
WORK_ORDER_PRIORITY_OPTIONS.includes(value as (typeof WORK_ORDER_PRIORITY_OPTIONS)[number]),
|
||||
"Priority is required",
|
||||
),
|
||||
status: z.enum(WORK_ORDER_STATUS_OPTIONS),
|
||||
createdDate: z.string().min(1, "Created date is required"),
|
||||
dueDate: z.string(),
|
||||
scheduledDate: z.string(),
|
||||
completedDate: z.string(),
|
||||
assignedTo: z.string(),
|
||||
description: z.string(),
|
||||
po: z.string(),
|
||||
tt: z.string(),
|
||||
problem: z.string(),
|
||||
trade: z.string(),
|
||||
subTrade: z.string(),
|
||||
vendorNTE: z.string(),
|
||||
source: z.string(),
|
||||
});
|
||||
|
||||
export type WorkOrderFormSchemaValues = z.infer<typeof workOrderFormSchema>;
|
||||
270
src/domain/work-orders/types/work-order.ts
Normal file
270
src/domain/work-orders/types/work-order.ts
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
export interface WorkOrderListItem {
|
||||
id: string | number;
|
||||
number: string;
|
||||
title: string;
|
||||
location: string;
|
||||
priority: string;
|
||||
status: string;
|
||||
createdDate: string;
|
||||
dueDate: string;
|
||||
assignedTo: string;
|
||||
lastUpdated: string;
|
||||
}
|
||||
|
||||
export interface WorkOrdersListParams {
|
||||
search?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sortBy?: string;
|
||||
sortDir?: string;
|
||||
status?: string;
|
||||
assignee?: string;
|
||||
locationId?: string | number;
|
||||
}
|
||||
|
||||
export interface WorkOrderUser {
|
||||
id: string | number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface WorkOrdersListResult {
|
||||
items: WorkOrderListItem[];
|
||||
totalCount: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface WorkOrderComment {
|
||||
id: string | number;
|
||||
createdDate: string;
|
||||
commenttext: string;
|
||||
commentType: string;
|
||||
userName: string;
|
||||
commenter: string | null;
|
||||
recordType: string | null;
|
||||
}
|
||||
|
||||
export interface WorkOrderAuditEntry {
|
||||
id: string | number;
|
||||
fieldName: string;
|
||||
oldValue: string;
|
||||
newValue: string;
|
||||
action: string;
|
||||
createdAt: string;
|
||||
userName: string;
|
||||
}
|
||||
|
||||
export interface WorkOrderDispatchSummary {
|
||||
id: string | number;
|
||||
dispatchNumber: string;
|
||||
poNumber: string;
|
||||
vendorName: string;
|
||||
nteAmount: number | null;
|
||||
status: string;
|
||||
dispatchedAt: string;
|
||||
}
|
||||
|
||||
export interface WorkOrderDetail {
|
||||
id: string | number;
|
||||
internalWONumber: string;
|
||||
workOrderNumber: string;
|
||||
title: string;
|
||||
location: string;
|
||||
locationId: string;
|
||||
locationZip: string;
|
||||
assignedTo: string;
|
||||
dueDate: string;
|
||||
createdDate: string;
|
||||
createdDateFull: string;
|
||||
description: string;
|
||||
priority: string;
|
||||
status: string;
|
||||
po: string;
|
||||
tt: string;
|
||||
problem: string;
|
||||
trade: string;
|
||||
subTrade: string;
|
||||
vendorNTE: string;
|
||||
scheduledDate: string;
|
||||
completedDate: string;
|
||||
source: string;
|
||||
comments: WorkOrderComment[];
|
||||
auditLog: WorkOrderAuditEntry[];
|
||||
dispatches: WorkOrderDispatchSummary[];
|
||||
}
|
||||
|
||||
export interface WorkOrderFormValues {
|
||||
internalWONumber: string;
|
||||
workOrderNumber: string;
|
||||
title: string;
|
||||
locationId: string;
|
||||
priority: string;
|
||||
status: string;
|
||||
createdDate: string;
|
||||
dueDate: string;
|
||||
scheduledDate: string;
|
||||
completedDate: string;
|
||||
assignedTo: string;
|
||||
description: string;
|
||||
po: string;
|
||||
tt: string;
|
||||
problem: string;
|
||||
trade: string;
|
||||
subTrade: string;
|
||||
vendorNTE: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface LocationOption {
|
||||
id: string | number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface EmployeeOption {
|
||||
id: string | number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface VendorDropdownItem {
|
||||
id: string | number;
|
||||
companyName: string;
|
||||
tradeSpecialties: string;
|
||||
address: string;
|
||||
distanceMiles: number | null;
|
||||
}
|
||||
|
||||
export interface TaskListTemplate {
|
||||
id: string | number;
|
||||
name: string;
|
||||
items: { id?: string | number; itemText?: string }[];
|
||||
}
|
||||
|
||||
export interface DispatchChecklistItem {
|
||||
id: string | number;
|
||||
itemText: string;
|
||||
isCompleted: boolean;
|
||||
completedBy: string | null;
|
||||
completedAt: string | null;
|
||||
}
|
||||
|
||||
export interface DispatchSignoff {
|
||||
id: string | number;
|
||||
signoffType: string;
|
||||
name: string;
|
||||
signature: string;
|
||||
signatureMethod: string;
|
||||
signedAt: string;
|
||||
}
|
||||
|
||||
export interface DispatchComment {
|
||||
id: string | number;
|
||||
createdDate: string;
|
||||
commenttext: string;
|
||||
userName: string;
|
||||
commenter: string | null;
|
||||
}
|
||||
|
||||
export interface DispatchWorkOrderRef {
|
||||
id: string | number;
|
||||
woNumber: string;
|
||||
}
|
||||
|
||||
export interface DispatchDetail {
|
||||
id: string | number;
|
||||
dispatchNumber: string;
|
||||
poNumber: string;
|
||||
vendorName: string;
|
||||
vendorEmail: string;
|
||||
woNumber: string;
|
||||
status: string;
|
||||
nteAmount: number | null;
|
||||
scheduledDate: string;
|
||||
completedDate: string;
|
||||
description: string;
|
||||
dispatchedAt: string;
|
||||
acknowledgedAt: string;
|
||||
verifiedAt: string;
|
||||
verifiedBy: string;
|
||||
checklistItems: DispatchChecklistItem[];
|
||||
signoffs: DispatchSignoff[];
|
||||
comments: DispatchComment[];
|
||||
workOrders: DispatchWorkOrderRef[];
|
||||
}
|
||||
|
||||
export interface DispatchToVendorPayload {
|
||||
workOrderIds: (string | number)[];
|
||||
vendorIds: (string | number)[];
|
||||
nteAmount: number | null;
|
||||
scheduledDate: string | null;
|
||||
description: string;
|
||||
taskListTemplateId: number | null;
|
||||
customChecklistItems: string[] | null;
|
||||
}
|
||||
|
||||
export interface UpdateDispatchPayload {
|
||||
id: string | number;
|
||||
status: string;
|
||||
nteAmount: number | null;
|
||||
scheduledDate: string | null;
|
||||
completedDate: string | null;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface UpliftRequest {
|
||||
id: string | number;
|
||||
status: string;
|
||||
currentNTE: number | null;
|
||||
requestedNTE: number;
|
||||
delta: number;
|
||||
vendorReason: string;
|
||||
requestedAt: string;
|
||||
requestedByVendorName: string;
|
||||
decidedAt: string;
|
||||
decidedByName: string;
|
||||
decisionNote: string;
|
||||
requiredTier: number;
|
||||
canDecide: boolean;
|
||||
}
|
||||
|
||||
export const WORK_ORDER_STATUS_OPTIONS = [
|
||||
"Open",
|
||||
"In Progress",
|
||||
"On Hold",
|
||||
"Done",
|
||||
"Cancelled",
|
||||
] as const;
|
||||
|
||||
export const WORK_ORDER_PRIORITY_OPTIONS = ["Sev 1", "Sev 2", "Sev 3", "Sev 4", "Sev 5"] as const;
|
||||
|
||||
export const DISPATCH_STATUS_OPTIONS = [
|
||||
"Sent",
|
||||
"Acknowledged",
|
||||
"In Progress",
|
||||
"Completed",
|
||||
"Verified",
|
||||
"Cancelled",
|
||||
] as const;
|
||||
|
||||
export function createDefaultWorkOrderFormValues(): WorkOrderFormValues {
|
||||
return {
|
||||
internalWONumber: "",
|
||||
workOrderNumber: "",
|
||||
title: "",
|
||||
locationId: "",
|
||||
priority: "",
|
||||
status: "Open",
|
||||
createdDate: new Date().toISOString().split("T")[0] ?? "",
|
||||
dueDate: "",
|
||||
scheduledDate: "",
|
||||
completedDate: "",
|
||||
assignedTo: "",
|
||||
description: "",
|
||||
po: "",
|
||||
tt: "",
|
||||
problem: "",
|
||||
trade: "",
|
||||
subTrade: "",
|
||||
vendorNTE: "",
|
||||
source: "",
|
||||
};
|
||||
}
|
||||
67
src/domain/work-orders/use-cases/use-work-order-detail.ts
Normal file
67
src/domain/work-orders/use-cases/use-work-order-detail.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
|
||||
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
|
||||
import type {
|
||||
DispatchDetail,
|
||||
EmployeeOption,
|
||||
LocationOption,
|
||||
TaskListTemplate,
|
||||
VendorDropdownItem,
|
||||
WorkOrderDetail,
|
||||
} from "@/domain/work-orders/types/work-order";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
import { requireQueryParam } from "@/lib/query/require-query-param";
|
||||
|
||||
export function useWorkOrderDetail(
|
||||
id: string | number | undefined,
|
||||
): UseQueryResult<WorkOrderDetail, Error> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.workOrders.detail(id ?? ""),
|
||||
queryFn: () => workOrdersApi.getById(requireQueryParam(id, "Work order id")),
|
||||
enabled: id !== undefined && id !== "",
|
||||
});
|
||||
}
|
||||
|
||||
export function useWorkOrderLocations(): UseQueryResult<LocationOption[], Error> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.locations.dropdown(),
|
||||
queryFn: () => workOrdersApi.fetchLocations(),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useWorkOrderEmployeesDropdown(): UseQueryResult<EmployeeOption[], Error> {
|
||||
return useQuery({
|
||||
queryKey: [...queryKeys.employees.all, "dropdown"] as const,
|
||||
queryFn: () => workOrdersApi.fetchEmployeesDropdown(),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useWorkOrderVendors(
|
||||
trade?: string,
|
||||
siteZip?: string,
|
||||
): UseQueryResult<VendorDropdownItem[], Error> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.workOrders.vendors(trade, siteZip),
|
||||
queryFn: () => workOrdersApi.fetchVendorsDropdown(trade, siteZip),
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTaskListTemplates(): UseQueryResult<TaskListTemplate[], Error> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.workOrders.taskTemplates(),
|
||||
queryFn: () => workOrdersApi.fetchTaskTemplates(),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDispatchDetail(
|
||||
dispatchId: string | number | undefined,
|
||||
): UseQueryResult<DispatchDetail, Error> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.workOrders.dispatch(dispatchId ?? ""),
|
||||
queryFn: () => workOrdersApi.getDispatchById(requireQueryParam(dispatchId, "Dispatch id")),
|
||||
enabled: dispatchId !== undefined && dispatchId !== "",
|
||||
});
|
||||
}
|
||||
268
src/domain/work-orders/use-cases/use-work-order-mutations.ts
Normal file
268
src/domain/work-orders/use-cases/use-work-order-mutations.ts
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
|
||||
import type { WorkOrderFormSchemaValues } from "@/domain/work-orders/schemas/work-order-schema";
|
||||
import type {
|
||||
DispatchComment,
|
||||
DispatchSignoff,
|
||||
DispatchToVendorPayload,
|
||||
UpdateDispatchPayload,
|
||||
WorkOrderComment,
|
||||
WorkOrderDetail,
|
||||
WorkOrderFormValues,
|
||||
} from "@/domain/work-orders/types/work-order";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
|
||||
function invalidateWorkOrders(queryClient: ReturnType<typeof useQueryClient>) {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
|
||||
}
|
||||
|
||||
interface AddWorkOrderCommentInput {
|
||||
text: string;
|
||||
commentType: string;
|
||||
}
|
||||
|
||||
interface ChangeWorkOrderAssignmentInput {
|
||||
userId: string | number;
|
||||
userName: string;
|
||||
}
|
||||
|
||||
interface AddDispatchCommentInput {
|
||||
text: string;
|
||||
sendEmail: boolean;
|
||||
}
|
||||
|
||||
interface UpdateChecklistItemInput {
|
||||
itemId: string | number;
|
||||
isCompleted: boolean;
|
||||
completedBy: string;
|
||||
}
|
||||
|
||||
interface AddDispatchSignoffInput {
|
||||
signoffType: string;
|
||||
name: string;
|
||||
signature: string;
|
||||
signatureMethod: string;
|
||||
}
|
||||
|
||||
export function useCreateWorkOrder(): UseMutationResult<
|
||||
WorkOrderDetail,
|
||||
Error,
|
||||
WorkOrderFormSchemaValues | WorkOrderFormValues
|
||||
> {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (values: WorkOrderFormSchemaValues | WorkOrderFormValues) =>
|
||||
workOrdersApi.create(values),
|
||||
onSuccess: (data) => {
|
||||
invalidateWorkOrders(queryClient);
|
||||
toast.success("Work Order created successfully!");
|
||||
navigate(`/workorders/${data.id}`);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to create work order");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateWorkOrder(
|
||||
id: string | number,
|
||||
): UseMutationResult<WorkOrderDetail, Error, WorkOrderFormSchemaValues | WorkOrderFormValues> {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (values: WorkOrderFormSchemaValues | WorkOrderFormValues) =>
|
||||
workOrdersApi.update(id, values),
|
||||
onSuccess: () => {
|
||||
invalidateWorkOrders(queryClient);
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.detail(id) });
|
||||
toast.success("Work Order updated successfully!");
|
||||
navigate(`/workorders/${id}`);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to update work order");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAddWorkOrderComment(
|
||||
workOrderId: string | number,
|
||||
): UseMutationResult<WorkOrderComment, Error, AddWorkOrderCommentInput> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ text, commentType }: AddWorkOrderCommentInput) =>
|
||||
workOrdersApi.addComment(workOrderId, text, commentType),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.detail(workOrderId) });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to add comment");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useChangeWorkOrderStatus(
|
||||
workOrderId: string | number,
|
||||
): UseMutationResult<void, Error, string> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (status: string) => workOrdersApi.changeStatus(workOrderId, status),
|
||||
onSuccess: (_data, status) => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.detail(workOrderId) });
|
||||
invalidateWorkOrders(queryClient);
|
||||
toast.success(`Status changed to ${status}`);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to update status");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useChangeWorkOrderAssignment(
|
||||
workOrderId: string | number,
|
||||
): UseMutationResult<string, Error, ChangeWorkOrderAssignmentInput> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ userId, userName }: ChangeWorkOrderAssignmentInput) =>
|
||||
workOrdersApi.changeAssignment(workOrderId, userId).then(() => userName),
|
||||
onSuccess: (userName) => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.detail(workOrderId) });
|
||||
invalidateWorkOrders(queryClient);
|
||||
toast.success(`Assigned to ${userName || "Unassigned"}`);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to update assignment");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDispatchToVendor(
|
||||
workOrderId: string | number,
|
||||
): UseMutationResult<void, Error, DispatchToVendorPayload> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: workOrdersApi.dispatchToVendor,
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.detail(workOrderId) });
|
||||
invalidateWorkOrders(queryClient);
|
||||
toast.success("Dispatched successfully");
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to dispatch");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateDispatch(
|
||||
dispatchId: string | number,
|
||||
workOrderId: string | number,
|
||||
): UseMutationResult<void, Error, UpdateDispatchPayload> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: workOrdersApi.updateDispatch,
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.dispatch(dispatchId) });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.detail(workOrderId) });
|
||||
toast.success("Dispatch updated");
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to update dispatch");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAddDispatchComment(
|
||||
dispatchId: string | number,
|
||||
): UseMutationResult<DispatchComment, Error, AddDispatchCommentInput> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ text, sendEmail }: AddDispatchCommentInput) =>
|
||||
workOrdersApi.addDispatchComment(dispatchId, text, sendEmail),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.dispatch(dispatchId) });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to send message");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateChecklistItem(
|
||||
dispatchId: string | number,
|
||||
): UseMutationResult<void, Error, UpdateChecklistItemInput> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ itemId, isCompleted, completedBy }: UpdateChecklistItemInput) =>
|
||||
workOrdersApi.updateChecklistItem(itemId, isCompleted, completedBy),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.dispatch(dispatchId) });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to update checklist item");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAddChecklistItem(
|
||||
dispatchId: string | number,
|
||||
): UseMutationResult<void, Error, string> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (itemText: string) => workOrdersApi.addChecklistItem(dispatchId, itemText),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.dispatch(dispatchId) });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to add checklist item");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAddDispatchSignoff(
|
||||
dispatchId: string | number,
|
||||
): UseMutationResult<DispatchSignoff, Error, AddDispatchSignoffInput> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ signoffType, name, signature, signatureMethod }: AddDispatchSignoffInput) =>
|
||||
workOrdersApi.addDispatchSignoff(dispatchId, signoffType, name, signature, signatureMethod),
|
||||
onSuccess: (_data, variables) => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.dispatch(dispatchId) });
|
||||
toast.success(`${variables.signoffType} sign-off collected`);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to save sign-off");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useVerifyDispatch(
|
||||
dispatchId: string | number,
|
||||
workOrderId: string | number,
|
||||
): UseMutationResult<void, Error, void> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: () => workOrdersApi.verifyDispatch(dispatchId),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.dispatch(dispatchId) });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.detail(workOrderId) });
|
||||
toast.success("Dispatch verified");
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to verify");
|
||||
},
|
||||
});
|
||||
}
|
||||
26
src/domain/work-orders/use-cases/use-work-orders-list.ts
Normal file
26
src/domain/work-orders/use-cases/use-work-orders-list.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { keepPreviousData, useQuery, type UseQueryResult } from "@tanstack/react-query";
|
||||
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
|
||||
import type {
|
||||
WorkOrdersListParams,
|
||||
WorkOrdersListResult,
|
||||
WorkOrderUser,
|
||||
} from "@/domain/work-orders/types/work-order";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
|
||||
export function useWorkOrdersList(
|
||||
params: WorkOrdersListParams,
|
||||
): UseQueryResult<WorkOrdersListResult, Error> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.workOrders.list(params as Record<string, unknown>),
|
||||
queryFn: () => workOrdersApi.getList(params),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
|
||||
export function useWorkOrderUsers(): UseQueryResult<WorkOrderUser[], Error> {
|
||||
return useQuery({
|
||||
queryKey: [...queryKeys.workOrders.all, "users"] as const,
|
||||
queryFn: () => workOrdersApi.fetchUsers(),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
10
src/domain/work-orders/utils/is-dispatch-locked.ts
Normal file
10
src/domain/work-orders/utils/is-dispatch-locked.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
export function isDispatchLocked(dispatch: {
|
||||
verifiedAt?: string | null;
|
||||
status: string;
|
||||
}): boolean {
|
||||
return (
|
||||
Boolean(dispatch.verifiedAt) ||
|
||||
dispatch.status === "Cancelled" ||
|
||||
dispatch.status === "Verified"
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue