diff --git a/src/app/(protected)/workorders/[id].tsx b/src/app/(protected)/workorders/[id].tsx index 55a32726..9deeb752 100644 --- a/src/app/(protected)/workorders/[id].tsx +++ b/src/app/(protected)/workorders/[id].tsx @@ -1,36 +1,20 @@ -import { useMemo, useState } from "react"; -import { Link as RouterLink, useNavigate, useParams } from "react-router"; +import { Link as RouterLink, useNavigate } 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 ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import { useState } from "react"; +import { useParams } from "react-router"; 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 { WorkOrderDetailsSection } from "./_components/detail/work-order-detail-sections"; +import { WorkOrderStatusMenu } from "./_components/detail/work-order-status-menu"; import { useWorkOrderDetail } from "@/domain/work-orders/use-cases/use-work-order-detail"; import { useAddWorkOrderComment, @@ -38,164 +22,20 @@ 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, refetch } = useWorkOrderDetail(workOrderId); + const { data: wo, isLoading, error } = 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 ( @@ -221,14 +61,9 @@ export default function WorkOrderViewPage() { Work Orders - - {wo.internalWONumber} - + {wo.internalWONumber} + {wo.title} - setStatusAnchor(event.currentTarget)} - className="w-fit cursor-pointer" + statusMutation.mutate(status)} /> - setStatusAnchor(null)} - > - {WORK_ORDER_STATUS_OPTIONS.map((status) => ( - { - setStatusAnchor(null); - if (status !== wo.status) { - statusMutation.mutate(status); - } - }} - > - {status} - - ))} - - + setDispatchOpen(true)}> Dispatch @@ -287,283 +94,27 @@ export default function WorkOrderViewPage() { - - - Details - - - - - - - - - Assigned To - - setAssignAnchor(event.currentTarget)}> - {wo.assignedTo || "Unassigned"} - - 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" && } + assignmentMutation.mutate({ userId, userName })} + onDispatchClick={setDispatchDetailId} + onCommentSubmit={(text, commentType) => commentMutation.mutate({ text, commentType })} + isCommentPending={commentMutation.isPending} + /> - {activeTab === "internal" && ( - - setCommentText(event.target.value)} - placeholder="Type a comment..." - disabled={commentMutation.isPending} - /> - - {commentMutation.isPending ? "Sending..." : "Send"} - - - )} - {dispatchOpen && ( - setDispatchOpen(false)} - onDispatched={() => void refetch()} - /> + setDispatchOpen(false)} /> )} + {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/list/filter-bar/dispatcher-filter.tsx b/src/app/(protected)/workorders/_components/list/filter-bar/dispatcher-filter.tsx new file mode 100644 index 00000000..9e85a9ac --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/filter-bar/dispatcher-filter.tsx @@ -0,0 +1,168 @@ +import { useMemo } from "react"; +import { Popover } from "@mui/material"; +import { ChevronDown, UserX, Users } from "lucide-react"; +import { ASSIGNEE_FILTER_UNASSIGNED } from "@/domain/work-orders/types/work-order-list"; +import type { WorkOrderUser } from "@/domain/work-orders/types/work-order"; +import { + MOCK_DISPATCHER_USERS, + getDispatcherAvatarColor, +} from "@/domain/work-orders/mocks/dispatcher-filter-mock-data"; +import { CheckboxMenuItem } from "@/components/ui/checkbox-menu-item"; +import { MenuAnchorButton } from "@/components/ui/menu-anchor-button"; +import { useMenuAnchor } from "@/hooks/use-menu-anchor"; + +type DispatcherFilterProps = { + selectedIds: Set; + onChange: (ids: Set) => void; + users: WorkOrderUser[]; + currentUserId?: string | number; +}; + +function userInitials(name: string): string { + const parts = name.trim().split(/\s+/).filter(Boolean); + if (parts.length === 0) return "?"; + if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); + return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase(); +} + +function DispatcherAvatar({ name, color }: { name: string; color: string }) { + return ( + + {userInitials(name)} + + ); +} + +export function DispatcherFilter({ + selectedIds, + onChange, + users, + currentUserId, +}: DispatcherFilterProps) { + const { anchorEl, open, setAnchor, close } = useMenuAnchor(); + + const meId = currentUserId != null && currentUserId !== "" ? String(currentUserId) : null; + const allSelected = selectedIds.size === 0; + const meOnly = meId != null && selectedIds.size === 1 && selectedIds.has(meId); + const unassignedOnly = selectedIds.size === 1 && selectedIds.has(ASSIGNEE_FILTER_UNASSIGNED); + + const label = useMemo(() => { + if (allSelected) return "All dispatchers"; + if (meOnly) return "My WOs"; + if (unassignedOnly) return "Unassigned"; + return `${selectedIds.size} selected`; + }, [allSelected, meOnly, unassignedOnly, selectedIds.size]); + + const dispatchers = useMemo(() => { + const byId = new Map(); + for (const user of [...MOCK_DISPATCHER_USERS, ...users]) { + byId.set(String(user.id), user); + } + return [...byId.values()].sort((a, b) => a.name.localeCompare(b.name)); + }, [users]); + + const toggle = (id: string) => { + const next = new Set(selectedIds); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + onChange(next); + }; + + return ( + <> + + + {label} + + + + + onChange(new Set())} + /> + + {meId && ( + onChange(new Set([meId]))} + /> + )} + + } + className="mb-1 pb-2" + style={{ borderBottom: "1px solid var(--color-border)" }} + onClick={() => toggle(ASSIGNEE_FILTER_UNASSIGNED)} + /> + + {dispatchers.map((user) => { + const id = String(user.id); + const isMe = meId === id; + return ( + } + right={ + isMe ? me : undefined + } + onClick={() => toggle(id)} + /> + ); + })} + + > + ); +} diff --git a/src/app/(protected)/workorders/_components/list/filter-bar/week-nav.tsx b/src/app/(protected)/workorders/_components/list/filter-bar/week-nav.tsx new file mode 100644 index 00000000..a99e3a02 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/filter-bar/week-nav.tsx @@ -0,0 +1,131 @@ +import { useEffect, useMemo, useState } from "react"; + +import { ChevronLeft, ChevronRight } from "lucide-react"; + +import { NavArrowButton } from "@/components/ui/nav-arrow-button"; + +import { NavPillButton } from "@/components/ui/nav-pill-button"; + +import { + buildWeekWindow, + isFutureWeek, + isLiveWeek, +} from "@/domain/work-orders/utils/list-week-utils"; + +type WeekNavProps = { + weekMonday: string; + + onChange: (mondayIso: string) => void; +}; + +const VISIBLE_COUNT = 3; + +const WINDOW_BEFORE = 12; + +const WINDOW_AFTER = 1; + +function WeekLiveBadge() { + return ( + + Live + + ); +} + +export function WeekNav({ weekMonday, onChange }: WeekNavProps) { + const weeks = useMemo( + () => buildWeekWindow(weekMonday, WINDOW_BEFORE, WINDOW_AFTER), + + [weekMonday], + ); + + const activeIndex = useMemo( + () => weeks.findIndex((week) => week.mondayIso === weekMonday), + + [weeks, weekMonday], + ); + + const [offset, setOffset] = useState(() => Math.max(0, activeIndex >= 0 ? activeIndex - 1 : 0)); + + useEffect(() => { + if (activeIndex < 0) return; + + const maxOffset = Math.max(0, weeks.length - VISIBLE_COUNT); + + setOffset((current) => { + if (activeIndex < current) return Math.min(activeIndex, maxOffset); + + if (activeIndex > current + VISIBLE_COUNT - 1) { + return Math.min(Math.max(0, activeIndex - (VISIBLE_COUNT - 1)), maxOffset); + } + + return current; + }); + }, [activeIndex, weeks.length]); + + const visible = weeks.slice(offset, offset + VISIBLE_COUNT); + + const canGoBack = offset > 0; + + const canGoForward = offset + VISIBLE_COUNT < weeks.length; + + return ( + + setOffset((current) => current - 1)} + aria-label="Previous weeks" + > + + + + {visible.map((week) => { + const isActive = weekMonday === week.mondayIso; + + const isDisabled = isFutureWeek(week.mondayIso); + + const showLiveBadge = isLiveWeek(week.mondayIso); + + return ( + onChange(week.mondayIso)} + > + {week.label} + + {showLiveBadge && } + + ); + })} + + setOffset((current) => current + 1)} + aria-label="Next weeks" + > + + + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/filter-bar/wo-type-segment.tsx b/src/app/(protected)/workorders/_components/list/filter-bar/wo-type-segment.tsx new file mode 100644 index 00000000..3810f8ec --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/filter-bar/wo-type-segment.tsx @@ -0,0 +1,25 @@ +import { SegmentButton, SegmentGroup } from "@/components/ui/segment-control"; + +import { WO_TYPES } from "@/domain/work-orders/types/work-order-wizard"; + +import type { WorkOrderTypeFilter } from "@/domain/work-orders/types/work-order-list-filters"; + +type WoTypeSegmentProps = { + value: WorkOrderTypeFilter; + + onChange: (value: WorkOrderTypeFilter) => void; +}; + +const OPTIONS: WorkOrderTypeFilter[] = ["All", ...WO_TYPES]; + +export function WoTypeSegment({ value, onChange }: WoTypeSegmentProps) { + return ( + + {OPTIONS.map((option) => ( + onChange(option)}> + {option} + + ))} + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar.tsx b/src/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar.tsx new file mode 100644 index 00000000..4801105c --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar.tsx @@ -0,0 +1,48 @@ +import type { WorkOrderUser } from "@/domain/work-orders/types/work-order"; +import type { WorkOrderTypeFilter } from "@/domain/work-orders/types/work-order-list-filters"; +import { DispatcherFilter } from "@/app/(protected)/workorders/_components/list/filter-bar/dispatcher-filter"; +import { WeekNav } from "@/app/(protected)/workorders/_components/list/filter-bar/week-nav"; +import { WoTypeSegment } from "@/app/(protected)/workorders/_components/list/filter-bar/wo-type-segment"; + +type WorkOrdersFilterBarProps = { + weekMonday: string; + onWeekChange: (mondayIso: string) => void; + dispatcherIds: Set; + onDispatcherChange: (ids: Set) => void; + typeFilter: WorkOrderTypeFilter; + onTypeChange: (value: WorkOrderTypeFilter) => void; + users: WorkOrderUser[]; + currentUserId?: string | number; + filteredCount: number; + scopeTotalCount: number; +}; + +export function WorkOrdersFilterBar({ + weekMonday, + onWeekChange, + dispatcherIds, + onDispatcherChange, + typeFilter, + onTypeChange, + users, + currentUserId, + filteredCount, + scopeTotalCount, +}: WorkOrdersFilterBarProps) { + const resultLabel = `${filteredCount} of ${scopeTotalCount}`; + + return ( + + + + + + {resultLabel} + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner.tsx b/src/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner.tsx new file mode 100644 index 00000000..7b729bd1 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner.tsx @@ -0,0 +1,53 @@ +import { AppliedFilterChip } from "@/components/ui/applied-filter-chip"; +import { AppliedFiltersBar } from "@/components/ui/applied-filters-bar"; +import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters"; +import type { WorkOrderUser } from "@/domain/work-orders/types/work-order"; +import { + buildAdvancedFilterChips, + countAdvancedFilters, +} from "@/domain/work-orders/utils/advanced-filters-utils"; + +type WorkOrdersAdvancedFiltersBannerProps = { + applied: WorkOrderAdvancedFilters; + totalCount: number; + sites: { id: string | number; name: string }[]; + users: WorkOrderUser[]; + onClearChip: (chipKey: string) => void; + onClearAll: () => void; +}; + +export function WorkOrdersAdvancedFiltersBanner({ + applied, + totalCount, + sites, + users, + onClearChip, + onClearAll, +}: WorkOrdersAdvancedFiltersBannerProps) { + if (countAdvancedFilters(applied) === 0) { + return null; + } + + const siteNameOf = (id: string) => sites.find((site) => String(site.id) === id)?.name ?? id; + const dispatcherNameOf = (id: string) => users.find((user) => String(user.id) === id)?.name ?? id; + + const chips = buildAdvancedFilterChips(applied, { siteNameOf, dispatcherNameOf }); + + return ( + + {chips.map((chip) => ( + onClearChip(chip.key)} + /> + ))} + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/work-orders-list-header.tsx b/src/app/(protected)/workorders/_components/list/work-orders-list-header.tsx new file mode 100644 index 00000000..71dd3c7e --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/work-orders-list-header.tsx @@ -0,0 +1,63 @@ +import { CircularProgress } from "@mui/material"; + +import { Plus, SlidersHorizontal } from "lucide-react"; + +import { SearchInput } from "@/components/ui/search-input"; + +import { ToolbarButton } from "@/components/ui/toolbar-button"; + +type WorkOrdersListHeaderProps = { + search: string; + + onSearchChange: (value: string) => void; + + onOpenAdvancedFilters: () => void; + + onNewWorkOrder: () => void; + + activeFilterCount: number; + + isFetching?: boolean; +}; + +export function WorkOrdersListHeader({ + search, + + onSearchChange, + + onOpenAdvancedFilters, + + onNewWorkOrder, + + activeFilterCount, + + isFetching = false, +}: WorkOrdersListHeaderProps) { + return ( + <> + + + } + onClick={onOpenAdvancedFilters} + aria-label="Advanced Filters" + title="Advanced Filters" + badge={activeFilterCount} + > + Filters + + + } onClick={onNewWorkOrder}> + New WO + + + {isFetching && } + > + ); +} diff --git a/src/app/(protected)/workorders/_components/list/work-orders-table.tsx b/src/app/(protected)/workorders/_components/list/work-orders-table.tsx new file mode 100644 index 00000000..5579f5ca --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/work-orders-table.tsx @@ -0,0 +1,117 @@ +import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; +import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined"; +import { Button, IconButton, Tooltip } from "@mui/material"; +import { ResourceListTable, type ResourceListColumn } from "@/components/ui/resource-list-table"; +import { WorkOrderStatusBadge } from "@/components/domain/status-badge"; +import type { WorkOrderListItem } from "@/domain/work-orders/types/work-order"; +import type { SortDirection } from "@/domain/work-orders/types/work-order-list"; +import { formatDateTime } from "@/lib/time-utils"; + +function displayDateTime(value: string): string { + return formatDateTime(value) || "—"; +} + +type WorkOrdersTableProps = { + rows: WorkOrderListItem[]; + isLoading: boolean; + sort: { column: string; direction: SortDirection }; + onSort: (column: string) => void; + onView: (id: string | number) => void; + onEdit: (id: string | number) => void; +}; + +export function WorkOrdersTable({ + rows, + isLoading, + sort, + onSort, + onView, + onEdit, +}: WorkOrdersTableProps) { + const columns: ResourceListColumn[] = [ + { + id: "number", + header: "WO #", + sortable: true, + render: (row) => ( + onView(row.id)} + > + {row.number || row.id} + + ), + }, + { + id: "title", + header: "Title", + sortable: true, + render: (row) => row.title, + }, + { + id: "location", + header: "Location", + render: (row) => row.location, + }, + { + id: "priority", + header: "Priority", + render: (row) => row.priority, + }, + { + id: "status", + header: "Status", + render: (row) => , + }, + { + id: "createdDate", + header: "Created", + cellClassName: "whitespace-nowrap", + render: (row) => displayDateTime(row.createdDate), + }, + { + id: "assignedTo", + header: "Assigned To", + render: (row) => row.assignedTo || "Unassigned", + }, + { + id: "lastUpdated", + header: "Last Updated", + cellClassName: "whitespace-nowrap", + render: (row) => displayDateTime(row.lastUpdated), + }, + { + id: "actions", + header: "Actions", + align: "right", + render: (row) => ( + <> + + onView(row.id)}> + + + + + onEdit(row.id)}> + + + + > + ), + }, + ]; + + return ( + String(row.id)} + isLoading={isLoading} + emptyMessage="No work orders found" + sort={sort} + onSort={onSort} + /> + ); +} diff --git a/src/app/(protected)/workorders/_components/media/media-upload-modal.tsx b/src/app/(protected)/workorders/_components/media/media-upload-modal.tsx new file mode 100644 index 00000000..921e7d21 --- /dev/null +++ b/src/app/(protected)/workorders/_components/media/media-upload-modal.tsx @@ -0,0 +1,65 @@ +import { type MouseEvent } from "react"; +import { Dialog, DialogContent, DialogTitle } from "@mui/material"; +import { Image as ImageIcon } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop"; +import type { MediaFile } from "@/domain/work-orders/types/work-order-media"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { MediaUploader } from "./media-uploader"; + +type MediaUploadModalProps = { + row: WorkOrderTableRow; + open: boolean; + onOpenChange: (open: boolean) => void; + uploads: MediaFile[]; + onChange: (next: MediaFile[]) => void; +}; + +export function MediaUploadModal({ + row, + open, + onOpenChange, + uploads, + onChange, +}: MediaUploadModalProps) { + return ( + onOpenChange(false)} + maxWidth={false} + slotProps={{ + ...dialogBlurBackdropSlotProps, + paper: { + style: { borderRadius: 12, maxWidth: 520, width: "100%" }, + onClick: (e: MouseEvent) => e.stopPropagation(), + }, + }} + > + e.stopPropagation()}> + + + + Photos & Videos + + #{row.woNumber} + + Upload photos and videos for this work order. + + + + + + + onOpenChange(false)} + > + Close + + + + + ); +} diff --git a/src/app/(protected)/workorders/_components/media/media-uploader.tsx b/src/app/(protected)/workorders/_components/media/media-uploader.tsx new file mode 100644 index 00000000..bd7fcc9a --- /dev/null +++ b/src/app/(protected)/workorders/_components/media/media-uploader.tsx @@ -0,0 +1,143 @@ +import { useRef } from "react"; +import { + File as FileIcon, + FileText, + Film, + Image as ImageIcon, + Paperclip, + Play, + Upload, + X, +} from "lucide-react"; +import type { FileCategory, MediaFile } from "@/domain/work-orders/types/work-order-media"; +import { makeMediaFile } from "@/domain/work-orders/utils/work-order-media-utils"; + +const FILETYPE_ICON: Record = { + image: ImageIcon, + video: Film, + pdf: FileText, + doc: FileIcon, + other: Paperclip, +}; + +type MediaUploaderProps = { + uploads: MediaFile[]; + onChange: (next: MediaFile[]) => void; +}; + +export function MediaUploader({ uploads, onChange }: MediaUploaderProps) { + const inputRef = useRef(null); + + const addFiles = (files: FileList | null) => { + if (!files || files.length === 0) return; + onChange([...uploads, ...Array.from(files).map(makeMediaFile)]); + }; + + const remove = (id: string) => onChange(uploads.filter((u) => u.id !== id)); + + return ( + + inputRef.current?.click()} + onDragOver={(e) => e.preventDefault()} + onDrop={(e) => { + e.preventDefault(); + addFiles(e.dataTransfer.files); + }} + className="flex w-full flex-col items-center justify-center gap-1.5 transition-colors hover:bg-[var(--color-bg-muted)]" + style={{ + minHeight: 120, + border: "1px dashed var(--color-border)", + borderRadius: 8, + }} + > + + + Drag files here or click to browse + + + Accepted: JPG, PNG, MP4, MOV · Max 50MB per file + + + addFiles(e.target.files)} + /> + {uploads.length > 0 && ( + + {uploads.map((u) => { + const TypeIcon = FILETYPE_ICON[u.fileType]; + return ( + + + {u.fileType === "image" ? ( + + ) : u.fileType === "video" ? ( + <> + + + + + > + ) : ( + + + + )} + remove(u.id)} + title="Remove" + className="absolute flex items-center justify-center transition-opacity hover:opacity-80" + style={{ + top: 4, + right: 4, + width: 18, + height: 18, + borderRadius: 4, + background: "var(--color-bg-surface)", + border: "1px solid var(--color-border)", + color: "var(--color-text-muted)", + }} + > + + + + + + {u.name} + + + + ); + })} + + )} + + ); +} diff --git a/src/app/(protected)/workorders/_hooks/use-work-orders-list-filters.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-list-filters.ts new file mode 100644 index 00000000..a02cce3c --- /dev/null +++ b/src/app/(protected)/workorders/_hooks/use-work-orders-list-filters.ts @@ -0,0 +1,191 @@ +import { useMemo, useState } from "react"; +import { useDebounce } from "@/hooks/use-debounce"; +import { isWoTableMockEnabled } from "@/domain/work-orders/mocks/is-wo-table-mock-enabled"; +import type { WorkOrdersListParams } from "@/domain/work-orders/types/work-order-list"; +import { WORK_ORDERS_TABLE_PAGE_SIZE } from "@/domain/work-orders/types/work-order-table-row"; +import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters"; +import type { + WorkOrderBarFilters, + WorkOrderTypeFilter, +} from "@/domain/work-orders/types/work-order-list-filters"; +import { + clearAdvancedFilterGroup, + cloneAdvancedFilters, + countAdvancedFilters, + getInitialAdvancedFilters, +} from "@/domain/work-orders/utils/advanced-filters-utils"; +import { + compactWeekLabel, + currentWeekMonday, + weekRange, +} from "@/domain/work-orders/utils/list-week-utils"; + +function defaultDispatcherIds(currentUserId?: string | number): Set { + if (isWoTableMockEnabled()) { + return new Set(); + } + if (currentUserId != null && currentUserId !== "") { + return new Set([String(currentUserId)]); + } + return new Set(); +} + +/** Phase 2: map bar filters to API list params. */ +export function toListParamsFromBar( + bar: WorkOrderBarFilters, +): Pick { + const [dateFrom, dateTo] = weekRange(bar.weekMonday); + return { + dateFrom, + dateTo, + type: bar.type === "All" ? undefined : bar.type, + assigneeIds: bar.dispatcherIds.size > 0 ? [...bar.dispatcherIds].join(",") : undefined, + }; +} + +export function useWorkOrdersListFilters(currentUserId?: string | number) { + const [search, setSearch] = useState(""); + const [advOpen, setAdvOpen] = useState(false); + const [advApplied, setAdvApplied] = useState(null); + const [page, setPage] = useState(0); + const [pageSize, setPageSize] = useState(12); + const [sortBy, setSortBy] = useState(""); + const [sortDir, setSortDir] = useState<"asc" | "desc" | "">(""); + const [weekMonday, setWeekMondayState] = useState(currentWeekMonday); + const [dispatcherIds, setDispatcherIdsState] = useState>(() => + defaultDispatcherIds(currentUserId), + ); + const [typeFilter, setTypeFilterState] = useState("All"); + + const debouncedSearch = useDebounce(search, 300); + + const barFilters = useMemo( + () => ({ + weekMonday, + dispatcherIds, + type: typeFilter, + }), + [weekMonday, dispatcherIds, typeFilter], + ); + + const weekLabel = useMemo(() => compactWeekLabel(weekMonday), [weekMonday]); + + const scopeListParams = useMemo( + () => ({ + page: 1, + pageSize: 1, + ...toListParamsFromBar(barFilters), + }), + [barFilters], + ); + + const listParams = useMemo( + () => ({ + search: debouncedSearch, + page: 1, + pageSize: WORK_ORDERS_TABLE_PAGE_SIZE, + sortBy, + sortDir, + ...toListParamsFromBar(barFilters), + }), + [debouncedSearch, sortBy, sortDir, barFilters], + ); + + const activeFilterCount = useMemo( + () => (advApplied ? countAdvancedFilters(advApplied) : 0), + [advApplied], + ); + + const setWeekMonday = (value: string) => { + setWeekMondayState(value); + setPage(0); + }; + + const setDispatcherIds = (value: Set) => { + setDispatcherIdsState(new Set(value)); + setPage(0); + }; + + const setTypeFilter = (value: WorkOrderTypeFilter) => { + setTypeFilterState(value); + setPage(0); + }; + + const handleSearchChange = (value: string) => { + setSearch(value); + setPage(0); + }; + + const applyAdvancedFilters = (filters: WorkOrderAdvancedFilters) => { + setAdvApplied(cloneAdvancedFilters(filters)); + setPage(0); + }; + + const clearAdvancedFilters = () => { + setAdvApplied(null); + setPage(0); + }; + + const clearAdvancedFilterChip = (chipKey: string) => { + if (!advApplied) return; + const next = clearAdvancedFilterGroup(advApplied, chipKey); + setAdvApplied(next); + setPage(0); + }; + + const handleSort = (column: string) => { + if (sortBy === column) { + setSortDir((current) => { + const next = current === "asc" ? "desc" : current === "desc" ? "" : "asc"; + if (next === "") { + setSortBy(""); + } + return next; + }); + return; + } + + setSortBy(column); + setSortDir("asc"); + }; + + const sortIndicator = (column: string) => { + if (sortBy !== column) { + return ""; + } + return sortDir === "desc" ? " ↓" : " ↑"; + }; + + return { + search, + advOpen, + advApplied, + advInitial: getInitialAdvancedFilters(advApplied), + page, + pageSize, + sortBy, + sortDir, + weekMonday, + dispatcherIds, + typeFilter, + barFilters, + weekLabel, + listParams, + scopeListParams, + activeFilterCount, + handleSearchChange, + applyAdvancedFilters, + clearAdvancedFilters, + clearAdvancedFilterChip, + handleSort, + sortIndicator, + setAdvOpen, + setPage, + setPageSize, + setWeekMonday, + setDispatcherIds, + setTypeFilter, + setSortBy, + setSortDir, + }; +} diff --git a/src/app/(protected)/workorders/index.tsx b/src/app/(protected)/workorders/index.tsx index c33f1a2e..98de7ec7 100644 --- a/src/app/(protected)/workorders/index.tsx +++ b/src/app/(protected)/workorders/index.tsx @@ -1,339 +1,191 @@ import { useMemo, useState } from "react"; -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 { 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 { - 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(); -} + 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"; 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 { 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 debouncedSearch = useDebounce(search, 300); + const { data: users = [] } = useWorkOrderUsers(); + const { data: locations = [] } = useWorkOrderLocations(); + const { data: vendors = [] } = useWorkOrderVendors(); + const { data: problemOptions = [] } = useDropdownOptionsByCategory("Problem"); - const listParams = useMemo( - () => ({ - search: debouncedSearch, - page: page + 1, - pageSize, - sortBy, - sortDir, - status: statusFilter, - assignee: assigneeFilter, - }), - [debouncedSearch, page, pageSize, sortBy, sortDir, statusFilter, assigneeFilter], + const pmTypes = useMemo( + () => + isWoTableMockEnabled() + ? getMockPmTypes() + : problemOptions.map((option) => option.value).filter(Boolean), + [problemOptions], ); - const { data, isLoading, isFetching, error } = useWorkOrdersList(listParams); - const { data: users = [] } = useWorkOrderUsers(); + const vendorTechs = useMemo( + () => [...new Set(vendors.map((vendor) => vendor.companyName).filter(Boolean))], + [vendors], + ); - const rows = data?.items ?? []; - const totalCount = data?.totalCount ?? 0; + const totalCount = tableData.totalCount; + const scopeTotalCount = tableData.scopeTotalCount; - const handleSort = (column: string) => { - if (sortBy === column) { - setSortDir((current) => (current === "asc" ? "desc" : current === "desc" ? "" : "asc")); - if (sortDir === "desc") { - setSortBy(""); - } - return; - } + const activeDocRow = useMemo(() => { + if (!docRow) return null; + return tableData.rows.find((row) => row.id === docRow.id) ?? docRow; + }, [docRow, tableData.rows]); - setSortBy(column); - setSortDir("asc"); - }; + const activeMediaRow = useMemo(() => { + if (!mediaRow) return null; + return tableData.rows.find((row) => row.id === mediaRow.id) ?? mediaRow; + }, [mediaRow, tableData.rows]); - const sortIndicator = (column: string) => { - if (sortBy !== column) { - return ""; - } - return sortDir === "desc" ? " ↓" : " ↑"; - }; - - const handleStatusFilter = (status: string) => { - setStatusFilter((current) => (current === status ? "" : status)); - setPage(0); + const handleOpenSlideOver = (row: WorkOrderTableRow, edit = false) => { + setSlideOverRow(row); + setSlideOverEdit(edit); }; return ( - - - - - Work Orders - - - {totalCount} total - - {isFetching && !isLoading && } - - - - { - setSearch(event.target.value); - setPage(0); - }} - placeholder="Search..." - className="min-w-[220px]" + + filters.setAdvOpen(true)} + onNewWorkOrder={() => setWizardOpen(true)} + activeFilterCount={filters.activeFilterCount} + isFetching={tableData.isFetching && !tableData.isLoading} /> - navigate("/workorders/new")}> - + New Work Order - - - - + + + + {filters.advApplied && ( + + )} + + filters.handleSearchChange("")} + onOpenAdvancedFilters={() => filters.setAdvOpen(true)} + /> + + + + + + { + setSlideOverRow(null); + setSlideOverEdit(false); + tableData.setSelectedId(null); }} - > - { - setStatusFilter(""); - setPage(0); + /> + + {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 }); }} /> - {STATUS_OPTIONS.map((status) => ( - handleStatusFilter(status)} - /> - ))} - - - Assignee - { - setAssigneeFilter(event.target.value); - setPage(0); - }} - > - All Assignees - Unassigned - {users.map((user) => ( - - {user.name} - - ))} - - - - {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) => ( - - - navigate(`/workorders/${row.id}`)} - > - {row.number || row.id} - - - {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]} - /> + + {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 })} + /> + )} ); }
Upload photos and videos for this work order.