feat: add work orders module with dispatch modals

This commit is contained in:
Arthur Bassi 2026-06-12 11:54:52 -03:00
parent 77e128af5a
commit a27e7607d3
15 changed files with 3561 additions and 0 deletions

View 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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View 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>
);
}

View file

@ -0,0 +1 @@
export { default } from "../_components/work-order-form";

View 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>
);
}

View file

@ -0,0 +1 @@
export { default } from "./_components/work-order-form";

View 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 },
});
},
};

View 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);
}

View 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>;

View 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: "",
};
}

View 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 !== "",
});
}

View 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");
},
});
}

View 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,
});
}

View 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"
);
}