diff --git a/src/app/(protected)/workorders/[id].tsx b/src/app/(protected)/workorders/[id].tsx index 9deeb752..fb4d999f 100644 --- a/src/app/(protected)/workorders/[id].tsx +++ b/src/app/(protected)/workorders/[id].tsx @@ -1,20 +1,36 @@ -import { Link as RouterLink, useNavigate } from "react-router"; +import { useMemo, useState } from "react"; +import { Link as RouterLink, useNavigate, useParams } from "react-router"; 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 { useState } from "react"; -import { useParams } from "react-router"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import { DispatchCreateModal } from "./_components/dispatch-create-modal"; import { DispatchDetailModal } from "./_components/dispatch-detail-modal"; -import { WorkOrderDetailsSection } from "./_components/detail/work-order-detail-sections"; -import { WorkOrderStatusMenu } from "./_components/detail/work-order-status-menu"; +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, @@ -22,20 +38,167 @@ import { 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 } = useWorkOrderDetail(workOrderId); + 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; + } + if (activeTab !== "customer" && activeTab !== "internal") { + return; + } + + commentMutation.mutate( + { text: commentText.trim(), commentType: activeTab }, + { onSuccess: () => setCommentText("") }, + ); + }; if (isLoading) { return ( @@ -61,9 +224,14 @@ export default function WorkOrderViewPage() { Work Orders - {wo.internalWONumber} + + {wo.internalWONumber} + - {wo.title} - statusMutation.mutate(status)} + 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} + + ))} + - + @@ -94,27 +290,283 @@ export default function WorkOrderViewPage() { + + + 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" + > + + + + - assignmentMutation.mutate({ userId, userName })} - onDispatchClick={setDispatchDetailId} - onCommentSubmit={(text, commentType) => commentMutation.mutate({ text, commentType })} - isCommentPending={commentMutation.isPending} - /> + {activeTab === "customer" && } + {activeTab === "internal" && } + {activeTab === "audit" && } + {activeTab === "internal" && ( + + setCommentText(event.target.value)} + placeholder="Type a comment..." + disabled={commentMutation.isPending} + /> + + + )} + {dispatchOpen && ( - setDispatchOpen(false)} /> + 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/index.tsx b/src/app/(protected)/workorders/index.tsx index 98de7ec7..dd5b0df8 100644 --- a/src/app/(protected)/workorders/index.tsx +++ b/src/app/(protected)/workorders/index.tsx @@ -1,191 +1,337 @@ import { useMemo, useState } from "react"; -import { Box } from "@mui/material"; -import { PageHeader } from "@/components/ui/page-header"; -import { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog"; -import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal"; -import { WorkOrderSlideOver } from "@/app/(protected)/workorders/_components/detail/work-order-slide-over"; -import { NewWoWizard } from "@/app/(protected)/workorders/_components/wizard/new-wo-wizard"; -import { WorkOrdersAdvancedSearchSheet } from "@/app/(protected)/workorders/_components/list/advanced-search/work-orders-advanced-search-sheet"; -import { WorkOrdersAdvancedFiltersBanner } from "@/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner"; -import { WorkOrdersFilterBar } from "@/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar"; -import { WorkOrdersListHeader } from "@/app/(protected)/workorders/_components/list/work-orders-list-header"; -import { WoTable } from "@/app/(protected)/workorders/_components/list/table/wo-table"; -import { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters"; -import { useWorkOrdersTableData } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-data"; -import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category"; +import { useNavigate } from "react-router"; +import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; +import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined"; +import { WorkOrderStatusBadge } from "@/components/domain/status-badge"; import { - useWorkOrderLocations, - useWorkOrderVendors, -} from "@/domain/work-orders/use-cases/use-work-order-detail"; -import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list"; -import { isWoTableMockEnabled } from "@/domain/work-orders/mocks/is-wo-table-mock-enabled"; -import { getMockPmTypes } from "@/domain/work-orders/mocks/wo-table-mock-data"; -import type { CompDocData } from "@/domain/work-orders/types/completion-doc"; -import { EMPTY_MEDIA, type MediaFile } from "@/domain/work-orders/types/work-order-media"; -import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; -import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations"; -import { useAuthContext } from "@/providers/auth-context"; + 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 { WorkOrderTableRow } 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 { user } = useAuthContext(); - const [wizardOpen, setWizardOpen] = useState(false); - const [slideOverRow, setSlideOverRow] = useState(null); - const [slideOverEdit, setSlideOverEdit] = useState(false); - const [docRow, setDocRow] = useState(null); - const [mediaRow, setMediaRow] = useState(null); - const [completionDocs, setCompletionDocs] = useState>({}); - const [mediaFiles, setMediaFiles] = useState>({}); - const filters = useWorkOrdersListFilters(user?.id); - const tableData = useWorkOrdersTableData(filters); - const { patchField } = useWorkOrderTableMutations(tableData.patchRow); + 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 { data: locations = [] } = useWorkOrderLocations(); - const { data: vendors = [] } = useWorkOrderVendors(); - const { data: problemOptions = [] } = useDropdownOptionsByCategory("Problem"); - const pmTypes = useMemo( - () => - isWoTableMockEnabled() - ? getMockPmTypes() - : problemOptions.map((option) => option.value).filter(Boolean), - [problemOptions], - ); + const rows = data?.items ?? []; + const totalCount = data?.totalCount ?? 0; - const vendorTechs = useMemo( - () => [...new Set(vendors.map((vendor) => vendor.companyName).filter(Boolean))], - [vendors], - ); + const handleSort = (column: string) => { + if (sortBy === column) { + setSortDir((current) => (current === "asc" ? "desc" : current === "desc" ? "" : "asc")); + if (sortDir === "desc") { + setSortBy(""); + } + return; + } - const totalCount = tableData.totalCount; - const scopeTotalCount = tableData.scopeTotalCount; + setSortBy(column); + setSortDir("asc"); + }; - const activeDocRow = useMemo(() => { - if (!docRow) return null; - return tableData.rows.find((row) => row.id === docRow.id) ?? docRow; - }, [docRow, tableData.rows]); + const sortIndicator = (column: string) => { + if (sortBy !== column) { + return ""; + } + return sortDir === "desc" ? " ↓" : " ↑"; + }; - const activeMediaRow = useMemo(() => { - if (!mediaRow) return null; - return tableData.rows.find((row) => row.id === mediaRow.id) ?? mediaRow; - }, [mediaRow, tableData.rows]); - - const handleOpenSlideOver = (row: WorkOrderTableRow, edit = false) => { - setSlideOverRow(row); - setSlideOverEdit(edit); + const handleStatusFilter = (status: string) => { + setStatusFilter((current) => (current === status ? "" : status)); + setPage(0); }; return ( - - filters.setAdvOpen(true)} - onNewWorkOrder={() => setWizardOpen(true)} - activeFilterCount={filters.activeFilterCount} - isFetching={tableData.isFetching && !tableData.isLoading} - /> - } - /> - - - - {filters.advApplied && ( - - )} - - filters.handleSearchChange("")} - onOpenAdvancedFilters={() => filters.setAdvOpen(true)} - /> - - - - - - { - setSlideOverRow(null); - setSlideOverEdit(false); - tableData.setSelectedId(null); + + + + + 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: WorkOrderTableRow) => ( + + + + + {row.title} + {row.location} + {row.priority} + + + + + {formatDateTime(row.scheduledOn)} + + {row.tech || "Unassigned"} + {formatDateTime(row.dueDate)} + + + 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]} /> - - {activeMediaRow && ( - { - if (!open) setMediaRow(null); - }} - uploads={mediaFiles[String(activeMediaRow.id)] ?? EMPTY_MEDIA} - onChange={(files) => { - setMediaFiles((prev) => ({ ...prev, [String(activeMediaRow.id)]: files })); - patchField(activeMediaRow.id, { mediaCount: files.length }); - }} - /> - )} - - {activeDocRow && ( - { - if (!open) setDocRow(null); - }} - data={completionDocs[String(activeDocRow.id)]} - onSave={(data, status) => { - setCompletionDocs((prev) => ({ ...prev, [String(activeDocRow.id)]: data })); - patchField(activeDocRow.id, { docStatus: status }); - }} - onStatusChange={(status) => patchField(activeDocRow.id, { status })} - onDateCompletedChange={(date) => patchField(activeDocRow.id, { scheduledOn: date })} - /> - )}
); }