From a27e7607d3fc30f3997c39d6c85506fe10a8dd27 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Fri, 12 Jun 2026 11:54:52 -0300 Subject: [PATCH] feat: add work orders module with dispatch modals --- src/app/(protected)/workorders/[id].tsx | 479 ++++++++++++ .../_components/dispatch-create-modal.tsx | 341 +++++++++ .../_components/dispatch-detail-modal.tsx | 698 ++++++++++++++++++ .../_components/work-order-form.tsx | 355 +++++++++ src/app/(protected)/workorders/edit/[id].tsx | 1 + src/app/(protected)/workorders/index.tsx | 310 ++++++++ src/app/(protected)/workorders/new.tsx | 1 + src/domain/work-orders/api/work-orders-api.ts | 256 +++++++ .../work-orders/mappers/work-order-mapper.ts | 443 +++++++++++ .../work-orders/schemas/work-order-schema.ts | 36 + src/domain/work-orders/types/work-order.ts | 270 +++++++ .../use-cases/use-work-order-detail.ts | 67 ++ .../use-cases/use-work-order-mutations.ts | 268 +++++++ .../use-cases/use-work-orders-list.ts | 26 + .../work-orders/utils/is-dispatch-locked.ts | 10 + 15 files changed, 3561 insertions(+) create mode 100644 src/app/(protected)/workorders/[id].tsx create mode 100644 src/app/(protected)/workorders/_components/dispatch-create-modal.tsx create mode 100644 src/app/(protected)/workorders/_components/dispatch-detail-modal.tsx create mode 100644 src/app/(protected)/workorders/_components/work-order-form.tsx create mode 100644 src/app/(protected)/workorders/edit/[id].tsx create mode 100644 src/app/(protected)/workorders/index.tsx create mode 100644 src/app/(protected)/workorders/new.tsx create mode 100644 src/domain/work-orders/api/work-orders-api.ts create mode 100644 src/domain/work-orders/mappers/work-order-mapper.ts create mode 100644 src/domain/work-orders/schemas/work-order-schema.ts create mode 100644 src/domain/work-orders/types/work-order.ts create mode 100644 src/domain/work-orders/use-cases/use-work-order-detail.ts create mode 100644 src/domain/work-orders/use-cases/use-work-order-mutations.ts create mode 100644 src/domain/work-orders/use-cases/use-work-orders-list.ts create mode 100644 src/domain/work-orders/utils/is-dispatch-locked.ts diff --git a/src/app/(protected)/workorders/[id].tsx b/src/app/(protected)/workorders/[id].tsx new file mode 100644 index 00000000..d898d763 --- /dev/null +++ b/src/app/(protected)/workorders/[id].tsx @@ -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 No comments yet.; + } + + return ( + + {sorted.map((comment) => ( + + + + {comment.userName || comment.commenter || "System"} + + + {comment.createdDate ? formatDateTime(comment.createdDate) : ""} + + + {comment.commenttext} + + ))} + + ); +} + +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 No changes recorded.; + } + + return ( + + {sorted.map((entry) => ( + + + + {entry.userName || "System"} + + + {entry.createdAt ? formatDateTime(entry.createdAt) : ""} + + + + {entry.fieldName} {entry.oldValue} → {entry.newValue} + + + ))} + + ); +} + +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("customer"); + const [commentText, setCommentText] = useState(""); + const [dispatchOpen, setDispatchOpen] = useState(false); + const [dispatchDetailId, setDispatchDetailId] = useState(null); + const [statusAnchor, setStatusAnchor] = useState(null); + const [assignAnchor, setAssignAnchor] = useState(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 ( + + + + ); + } + + if (error || !wo) { + return ( + + + {error instanceof Error ? error.message : "Work order not found."} + + + ); + } + + return ( + + + + Work Orders + + {wo.internalWONumber} + + + + + + {wo.title} + + setStatusAnchor(event.currentTarget)} + className="w-fit cursor-pointer" + /> + setStatusAnchor(null)} + > + {WORK_ORDER_STATUS_OPTIONS.map((status) => ( + { + setStatusAnchor(null); + if (status !== wo.status) { + statusMutation.mutate(status); + } + }} + > + {status} + + ))} + + + + + + + + + + + + + Details + + + + + + + + + Assigned To + + + setAssignAnchor(null)} + > + { + setAssignAnchor(null); + assignmentMutation.mutate({ userId: "", userName: "" }); + }} + > + Unassigned + + {users.map((user) => ( + { + setAssignAnchor(null); + assignmentMutation.mutate({ userId: user.id, userName: user.name }); + }} + > + {user.name} + + ))} + + + + + + + + + }> + Classification + + + + + + + + + + + + }> + Schedule + + + + + + + + + + + + + }> + Financial + + + + + + + + + + + + }> + Dispatches ({wo.dispatches.length}) + + + {wo.dispatches.length ? ( + + + + + Dispatch # + Vendor + NTE + Status + Date + Wait + + + + {wo.dispatches.map((dispatch) => { + const waitColor = waitTimeColor(dispatch.dispatchedAt); + return ( + setDispatchDetailId(dispatch.id)} + > + {dispatch.dispatchNumber || dispatch.poNumber} + {dispatch.vendorName} + + {dispatch.nteAmount != null ? `$${dispatch.nteAmount.toFixed(2)}` : "—"} + + {dispatch.status} + + {dispatch.dispatchedAt ? formatDateTime(dispatch.dispatchedAt) : "—"} + + + {dispatch.status === "Sent" ? ( + + {timeSince(dispatch.dispatchedAt)} + + ) : ( + "—" + )} + + + ); + })} + +
+
+ ) : ( + No dispatches yet. + )} +
+
+ + + }> + Description + + + {wo.description || "No description provided."} + + + + + + Activity + + setActiveTab(value)} + className="mb-3" + > + + + + + + {activeTab === "customer" && } + {activeTab === "internal" && } + {activeTab === "audit" && } + + {activeTab === "internal" && ( + + setCommentText(event.target.value)} + placeholder="Type a comment..." + disabled={commentMutation.isPending} + /> + + + )} + + + {dispatchOpen && ( + setDispatchOpen(false)} + onDispatched={() => void refetch()} + /> + )} + + {dispatchDetailId && ( + setDispatchDetailId(null)} + onUpdated={() => void refetch()} + /> + )} +
+ ); +} + +function Field({ label, value }: { label: string; value: string }) { + return ( + + + {label} + + {value} + + ); +} diff --git a/src/app/(protected)/workorders/_components/dispatch-create-modal.tsx b/src/app/(protected)/workorders/_components/dispatch-create-modal.tsx new file mode 100644 index 00000000..1ab4bb80 --- /dev/null +++ b/src/app/(protected)/workorders/_components/dispatch-create-modal.tsx @@ -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(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([]); + const [newCustomItem, setNewCustomItem] = useState(""); + const [selectedWOs, setSelectedWOs] = useState([ + { + 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 ( + + + Dispatch{" "} + {selectedWOs.length > 1 + ? `${selectedWOs.length} Work Orders` + : `Work Order ${workOrder.internalWONumber}`} + + + + + + + + + Work Orders ({selectedWOs.length}) + + + {selectedWOs.map((wo) => ( + setSelectedWOs((prev) => prev.filter((item) => item.id !== wo.id)) + : undefined + } + /> + ))} + + {siteWorkOrders.length > 0 && ( + + setWoSearch(event.target.value)} + placeholder="Search to filter..." + fullWidth + className="mb-2" + /> + + {filteredSiteWOs.map((item) => ( + { + setSelectedWOs((prev) => [ + ...prev, + { id: item.id, number: item.number, title: item.title }, + ]); + setWoSearch(""); + }} + > + + {item.number} — {item.title} + + + ))} + + + )} + + + + + Select Vendor + + setVendorSearch(event.target.value)} + placeholder="Search vendors..." + fullWidth + className="mb-2" + /> + {isLoadingVendors ? ( + Loading vendors... + ) : filteredVendors.length === 0 ? ( + No vendors found + ) : ( + + {filteredVendors.map((vendor) => ( + + setSelectedVendorId((current) => (current === vendor.id ? null : vendor.id)) + } + > + + + + {vendor.companyName} + + {vendor.tradeSpecialties && ( + + {vendor.tradeSpecialties} + + )} + {vendor.address && ( + + {vendor.address} + + )} + + {vendor.distanceMiles != null && ( + + {vendor.distanceMiles} miles + + )} + + ))} + + )} + + + + setNteAmount(event.target.value)} + inputProps={{ step: "0.01", min: 0 }} + fullWidth + /> + setScheduledDate(event.target.value)} + slotProps={{ inputLabel: { shrink: true } }} + fullWidth + /> + + + setSelectedTemplateId(event.target.value)} + fullWidth + > + No checklist template + {taskTemplates.map((template) => ( + + {template.name} ({template.items?.length ?? 0} items) + + ))} + + + {customItems.length > 0 && ( + + {customItems.map((item, index) => ( + setCustomItems((prev) => prev.filter((_, i) => i !== index))} + /> + ))} + + )} + + 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" + /> + + setDescription(event.target.value)} + fullWidth + /> + + + + + + + + ); +} + +function PaperList({ children }: { children: React.ReactNode }) { + return ( + {children} + ); +} diff --git a/src/app/(protected)/workorders/_components/dispatch-detail-modal.tsx b/src/app/(protected)/workorders/_components/dispatch-detail-modal.tsx new file mode 100644 index 00000000..a1860a13 --- /dev/null +++ b/src/app/(protected)/workorders/_components/dispatch-detail-modal.tsx @@ -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(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 ( + + + + + + ); + } + + if (!dispatch) { + return null; + } + + const isLocked = isDispatchLocked(dispatch); + const waitColor = waitTimeColor(dispatch.dispatchedAt); + + return ( + <> + + + + + {dispatch.dispatchNumber || dispatch.poNumber} · {dispatch.vendorName} + + + PO: {dispatch.poNumber} ·{" "} + {dispatch.workOrders.length > 0 + ? `${dispatch.workOrders.length} WO(s): ${dispatch.workOrders.map((wo) => wo.woNumber).join(", ")}` + : `WO: ${dispatch.woNumber}`}{" "} + · {dispatch.vendorEmail} + + + + + + + + + + {editing ? ( + <> + + setForm((current) => ({ ...current, status: event.target.value })) + } + > + {DISPATCH_STATUS_OPTIONS.map((status) => ( + + {status} + + ))} + + + setForm((current) => ({ ...current, nteAmount: event.target.value })) + } + inputProps={{ step: "0.01" }} + /> + + setForm((current) => ({ ...current, scheduledDate: event.target.value })) + } + slotProps={{ inputLabel: { shrink: true } }} + /> + + setForm((current) => ({ ...current, completedDate: event.target.value })) + } + slotProps={{ inputLabel: { shrink: true } }} + /> + + setForm((current) => ({ ...current, description: event.target.value })) + } + /> + + + + + + ) : ( + <> + + + + + + + + {dispatch.description && ( + + )} + {!isLocked && ( + + + + + )} + + )} + + + {(dispatch.checklistItems.length > 0 || !isLocked) && ( + + + Checklist ({dispatch.checklistItems.filter((item) => item.isCompleted).length}/ + {dispatch.checklistItems.length}) + + + {dispatch.checklistItems.map((item) => ( + + + updateChecklistMutation.mutate({ + itemId: item.id, + isCompleted: !item.isCompleted, + completedBy: userName, + }) + } + /> + + {item.itemText} + {item.isCompleted && ( + + {item.completedBy} + {item.completedAt ? ` · ${formatDateTime(item.completedAt)}` : ""} + + )} + + + ))} + + {!isLocked && ( + 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" + /> + )} + + )} + + + + Sign-offs + + + {(["Customer", "Vendor"] as const).map((type) => { + const signoff = dispatch.signoffs.find((item) => item.signoffType === type); + return ( + + {type} + {signoff ? ( + + {signoff.signatureMethod === "drawn" && signoff.signature && ( + Signature + )} + {signoff.signatureMethod === "typed" && ( + + {signoff.signature} + + )} + + {signoff.name} + {signoff.signedAt ? ` · ${formatDateTime(signoff.signedAt)}` : ""} + + + ) : ( + + )} + + ); + })} + + {dispatch.signoffs.length >= 2 && ( + + )} + + + + + NTE Uplift Requests + + {uplifts.length === 0 ? ( + No uplift requests. + ) : ( + + {uplifts.map((uplift) => { + const canDecide = uplift.canDecide && uplift.status === "Pending" && !isLocked; + + return ( + + + + {uplift.requiredTier === 2 && ( + Tier 2 · Manager approval + )} + + ${Number(uplift.currentNTE ?? 0).toFixed(2)} → $ + {Number(uplift.requestedNTE).toFixed(2)} (+$ + {Number(uplift.delta).toFixed(2)}) + + + {uplift.vendorReason && ( + + Reason: {uplift.vendorReason} + + )} + {(uplift.requestedByVendorName || uplift.requestedAt) && ( + + Requested by {uplift.requestedByVendorName || "vendor"} + {uplift.requestedAt ? ` · ${formatDateTime(uplift.requestedAt)}` : ""} + + )} + {uplift.status !== "Pending" && + (uplift.decidedByName || uplift.decidedAt || uplift.decisionNote) && ( + + {(uplift.decidedByName || uplift.decidedAt) && ( + + {uplift.status} by {uplift.decidedByName || "—"} + {uplift.decidedAt ? ` · ${formatDateTime(uplift.decidedAt)}` : ""} + + )} + {uplift.decisionNote && ( + + Note: {uplift.decisionNote} + + )} + + )} + {uplift.status === "Pending" && ( + + + + + + + + + + + + + )} + + ); + })} + + )} + + + + + Vendor Communication + + + {dispatch.comments.length === 0 ? ( + No messages yet. + ) : ( + + {dispatch.comments.map((comment) => ( + + + + {comment.userName || comment.commenter || "System"} + + + {comment.createdDate ? formatDateTime(comment.createdDate) : ""} + + + {comment.commenttext} + + ))} + + )} + + + setCommentText(event.target.value)} + placeholder="Message vendor..." + fullWidth + disabled={isLocked || addCommentMutation.isPending} + /> + + setSendEmail(event.target.checked)} + disabled={isLocked} + /> + } + label="Send email" + /> + + + + + + {dispatch.verifiedAt ? ( + + Verified by {dispatch.verifiedBy} on {formatDateTime(dispatch.verifiedAt)} + + ) : ( + + {verifyState.missing.length > 0 && ( + + Missing: {verifyState.missing.join(", ")} + + )} + + + )} + + + + {sigCaptureType && ( + 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 ( + + + {label} + + + {value} + + + ); +} diff --git a/src/app/(protected)/workorders/_components/work-order-form.tsx b/src/app/(protected)/workorders/_components/work-order-form.tsx new file mode 100644 index 00000000..c7db0492 --- /dev/null +++ b/src/app/(protected)/workorders/_components/work-order-form.tsx @@ -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 ( + + {children} + + ); +} + +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({ + 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 ( + + + + ); + } + + if (detailError) { + return ( + + + {detailError instanceof Error ? detailError.message : "Failed to load work order"} + + + ); + } + + return ( + + + + Work Orders + + {isEdit ? "Edit" : "Create"} + + + + {isEdit ? `Edit Work Order ${detail?.internalWONumber ?? id}` : "Create a Work Order"} + + + + + + Work Order Details + + + + + + ( + option.name} + value={locationOptions.find((loc) => String(loc.id) === field.value) ?? null} + onChange={(_event, option) => field.onChange(option ? String(option.id) : "")} + renderInput={(params) => ( + + )} + className="flex-1" + /> + )} + /> + + + + + + Classification + + + Select Problem + {problems.map((option) => ( + + {option.value} + + ))} + + + Select Trade + {trades.map((option) => ( + + {option.value} + + ))} + + + + {tradeValue ? "Select Sub-Trade" : "Select a Trade first"} + + {subTrades.map((option) => ( + + {option.value} + + ))} + + + + + + Schedule & Priority + + + Select Priority + {WORK_ORDER_PRIORITY_OPTIONS.map((priority) => ( + + {priority} + + ))} + + + {WORK_ORDER_STATUS_OPTIONS.map((status) => ( + + {status} + + ))} + + ( + employee.name)} + value={field.value} + onChange={(_event, value) => field.onChange(value ?? "")} + onInputChange={(_event, value) => field.onChange(value)} + renderInput={(params) => ( + + )} + className="flex-1" + /> + )} + /> + + + + + + + + + + + Financial + + + + + + + + + Description + + + + + + + + + + + ); +} diff --git a/src/app/(protected)/workorders/edit/[id].tsx b/src/app/(protected)/workorders/edit/[id].tsx new file mode 100644 index 00000000..56354fc4 --- /dev/null +++ b/src/app/(protected)/workorders/edit/[id].tsx @@ -0,0 +1 @@ +export { default } from "../_components/work-order-form"; diff --git a/src/app/(protected)/workorders/index.tsx b/src/app/(protected)/workorders/index.tsx new file mode 100644 index 00000000..c3b165eb --- /dev/null +++ b/src/app/(protected)/workorders/index.tsx @@ -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 ( + + + + + Work Orders + + + {totalCount} total + + {isFetching && !isLoading && } + + + + { + setSearch(event.target.value); + setPage(0); + }} + placeholder="Search..." + className="min-w-[220px]" + /> + + + + + + { + setStatusFilter(""); + setPage(0); + }} + /> + {STATUS_OPTIONS.map((status) => ( + handleStatusFilter(status)} + /> + ))} + + + Assignee + + + + + {error && ( + + {error instanceof Error ? error.message : "Failed to load work orders"} + + )} + + + + + + handleSort("number")} + > + WO #{sortIndicator("number")} + + handleSort("title")} + > + Title{sortIndicator("title")} + + Location + Priority + Status + Created + Assigned To + Last Updated + + Actions + + + + + {isLoading ? ( + + + + + + ) : rows.length === 0 ? ( + + + No work orders found + + + ) : ( + rows.map((row: WorkOrderListItem) => ( + + + + + {row.title} + {row.location} + {row.priority} + + + + + {formatDateTime(row.createdDate)} + + {row.assignedTo || "Unassigned"} + + {formatDateTime(row.lastUpdated)} + + + + navigate(`/workorders/${row.id}`)} + > + + + + + navigate(`/workorders/edit/${row.id}`)} + > + + + + + + )) + )} + +
+
+ + setPage(nextPage)} + rowsPerPage={pageSize} + onRowsPerPageChange={(event) => { + setPageSize(Number(event.target.value)); + setPage(0); + }} + rowsPerPageOptions={[12, 24, 48]} + /> +
+ ); +} diff --git a/src/app/(protected)/workorders/new.tsx b/src/app/(protected)/workorders/new.tsx new file mode 100644 index 00000000..15308687 --- /dev/null +++ b/src/app/(protected)/workorders/new.tsx @@ -0,0 +1 @@ +export { default } from "./_components/work-order-form"; diff --git a/src/domain/work-orders/api/work-orders-api.ts b/src/domain/work-orders/api/work-orders-api.ts new file mode 100644 index 00000000..5059ed57 --- /dev/null +++ b/src/domain/work-orders/api/work-orders-api.ts @@ -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 { + const searchParams: Record = { + 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; + 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 => { + const searchParams = buildSearchParams(params); + const data = await apiGet(API_PATHS.workOrder.getList, { searchParams }); + const paginated = handlePaginatedResponse(data, { + page: params.page, + pageSize: params.pageSize, + }); + + return { + items: mapWorkOrderListItems(paginated.items), + totalCount: paginated.totalCount, + page: paginated.page, + pageSize: paginated.pageSize, + }; + }, + + fetchUsers: async (): Promise => { + const data = await apiGet(API_PATHS.user.list); + const users = handleApiResponse(data); + return Array.isArray(users) ? users.map(mapUser) : []; + }, + + getById: async (id: string | number): Promise => { + const data = await apiGet(`${API_PATHS.rest.workOrders}/${id}`); + return mapWorkOrderDetail(handleApiResponse(data)); + }, + + create: async ( + values: WorkOrderFormSchemaValues | WorkOrderFormValues, + ): Promise => { + const payload = mapWorkOrderFormToCreatePayload(values); + const data = await apiPost(API_PATHS.rest.workOrders, payload); + return mapWorkOrderDetail(handleApiResponse(data)); + }, + + update: async ( + id: string | number, + values: WorkOrderFormSchemaValues | WorkOrderFormValues, + ): Promise => { + const payload = mapWorkOrderFormToUpdatePayload(id, values); + const data = await apiPut(`${API_PATHS.rest.workOrders}/${id}`, payload); + return mapWorkOrderDetail(handleApiResponse(data)); + }, + + delete: async (id: string | number): Promise => { + await apiDelete(`${API_PATHS.rest.workOrders}/${id}`); + }, + + addComment: async ( + workOrderId: string | number, + text: string, + commentType: string, + ): Promise => { + const data = await apiPost(API_PATHS.workOrder.addCommentJson, { + WorkorderId: workOrderId, + Text: text, + CommentType: commentType, + }); + return mapWorkOrderComment(handleApiResponse(data)); + }, + + changeStatus: async (id: string | number, status: string): Promise => { + await apiPost(API_PATHS.workOrder.changeStatus, undefined, { + searchParams: { id, status }, + }); + }, + + changeAssignment: async (id: string | number, assignTo: string | number): Promise => { + await apiPost(API_PATHS.workOrder.changeAssignment, undefined, { + searchParams: { id, assignTo: assignTo || "" }, + }); + }, + + fetchLocations: async (): Promise => { + const data = await apiGet(API_PATHS.contact.getLocations); + const items = handleApiResponse(data); + return Array.isArray(items) ? mapLocationOptions(items) : []; + }, + + fetchEmployeesDropdown: async (): Promise => { + const data = await apiGet(API_PATHS.employee.dropdown); + const items = handleApiResponse(data); + return Array.isArray(items) ? mapEmployeeOptions(items) : []; + }, + + fetchVendorsDropdown: async (trade?: string, siteZip?: string): Promise => { + const searchParams: Record = {}; + if (trade) { + searchParams.trade = trade; + } + if (siteZip) { + searchParams.siteZip = siteZip; + } + + const data = await apiGet(API_PATHS.vendor.dropdown, { + searchParams: Object.keys(searchParams).length > 0 ? searchParams : undefined, + }); + const items = handleApiResponse(data); + return Array.isArray(items) ? mapVendorDropdownItems(items) : []; + }, + + fetchTaskTemplates: async (): Promise => { + const data = await apiGet(API_PATHS.taskListTemplate.list); + const items = handleApiResponse(data); + return Array.isArray(items) ? mapTaskListTemplates(items) : []; + }, + + dispatchToVendor: async (payload: DispatchToVendorPayload): Promise => { + await apiPost( + API_PATHS.workOrder.dispatchToVendor, + mapDispatchToVendorPayload(payload), + ); + }, + + getDispatchById: async (id: string | number): Promise => { + const data = await apiGet(`${API_PATHS.workOrder.getDispatch}/${id}`); + return mapDispatchDetail(handleApiResponse(data)); + }, + + updateDispatch: async (payload: UpdateDispatchPayload): Promise => { + await apiPost(API_PATHS.workOrder.updateDispatch, mapUpdateDispatchPayload(payload)); + }, + + addDispatchComment: async ( + dispatchId: string | number, + text: string, + sendEmail = true, + ): Promise => { + const data = await apiPost(API_PATHS.workOrder.addDispatchComment, { + DispatchId: dispatchId, + Text: text, + SendEmail: sendEmail, + }); + return mapDispatchComment(handleApiResponse(data)); + }, + + updateChecklistItem: async ( + id: string | number, + isCompleted: boolean, + completedBy: string, + ): Promise => { + await apiPost(API_PATHS.workOrder.updateChecklistItem, { + Id: id, + IsCompleted: isCompleted, + CompletedBy: completedBy, + }); + }, + + addChecklistItem: async (dispatchId: string | number, itemText: string): Promise => { + await apiPost(API_PATHS.workOrder.addChecklistItem, { + DispatchId: dispatchId, + ItemText: itemText, + }); + }, + + addDispatchSignoff: async ( + dispatchId: string | number, + signoffType: string, + name: string, + signature: string, + signatureMethod: string, + ): Promise => { + const data = await apiPost(API_PATHS.workOrder.addDispatchSignoff, { + DispatchId: dispatchId, + SignoffType: signoffType, + Name: name, + Signature: signature, + SignatureMethod: signatureMethod, + }); + return mapDispatchSignoff(handleApiResponse(data)); + }, + + verifyDispatch: async (dispatchId: string | number): Promise => { + await apiPost(API_PATHS.workOrder.verifyDispatch, undefined, { + searchParams: { dispatchId }, + }); + }, +}; diff --git a/src/domain/work-orders/mappers/work-order-mapper.ts b/src/domain/work-orders/mappers/work-order-mapper.ts new file mode 100644 index 00000000..a1022ac6 --- /dev/null +++ b/src/domain/work-orders/mappers/work-order-mapper.ts @@ -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 { + return typeof raw === "object" && raw !== null ? (raw as Record) : {}; +} + +function readString(record: Record, ...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 | number { + const value = record.id ?? record.Id; + if (typeof value === "string" || typeof value === "number") { + return value; + } + return ""; +} + +function readNumber(record: Record, ...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, ...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 { + 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 { + 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 { + 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 { + 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); +} diff --git a/src/domain/work-orders/schemas/work-order-schema.ts b/src/domain/work-orders/schemas/work-order-schema.ts new file mode 100644 index 00000000..a675089b --- /dev/null +++ b/src/domain/work-orders/schemas/work-order-schema.ts @@ -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; diff --git a/src/domain/work-orders/types/work-order.ts b/src/domain/work-orders/types/work-order.ts new file mode 100644 index 00000000..34f63bc5 --- /dev/null +++ b/src/domain/work-orders/types/work-order.ts @@ -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: "", + }; +} diff --git a/src/domain/work-orders/use-cases/use-work-order-detail.ts b/src/domain/work-orders/use-cases/use-work-order-detail.ts new file mode 100644 index 00000000..5b451fc6 --- /dev/null +++ b/src/domain/work-orders/use-cases/use-work-order-detail.ts @@ -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 { + return useQuery({ + queryKey: queryKeys.workOrders.detail(id ?? ""), + queryFn: () => workOrdersApi.getById(requireQueryParam(id, "Work order id")), + enabled: id !== undefined && id !== "", + }); +} + +export function useWorkOrderLocations(): UseQueryResult { + return useQuery({ + queryKey: queryKeys.locations.dropdown(), + queryFn: () => workOrdersApi.fetchLocations(), + staleTime: 5 * 60 * 1000, + }); +} + +export function useWorkOrderEmployeesDropdown(): UseQueryResult { + return useQuery({ + queryKey: [...queryKeys.employees.all, "dropdown"] as const, + queryFn: () => workOrdersApi.fetchEmployeesDropdown(), + staleTime: 5 * 60 * 1000, + }); +} + +export function useWorkOrderVendors( + trade?: string, + siteZip?: string, +): UseQueryResult { + return useQuery({ + queryKey: queryKeys.workOrders.vendors(trade, siteZip), + queryFn: () => workOrdersApi.fetchVendorsDropdown(trade, siteZip), + staleTime: 60 * 1000, + }); +} + +export function useTaskListTemplates(): UseQueryResult { + return useQuery({ + queryKey: queryKeys.workOrders.taskTemplates(), + queryFn: () => workOrdersApi.fetchTaskTemplates(), + staleTime: 5 * 60 * 1000, + }); +} + +export function useDispatchDetail( + dispatchId: string | number | undefined, +): UseQueryResult { + return useQuery({ + queryKey: queryKeys.workOrders.dispatch(dispatchId ?? ""), + queryFn: () => workOrdersApi.getDispatchById(requireQueryParam(dispatchId, "Dispatch id")), + enabled: dispatchId !== undefined && dispatchId !== "", + }); +} diff --git a/src/domain/work-orders/use-cases/use-work-order-mutations.ts b/src/domain/work-orders/use-cases/use-work-order-mutations.ts new file mode 100644 index 00000000..8c4f957f --- /dev/null +++ b/src/domain/work-orders/use-cases/use-work-order-mutations.ts @@ -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) { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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"); + }, + }); +} diff --git a/src/domain/work-orders/use-cases/use-work-orders-list.ts b/src/domain/work-orders/use-cases/use-work-orders-list.ts new file mode 100644 index 00000000..a0b6ef07 --- /dev/null +++ b/src/domain/work-orders/use-cases/use-work-orders-list.ts @@ -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 { + return useQuery({ + queryKey: queryKeys.workOrders.list(params as Record), + queryFn: () => workOrdersApi.getList(params), + placeholderData: keepPreviousData, + }); +} + +export function useWorkOrderUsers(): UseQueryResult { + return useQuery({ + queryKey: [...queryKeys.workOrders.all, "users"] as const, + queryFn: () => workOrdersApi.fetchUsers(), + staleTime: 5 * 60 * 1000, + }); +} diff --git a/src/domain/work-orders/utils/is-dispatch-locked.ts b/src/domain/work-orders/utils/is-dispatch-locked.ts new file mode 100644 index 00000000..41d3d82e --- /dev/null +++ b/src/domain/work-orders/utils/is-dispatch-locked.ts @@ -0,0 +1,10 @@ +export function isDispatchLocked(dispatch: { + verifiedAt?: string | null; + status: string; +}): boolean { + return ( + Boolean(dispatch.verifiedAt) || + dispatch.status === "Cancelled" || + dispatch.status === "Verified" + ); +}