From af0e4aa1a6d9f9a892ec7beb7ccb779d63aa9c0c Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Fri, 17 Jul 2026 12:03:50 -0300 Subject: [PATCH 01/11] feat(work-orders): add weekly board page shell and list chrome --- src/app/(protected)/workorders/[id].tsx | 493 +----------------- .../list/filter-bar/dispatcher-filter.tsx | 168 ++++++ .../_components/list/filter-bar/week-nav.tsx | 131 +++++ .../list/filter-bar/wo-type-segment.tsx | 25 + .../filter-bar/work-orders-filter-bar.tsx | 48 ++ .../work-orders-advanced-filters-banner.tsx | 53 ++ .../list/work-orders-list-header.tsx | 63 +++ .../_components/list/work-orders-table.tsx | 117 +++++ .../_components/media/media-upload-modal.tsx | 65 +++ .../_components/media/media-uploader.tsx | 143 +++++ .../_hooks/use-work-orders-list-filters.ts | 191 +++++++ src/app/(protected)/workorders/index.tsx | 484 ++++++----------- 12 files changed, 1194 insertions(+), 787 deletions(-) create mode 100644 src/app/(protected)/workorders/_components/list/filter-bar/dispatcher-filter.tsx create mode 100644 src/app/(protected)/workorders/_components/list/filter-bar/week-nav.tsx create mode 100644 src/app/(protected)/workorders/_components/list/filter-bar/wo-type-segment.tsx create mode 100644 src/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar.tsx create mode 100644 src/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner.tsx create mode 100644 src/app/(protected)/workorders/_components/list/work-orders-list-header.tsx create mode 100644 src/app/(protected)/workorders/_components/list/work-orders-table.tsx create mode 100644 src/app/(protected)/workorders/_components/media/media-upload-modal.tsx create mode 100644 src/app/(protected)/workorders/_components/media/media-uploader.tsx create mode 100644 src/app/(protected)/workorders/_hooks/use-work-orders-list-filters.ts 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} - - ))} - - + @@ -287,283 +94,27 @@ 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" - > - - - - - {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} - /> - - - )} - {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) => ( + + ), + }, + { + 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.

+ +
+ +
+ +
+ +
+
+
+ ); +} 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 ( +
+ + addFiles(e.target.files)} + /> + {uploads.length > 0 && ( +
+ {uploads.map((u) => { + const TypeIcon = FILETYPE_ICON[u.fileType]; + return ( +
+
+ {u.fileType === "image" ? ( + {u.name} + ) : u.fileType === "video" ? ( + <> +
+
+
+ {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} /> - - - - + + + + {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 - - - - {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]} - /> + + {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 })} + /> + )}
); } From 1cb8362d769a2b6db07d7fc2955f5f94702e42b8 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Fri, 17 Jul 2026 12:04:33 -0300 Subject: [PATCH 02/11] feat(work-orders): add board table core and filters --- .../_components/list/table/pm-type-icons.ts | 45 +++ .../_components/list/table/wo-table-cols.ts | 19 + .../_components/list/table/wo-table-head.tsx | 31 ++ .../list/table/wo-table-inline-row.tsx | 288 +++++++++++++++ .../list/table/wo-table-row-actions.tsx | 67 ++++ .../_components/list/table/wo-table-row.tsx | 280 +++++++++++++++ .../list/table/wo-table-section-header.tsx | 144 ++++++++ .../list/table/wo-table-status-style.ts | 23 ++ .../list/table/wo-table-type-style.ts | 14 + .../_components/list/table/wo-table-types.ts | 3 + .../_components/list/table/wo-table.tsx | 329 ++++++++++++++++++ .../workorders/_hooks/use-table-sort.ts | 58 +++ .../_hooks/use-work-orders-table-data.ts | 222 ++++++++++++ 13 files changed, 1523 insertions(+) create mode 100644 src/app/(protected)/workorders/_components/list/table/pm-type-icons.ts create mode 100644 src/app/(protected)/workorders/_components/list/table/wo-table-cols.ts create mode 100644 src/app/(protected)/workorders/_components/list/table/wo-table-head.tsx create mode 100644 src/app/(protected)/workorders/_components/list/table/wo-table-inline-row.tsx create mode 100644 src/app/(protected)/workorders/_components/list/table/wo-table-row-actions.tsx create mode 100644 src/app/(protected)/workorders/_components/list/table/wo-table-row.tsx create mode 100644 src/app/(protected)/workorders/_components/list/table/wo-table-section-header.tsx create mode 100644 src/app/(protected)/workorders/_components/list/table/wo-table-status-style.ts create mode 100644 src/app/(protected)/workorders/_components/list/table/wo-table-type-style.ts create mode 100644 src/app/(protected)/workorders/_components/list/table/wo-table-types.ts create mode 100644 src/app/(protected)/workorders/_components/list/table/wo-table.tsx create mode 100644 src/app/(protected)/workorders/_hooks/use-table-sort.ts create mode 100644 src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts diff --git a/src/app/(protected)/workorders/_components/list/table/pm-type-icons.ts b/src/app/(protected)/workorders/_components/list/table/pm-type-icons.ts new file mode 100644 index 00000000..50539aca --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/pm-type-icons.ts @@ -0,0 +1,45 @@ +import { createElement, type ComponentProps } from "react"; +import type { LucideIcon } from "lucide-react"; +import { + AlertTriangle, + Droplets, + Filter, + Gauge, + Lightbulb, + Lock, + Shield, + ShieldCheck, + Thermometer, + Video, + Waves, + Wind, + Wrench, + Zap, +} from "lucide-react"; + +export const PM_TYPE_ICONS: Record = { + "Scrubber Pit": Filter, + "Backflow Preventers": Shield, + "Domestic Backflow": ShieldCheck, + "Tank / Water Heater": Thermometer, + "Pressure Reducing Valve": Gauge, + "Pumping Systems": Waves, + "HVAC Inspection": Wind, + Electrical: Zap, + Plumbing: Wrench, + "Door / Hardware": Lock, + "Floor Drain Backing Up": Droplets, + "Parking Lot Lighting": Lightbulb, + "Mens Restroom Camera Line": Video, + "Assess Window Leaks": AlertTriangle, +}; + +export function getPmTypeIcon(pmType: string): LucideIcon { + return PM_TYPE_ICONS[pmType] ?? Wrench; +} + +type PmTypeIconProps = ComponentProps & { pmType: string }; + +export function PmTypeIcon({ pmType, ...props }: PmTypeIconProps) { + return createElement(getPmTypeIcon(pmType), props); +} diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-cols.ts b/src/app/(protected)/workorders/_components/list/table/wo-table-cols.ts new file mode 100644 index 00000000..837c8622 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-cols.ts @@ -0,0 +1,19 @@ +export const WO_TABLE_COLS = [ + { key: "grip", label: "", w: 28 }, + { key: "flag", label: "", w: 28 }, + { key: "site", label: "SITE", w: 168 }, + { key: "wo", label: "WO", w: 120 }, + { key: "type", label: "TYPE OF WO", w: 108 }, + { key: "assigned", label: "ASSIGNED TO", w: 136 }, + { key: "sched", label: "SCHEDULE ON", w: 112 }, + { key: "due", label: "DUE DATE", w: 112 }, + { key: "pm", label: "SERVICE", w: 168 }, + { key: "vendor", label: "VENDOR", w: 184 }, + { key: "appt", label: "APPT TIME", w: 128 }, + { key: "status", label: "STATUS", w: 148 }, + { key: "doc", label: "COMP DOC", w: 84 }, + { key: "actions", label: "", w: 100 }, +] as const; + +export const WO_TABLE_MIN_WIDTH = 1504; +export const WO_TABLE_ROW_HEIGHT = 52; diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-head.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-head.tsx new file mode 100644 index 00000000..36309923 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-head.tsx @@ -0,0 +1,31 @@ +import { WO_TABLE_COLS } from "./wo-table-cols"; + +export function WoTableHead() { + return ( + + + {WO_TABLE_COLS.map((col) => ( + + {col.label} + + ))} + + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row.tsx new file mode 100644 index 00000000..3340e24a --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row.tsx @@ -0,0 +1,288 @@ +import { Check, Flag, X } from "lucide-react"; + +import type { + LocationOption, + VendorDropdownItem, + WorkOrderUser, +} from "@/domain/work-orders/types/work-order"; + +import type { WorkOrderTableDraft } from "@/domain/work-orders/types/work-order-table-row"; + +import { UNSCHEDULED_DAY_KEY } from "@/domain/work-orders/types/work-order-table-row"; + +import { AssignedToCell } from "./cells/assigned-to-cell"; + +import { ApptTimeCell } from "./cells/appt-time-cell"; + +import { DateCell } from "./cells/date-cell"; + +import { EditableWONumberCell } from "./cells/editable-wo-number-cell"; + +import { PMTypeCell } from "./cells/pm-type-cell"; + +import { SiteCell } from "./cells/site-cell"; + +import { StatusCell } from "./cells/status-cell"; + +import { TypeCell } from "./cells/type-cell"; + +import { VendorCell } from "./cells/vendor-cell"; + +import { WO_TABLE_ROW_HEIGHT } from "./wo-table-cols"; + +type WoTableInlineRowProps = { + draft: WorkOrderTableDraft; + + draftDayKey: string; + + users: WorkOrderUser[]; + + sites: LocationOption[]; + + vendors: VendorDropdownItem[]; + + pmTypes: string[]; + + allRows: { id: string | number; woNumber: string }[]; + + onChange: (draft: WorkOrderTableDraft) => void; + + onSave: () => void; + + onCancel: () => void; +}; + +export function WoTableInlineRow({ + draft, + + draftDayKey, + + users, + + sites, + + vendors, + + pmTypes, + + allRows, + + onChange, + + onSave, + + onCancel, +}: WoTableInlineRowProps) { + const weekDefault = draftDayKey === UNSCHEDULED_DAY_KEY; + + const canSave = weekDefault ? !!draft.targetWeek || !!draft.scheduledOn : !!draft.scheduledOn; + + const patch = (p: Partial) => onChange({ ...draft, ...p }); + + const draftRow = { + id: "draft", + + woNumber: draft.woNumber, + + status: draft.status, + + isPastDue: false, + + type: draft.type, + + rescheduleCount: 0, + + carriedOver: 0, + + docStatus: "" as const, + + mediaCount: 0, + + site: draft.site, + + location: draft.location, + + dispatcherId: draft.dispatcherId, + + scheduledOn: draft.scheduledOn, + + dueDate: draft.dueDate, + + pm: draft.pm, + + company: draft.company, + + tech: draft.tech, + + targetWeek: draft.targetWeek, + }; + + return ( + + + + + + + + + + patch({ + site: p.site, + + location: p.location, + + locationId: p.locationId, + + pocName: p.pocName, + + pocPhone: p.pocPhone, + + pocNotes: p.pocNotes, + }) + } + /> + + + + patch({ woNumber: v })} + /> + + + + patch({ type: t })} /> + + + + { + patch({ dispatcherId: id, status: draft.status || "Incomplete" }); + }} + /> + + + + patch({ scheduledOn: date, scheduleWeekOnly: false, targetWeek: "" })} + onSaveWeek={(week) => + patch({ scheduledOn: "", targetWeek: week, scheduleWeekOnly: true }) + } + /> + + + + patch({ dueDate: date })} /> + + + + patch({ pm })} /> + + + + patch({ company: p.company, tech: p.tech, techPhone: p.techPhone })} + /> + + + + { + const [start, end] = v.split(" – "); + + patch({ apptStart: start ?? "", apptEnd: end ?? "" }); + }} + /> + + + + patch({ status })} + onOpenSlideOver={() => {}} + /> + + + + — + + + +
+ + + +
+ + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-row-actions.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-row-actions.tsx new file mode 100644 index 00000000..c6ceaf10 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-row-actions.tsx @@ -0,0 +1,67 @@ +import { Eye, Pencil } from "lucide-react"; + +type WoTableRowActionsProps = { + onView: () => void; + + onEdit: () => void; +}; + +export function WoTableRowActions({ onView, onEdit }: WoTableRowActionsProps) { + const mk = ( + Icon: React.ElementType, + + title: string, + + onClick: () => void, + + hoverBg: string, + + hoverColor: string, + ) => ( + + ); + + return ( +
+ {mk(Eye, "View details", onView, "#e6f4fb", "var(--primary)")} + + {mk(Pencil, "Edit", onEdit, "#e6f4fb", "var(--primary)")} +
+ ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-row.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-row.tsx new file mode 100644 index 00000000..f3210b61 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-row.tsx @@ -0,0 +1,280 @@ +import { GripVertical } from "lucide-react"; +import type { + LocationOption, + VendorDropdownItem, + WorkOrderUser, +} from "@/domain/work-orders/types/work-order"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import type { DragVizState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-data"; +import { + applySchedulePatch, + maybeAutoSchedulePatch, +} from "@/domain/work-orders/use-cases/use-work-order-table-mutations"; +import { AssignedToCell } from "./cells/assigned-to-cell"; +import { ApptTimeCell } from "./cells/appt-time-cell"; +import { CompDocCell } from "./cells/comp-doc-cell"; +import { DateCell } from "./cells/date-cell"; +import { EditableWONumberCell } from "./cells/editable-wo-number-cell"; +import { FlagCell } from "./cells/flag-cell"; +import { PMTypeCell } from "./cells/pm-type-cell"; +import { SiteCell } from "./cells/site-cell"; +import { StatusCell } from "./cells/status-cell"; +import { TypeCell } from "./cells/type-cell"; +import { VendorCell } from "./cells/vendor-cell"; +import { WO_TABLE_ROW_HEIGHT } from "./wo-table-cols"; +import { WoTableRowActions } from "./wo-table-row-actions"; + +export type WoTableRowHandlers = { + onRowClick: (row: WorkOrderTableRow) => void; + onView: (row: WorkOrderTableRow) => void; + onEdit: (row: WorkOrderTableRow) => void; + onOpenCompDoc: (row: WorkOrderTableRow) => void; + onOpenMedia: (row: WorkOrderTableRow) => void; + onPatch: (id: string | number, patch: Partial) => void; + onDuplicateFound?: (existing: WorkOrderTableRow) => void; + onOpenSlideOver: (row: WorkOrderTableRow) => void; + onPastDueAttempt?: (id: string | number) => void; + onReorder: (dragId: string | number, overId: string | number) => void; + onDragStart: (id: string | number) => void; + onDragOver: (id: string | number) => void; + onDrop: () => void; + onDragEnd: () => void; + setFlag: (id: string | number, color: string | null) => void; +}; + +type WoTableRowProps = { + row: WorkOrderTableRow; + allRows: WorkOrderTableRow[]; + users: WorkOrderUser[]; + sites: LocationOption[]; + vendors: VendorDropdownItem[]; + pmTypes: string[]; + search?: string; + forceScheduleHighlight?: boolean; + flagColor?: string; + isSelected: boolean; + isFlashing: boolean; + dragViz: DragVizState; + handlers: WoTableRowHandlers; +}; + +export function WoTableRow({ + row, + allRows, + users, + sites, + vendors, + pmTypes, + search, + forceScheduleHighlight, + flagColor, + isSelected, + isFlashing, + dragViz, + handlers, +}: WoTableRowProps) { + const isDragging = dragViz.dragId === row.id; + const isDropTarget = dragViz.overId === row.id && dragViz.dragId !== row.id; + const isCanceled = (row.status as string) === "Canceled"; + + let firstCellShadow = "none"; + if (row.type === "Emergency") { + firstCellShadow = "inset 3px 0 0 var(--destructive)"; + } else if (row.isPastDue) { + firstCellShadow = "inset 3px 0 0 var(--warning)"; + } + + const patchAndMaybeAuto = (patch: Partial) => { + const merged = { ...row, ...patch }; + const auto = maybeAutoSchedulePatch(merged); + handlers.onPatch(row.id, { ...patch, ...auto }); + }; + + return ( + handlers.onRowClick(row)} + onDragStart={() => handlers.onDragStart(row.id)} + onDragOver={(e) => { + e.preventDefault(); + handlers.onDragOver(row.id); + }} + onDrop={(e) => { + e.preventDefault(); + handlers.onDrop(); + }} + onDragEnd={handlers.onDragEnd} + className="group/row cursor-pointer transition-colors" + style={{ + height: WO_TABLE_ROW_HEIGHT, + borderBottom: "1px solid var(--border)", + borderTop: isDropTarget ? "2px solid var(--color-primary)" : undefined, + opacity: isCanceled ? 0.45 : isDragging ? 0.4 : 1, + pointerEvents: isCanceled ? "none" : undefined, + background: isFlashing || isSelected ? "var(--color-primary-tint)" : "var(--card)", + outline: isFlashing ? "2px solid var(--color-primary)" : "none", + outlineOffset: isFlashing ? "-2px" : undefined, + borderRadius: isFlashing ? 4 : undefined, + transition: "background 1.8s ease-out, outline 1.8s ease-out", + }} + onMouseEnter={(e) => { + if (!isSelected && !isFlashing) { + (e.currentTarget as HTMLTableRowElement).style.background = "var(--color-primary-tint)"; + } + }} + onMouseLeave={(e) => { + if (!isSelected && !isFlashing) { + (e.currentTarget as HTMLTableRowElement).style.background = "var(--card)"; + } + }} + > + e.stopPropagation()} + > + + + + + e.stopPropagation()}> + handlers.setFlag(row.id, c)} /> + + e.stopPropagation()}> + + handlers.onPatch(row.id, { + site: p.site, + location: p.location, + locationId: p.locationId, + pocName: p.pocName, + pocPhone: p.pocPhone, + pocNotes: p.pocNotes, + }) + } + /> + + + handlers.onPatch(row.id, { woNumber: v })} + onDuplicateFound={handlers.onDuplicateFound} + /> + + e.stopPropagation()}> + handlers.onPatch(row.id, { type: t })} + /> + + e.stopPropagation()}> + patchAndMaybeAuto({ dispatcherId: id, dispatcherName: name })} + /> + + e.stopPropagation()}> + patchAndMaybeAuto(applySchedulePatch(date, false))} + onSaveWeek={(week) => + patchAndMaybeAuto({ + scheduledOn: "", + targetWeek: week, + scheduleWeekOnly: true, + dayGroup: "", + dayLabel: "", + }) + } + /> + + e.stopPropagation()}> + handlers.onPatch(row.id, { dueDate: date })} + /> + + e.stopPropagation()}> + handlers.onPatch(row.id, { pm })} + /> + + e.stopPropagation()}> + + handlers.onPatch(row.id, { + company: p.company, + tech: p.tech, + techPhone: p.techPhone, + }) + } + /> + + e.stopPropagation()}> + handlers.onPatch(row.id, { apptTime: v })} + /> + + e.stopPropagation()}> + handlers.onPatch(row.id, { status })} + onOpenSlideOver={() => handlers.onOpenSlideOver(row)} + onPastDueAttempt={() => handlers.onPastDueAttempt?.(row.id)} + /> + + e.stopPropagation()}> + handlers.onOpenCompDoc(row)} + onOpenMedia={() => handlers.onOpenMedia(row)} + /> + + e.stopPropagation()}> + handlers.onView(row)} + onEdit={() => handlers.onEdit(row)} + /> + + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-section-header.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-section-header.tsx new file mode 100644 index 00000000..18301594 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-section-header.tsx @@ -0,0 +1,144 @@ +import { AlertTriangle, ChevronDown, ChevronRight, Plus } from "lucide-react"; +import { WO_TABLE_COLS } from "./wo-table-cols"; + +type WoTableSectionHeaderProps = { + variant: "unscheduled" | "day"; + label: string; + count: number; + isToday?: boolean; + collapsed?: boolean; + onToggleCollapse?: () => void; + onAddWo: () => void; +}; + +export function WoTableSectionHeader({ + variant, + label, + count, + isToday, + collapsed, + onToggleCollapse, + onAddWo, +}: WoTableSectionHeaderProps) { + const isUnscheduled = variant === "unscheduled"; + + if (isUnscheduled) { + return ( + + +
+ + + {label} + + + {count} work order{count !== 1 ? "s" : ""} + + + + {collapsed ? : } + +
+ + + ); + } + + return ( + + +
+ + {label} + + + {count} WO{count !== 1 ? "s" : ""} + + {isToday && ( + + Today + + )} + +
+ + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-status-style.ts b/src/app/(protected)/workorders/_components/list/table/wo-table-status-style.ts new file mode 100644 index 00000000..f23d8fbd --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-status-style.ts @@ -0,0 +1,23 @@ +import type { StatusVariant } from "@/components/domain/status-badge"; +import { resolveStatusVariant } from "@/components/domain/status-badge-utils"; + +export type WoTableStatusStyle = { text: string; dot: string }; + +const STATUS_TABLE_STYLE: Record = { + incomplete: { text: "var(--status-incomplete-text)", dot: "#9CA3AF" }, + pending: { text: "var(--status-pending-text)", dot: "#D97706" }, + scheduled: { text: "var(--status-scheduled-text)", dot: "#3B82F6" }, + inroute: { text: "var(--status-inroute-text)", dot: "#0EA5E9" }, + onsite: { text: "var(--status-onsite-text)", dot: "#10B981" }, + inprogress: { text: "var(--status-inprogress-text)", dot: "#2563EB" }, + completed: { text: "var(--status-completed-text)", dot: "#16A34A" }, + rescheduled: { text: "var(--status-rescheduled-text)", dot: "#F59E0B" }, + canceled: { text: "var(--status-canceled-text)", dot: "#9CA3AF" }, + pendingquote: { text: "var(--status-pendingquote-text)", dot: "#9333EA" }, + pastdue: { text: "var(--status-pastdue-text)", dot: "#BE123C" }, +}; + +export function getWoTableStatusStyle(status: string): WoTableStatusStyle { + const variant = resolveStatusVariant(status) ?? "incomplete"; + return STATUS_TABLE_STYLE[variant]; +} diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-type-style.ts b/src/app/(protected)/workorders/_components/list/table/wo-table-type-style.ts new file mode 100644 index 00000000..23819e2a --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-type-style.ts @@ -0,0 +1,14 @@ +import type { WOType } from "@/domain/work-orders/types/work-order-wizard"; + +export const WO_TABLE_TYPE_TEXT: Record = { + Emergency: "var(--destructive)", + Reactive: "#e0701a", + PM: "var(--primary)", + "Add-On": "#7c3aed", + Overdue: "#9F1239", +}; + +export function getWoTableTypeColor(type: WOType | ""): string { + if (!type) return "var(--color-text-muted)"; + return WO_TABLE_TYPE_TEXT[type]; +} diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-types.ts b/src/app/(protected)/workorders/_components/list/table/wo-table-types.ts new file mode 100644 index 00000000..2bdf0152 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-types.ts @@ -0,0 +1,3 @@ +import type { useWorkOrdersTableData } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-data"; + +export type ReturnTypeOfUseWorkOrdersTableData = ReturnType; diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table.tsx new file mode 100644 index 00000000..f97c3071 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/wo-table.tsx @@ -0,0 +1,329 @@ +import { Fragment, useMemo, useState } from "react"; +import { CircularProgress } from "@mui/material"; +import { SearchX } from "lucide-react"; +import type { + LocationOption, + VendorDropdownItem, + WorkOrderUser, +} from "@/domain/work-orders/types/work-order"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { UNSCHEDULED_DAY_KEY } from "@/domain/work-orders/types/work-order-table-row"; +import { mapWizardDraftToFormValues } from "@/domain/work-orders/mappers/wizard-draft-mapper"; +import { useCreateWorkOrderFromWizard } from "@/domain/work-orders/use-cases/use-create-work-order-from-wizard"; +import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations"; +import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types"; +import { WoTableHead } from "./wo-table-head"; +import { WoTableInlineRow } from "./wo-table-inline-row"; +import { WoTableRow } from "./wo-table-row"; +import { WoTableSectionHeader } from "./wo-table-section-header"; +import { WO_TABLE_MIN_WIDTH } from "./wo-table-cols"; + +const CARD_STYLE: React.CSSProperties = { + background: "var(--card)", + border: "1px solid var(--border)", + borderRadius: 8, + boxShadow: "0 1px 3px rgba(38,34,98,.05)", +}; + +type WoTableProps = { + tableData: ReturnTypeOfUseWorkOrdersTableData; + users: WorkOrderUser[]; + sites: LocationOption[]; + vendors: VendorDropdownItem[]; + pmTypes: string[]; + onOpenSlideOver: (row: WorkOrderTableRow, edit?: boolean) => void; + onOpenCompDoc: (row: WorkOrderTableRow) => void; + onOpenMedia: (row: WorkOrderTableRow) => void; + onClearSearch?: () => void; + onOpenAdvancedFilters?: () => void; +}; + +function WoTableShell({ children }: { children: React.ReactNode }) { + return ( +
+
+ {children} +
+
+ ); +} + +function WoTableEmptyState({ + message, + actionLabel, + onAction, +}: { + message: string; + actionLabel: string; + onAction?: () => void; +}) { + return ( +
+ +

{message}

+ {onAction && ( + + )} +
+ ); +} + +export function WoTable({ + tableData, + users, + sites, + vendors, + pmTypes, + onOpenSlideOver, + onOpenCompDoc, + onOpenMedia, + onClearSearch, + onOpenAdvancedFilters, +}: WoTableProps) { + const [scheduleHighlightId, setScheduleHighlightId] = useState(null); + const { patchField } = useWorkOrderTableMutations(tableData.patchRow); + const createMutation = useCreateWorkOrderFromWizard(() => { + tableData.cancelDraft(); + }); + const { dragState: dragStateRef, setDragViz, reorderRows, endDrag, setFlag } = tableData; + + const handlers = useMemo( + () => ({ + onRowClick: (row: WorkOrderTableRow) => { + tableData.setSelectedId(row.id); + onOpenSlideOver(row, false); + }, + onView: (row: WorkOrderTableRow) => { + tableData.setSelectedId(row.id); + onOpenSlideOver(row, false); + }, + onEdit: (row: WorkOrderTableRow) => { + tableData.setSelectedId(row.id); + onOpenSlideOver(row, true); + }, + onOpenCompDoc: (row: WorkOrderTableRow) => { + tableData.setSelectedId(row.id); + onOpenCompDoc(row); + }, + onOpenMedia: (row: WorkOrderTableRow) => { + tableData.setSelectedId(row.id); + onOpenMedia(row); + }, + onPatch: (id: string | number, patch: Partial) => { + patchField(id, patch); + }, + onDuplicateFound: (existing: WorkOrderTableRow) => { + tableData.setSelectedId(existing.id); + onOpenSlideOver(existing, false); + }, + onOpenSlideOver: (row: WorkOrderTableRow) => onOpenSlideOver(row, false), + onPastDueAttempt: (id: string | number) => setScheduleHighlightId(id), + onReorder: reorderRows, + onDragStart: (id: string | number) => { + dragStateRef.current = { dragId: id, overId: null }; + setDragViz({ dragId: id, overId: null }); + }, + onDragOver: (id: string | number) => { + const did = dragStateRef.current.dragId; + if (did && did !== id && dragStateRef.current.overId !== id) { + dragStateRef.current.overId = id; + setDragViz((v) => ({ ...v, overId: id })); + } + }, + onDrop: () => { + const { dragId, overId } = dragStateRef.current; + if (dragId && overId) { + reorderRows(dragId, overId); + } + endDrag(); + }, + onDragEnd: endDrag, + setFlag, + }), + [ + tableData, + dragStateRef, + setDragViz, + reorderRows, + endDrag, + setFlag, + onOpenSlideOver, + onOpenCompDoc, + onOpenMedia, + patchField, + ], + ); + + const renderRow = (row: WorkOrderTableRow, forceScheduleHighlight = false) => ( + + ); + + const handleSaveDraft = () => { + const d = tableData.draft; + const assignee = users.find((u) => String(u.id) === d.dispatcherId); + const formValues = mapWizardDraftToFormValues({ + locationId: String(d.locationId || ""), + locationName: d.location || d.site, + woNumber: d.woNumber, + type: d.type, + assigneeId: d.dispatcherId, + assigneeName: assignee?.name ?? "", + scheduledOn: d.scheduledOn, + dueDate: d.dueDate, + pm: d.pm, + vendorId: "", + vendorName: d.company, + techPhone: d.techPhone, + vendorNotes: "", + apptStart: d.apptStart, + apptEnd: d.apptEnd, + status: d.status || "Incomplete", + pocName: d.pocName, + pocPhone: d.pocPhone, + pocNotes: d.pocNotes, + scheduleWeekOnly: d.scheduleWeekOnly, + }); + createMutation.mutate(formValues); + }; + + if (tableData.isLoading) { + return ( + +
+ +
+
+ ); + } + + const emptyAdv = tableData.advActive && tableData.advResults.length === 0; + + if (emptyAdv) { + return ( + + + + ); + } + + if (!tableData.advActive && tableData.noSearchResults) { + return ( + + + + ); + } + + return ( + + + + + {tableData.advActive ? ( + tableData.advResults.map((row) => renderRow(row)) + ) : ( + <> + {(tableData.unscheduled.length > 0 || + tableData.draftDayKey === UNSCHEDULED_DAY_KEY) && ( + <> + tableData.setUnschedOpen((v) => !v)} + onAddWo={() => tableData.startDayDraft(UNSCHEDULED_DAY_KEY)} + /> + {tableData.unschedOpen && ( + <> + {tableData.draftDayKey === UNSCHEDULED_DAY_KEY && ( + + )} + {tableData.unscheduled.map((row) => renderRow(row, true))} + + )} + + )} + {tableData.displayGroups.map((group) => ( + + tableData.startDayDraft(group.key)} + /> + {tableData.draftDayKey === group.key && ( + + )} + {group.items.map((row) => renderRow(row))} + + ))} + + )} + +
+
+ ); +} diff --git a/src/app/(protected)/workorders/_hooks/use-table-sort.ts b/src/app/(protected)/workorders/_hooks/use-table-sort.ts new file mode 100644 index 00000000..76bb8970 --- /dev/null +++ b/src/app/(protected)/workorders/_hooks/use-table-sort.ts @@ -0,0 +1,58 @@ +import { useCallback, useState } from "react"; +import type { + SortDirection, + WorkOrdersSortableColumn, +} from "@/domain/work-orders/types/work-order-list"; + +type TableSortState = { + column: WorkOrdersSortableColumn | ""; + direction: SortDirection; +}; + +export function useTableSort(initialColumn: WorkOrdersSortableColumn | "" = "") { + const [sortBy, setSortBy] = useState(initialColumn); + const [sortDir, setSortDir] = useState(""); + + const handleSort = useCallback((column: WorkOrdersSortableColumn | string) => { + const col = column as WorkOrdersSortableColumn; + setSortBy((currentColumn) => { + if (currentColumn !== col) { + setSortDir("asc"); + return col; + } + + setSortDir((currentDir) => { + if (currentDir === "asc") { + return "desc"; + } + if (currentDir === "desc") { + setSortBy(""); + return ""; + } + return "asc"; + }); + + return currentColumn; + }); + }, []); + + const sortIndicator = useCallback( + (column: string) => { + if (sortBy !== column) { + return ""; + } + return sortDir === "desc" ? " ↓" : sortDir === "asc" ? " ↑" : ""; + }, + [sortBy, sortDir], + ); + + const sort: TableSortState = { column: sortBy, direction: sortDir }; + + return { + sortBy, + sortDir, + sort, + handleSort, + sortIndicator, + }; +} diff --git a/src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts new file mode 100644 index 00000000..f36b6ab3 --- /dev/null +++ b/src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts @@ -0,0 +1,222 @@ +import { useCallback, useMemo, useRef, useState } from "react"; +import type { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters"; +import { useWorkOrdersList } from "@/domain/work-orders/use-cases/use-work-orders-list"; +import { + EMPTY_TABLE_DRAFT, + UNSCHEDULED_DAY_KEY, + type WorkOrderTableDraft, + type WorkOrderTableRow, +} from "@/domain/work-orders/types/work-order-table-row"; +import { currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils"; +import { buildWeekSkeleton, deriveDayFields } from "@/domain/work-orders/utils/table-day-utils"; +import { + buildDayGroups, + enrichTableRow, + filterByBarAndSearch, + matchesAdvancedFilters, + splitScheduledUnscheduled, +} from "@/domain/work-orders/utils/table-filter-utils"; + +type FiltersReturn = ReturnType; + +export type DragVizState = { + dragId: string | number | null; + overId: string | number | null; +}; + +export function useWorkOrdersTableData(filters: FiltersReturn) { + const { data, isLoading, isFetching } = useWorkOrdersList(filters.listParams); + const { data: scopeData } = useWorkOrdersList(filters.scopeListParams); + + const [patches, setPatches] = useState>>({}); + const [localRows, setLocalRows] = useState([]); + const [rowOrder, setRowOrder] = useState(null); + const [flags, setFlagsState] = useState>({}); + const [draftDayKey, setDraftDayKey] = useState(null); + const [draft, setDraft] = useState(EMPTY_TABLE_DRAFT); + const [unschedOpen, setUnschedOpen] = useState(true); + const [selectedId, setSelectedId] = useState(null); + const [flashId, setFlashId] = useState(null); + const [dragViz, setDragViz] = useState({ dragId: null, overId: null }); + const dragState = useRef({ dragId: null, overId: null }); + + const apiRows = useMemo( + () => (data?.items ?? []).map((row) => enrichTableRow({ ...row, ...patches[String(row.id)] })), + [data?.items, patches], + ); + + const displayRows = useMemo(() => { + const apiIds = new Set(apiRows.map((r) => String(r.id))); + const extra = localRows.filter((r) => !apiIds.has(String(r.id))); + let merged = [...apiRows, ...extra.map(enrichTableRow)]; + if (rowOrder?.length) { + const orderMap = new Map(rowOrder.map((id, idx) => [id, idx])); + merged = [...merged].sort((a, b) => { + const ai = orderMap.get(String(a.id)) ?? 9999; + const bi = orderMap.get(String(b.id)) ?? 9999; + return ai - bi; + }); + } + return merged; + }, [apiRows, localRows, rowOrder]); + + const advActive = filters.advApplied !== null; + const advResults = useMemo(() => { + if (!filters.advApplied) { + return []; + } + return displayRows.filter((row) => matchesAdvancedFilters(row, filters.advApplied!)); + }, [displayRows, filters.advApplied]); + + const filtered = useMemo(() => { + if (advActive) { + return []; + } + return filterByBarAndSearch(displayRows, filters.barFilters, filters.search); + }, [displayRows, filters.barFilters, filters.search, advActive]); + + const { scheduled, unscheduled } = useMemo(() => splitScheduledUnscheduled(filtered), [filtered]); + + const dayGroups = useMemo(() => buildDayGroups(scheduled), [scheduled]); + const weekSkeleton = useMemo(() => buildWeekSkeleton(filters.weekMonday), [filters.weekMonday]); + + const searchActive = filters.search.trim() !== ""; + const noSearchResults = filtered.length === 0 && searchActive; + const isEmptyWeek = scheduled.length === 0 && !searchActive; + const displayGroups = isEmptyWeek ? weekSkeleton : dayGroups; + + const todayKey = + filters.weekMonday === currentWeekMonday() + ? deriveDayFields(new Date().toISOString().slice(0, 10)).dayGroup + : null; + + const selectedRow = useMemo( + () => displayRows.find((row) => String(row.id) === String(selectedId)) ?? null, + [displayRows, selectedId], + ); + + const setFlag = useCallback((id: string | number, color: string | null) => { + setFlagsState((prev) => { + const key = String(id); + if (!color) { + const next = { ...prev }; + delete next[key]; + return next; + } + return { ...prev, [key]: color }; + }); + }, []); + + const patchRow = useCallback((id: string | number, patch: Partial) => { + const key = String(id); + setPatches((prev) => { + const merged = { ...prev[key], ...patch }; + if (patch.scheduledOn !== undefined) { + const dayFields = deriveDayFields(patch.scheduledOn); + merged.dayGroup = dayFields.dayGroup; + merged.dayLabel = dayFields.dayLabel; + } + return { ...prev, [key]: merged }; + }); + setLocalRows((prev) => + prev.map((row) => (String(row.id) === key ? enrichTableRow({ ...row, ...patch }) : row)), + ); + }, []); + + const reorderRows = useCallback( + (dragId: string | number, overId: string | number) => { + const dragKey = String(dragId); + const overKey = String(overId); + const dragRow = displayRows.find((r) => String(r.id) === dragKey); + const overRow = displayRows.find((r) => String(r.id) === overKey); + if (!dragRow || !overRow || dragRow.dayGroup !== overRow.dayGroup) { + return; + } + const ids = displayRows.map((r) => String(r.id)); + const dragIdx = ids.indexOf(dragKey); + const overIdx = ids.indexOf(overKey); + if (dragIdx < 0 || overIdx < 0) { + return; + } + const next = [...ids]; + next.splice(dragIdx, 1); + next.splice(overIdx, 0, dragKey); + setRowOrder(next); + }, + [displayRows], + ); + + const endDrag = useCallback(() => { + dragState.current = { dragId: null, overId: null }; + setDragViz({ dragId: null, overId: null }); + }, []); + + const startDayDraft = useCallback( + (key: string) => { + setDraftDayKey(key); + const scheduledOn = + key === UNSCHEDULED_DAY_KEY + ? "" + : (() => { + const [mm, dd] = key.split("/").map(Number); + const year = filters.weekMonday.slice(0, 4); + return `${year}-${String(mm).padStart(2, "0")}-${String(dd).padStart(2, "0")}`; + })(); + setDraft({ + ...EMPTY_TABLE_DRAFT, + scheduledOn, + scheduleWeekOnly: key === UNSCHEDULED_DAY_KEY, + targetWeek: key === UNSCHEDULED_DAY_KEY ? filters.weekMonday : "", + }); + }, + [filters.weekMonday], + ); + + const cancelDraft = useCallback(() => { + setDraftDayKey(null); + setDraft(EMPTY_TABLE_DRAFT); + }, []); + + const addRow = useCallback((row: WorkOrderTableRow) => { + setLocalRows((prev) => [...prev, enrichTableRow(row)]); + setFlashId(row.id); + setTimeout(() => setFlashId(null), 2000); + }, []); + + return { + rows: displayRows, + isLoading, + isFetching, + advActive, + advResults, + unscheduled, + displayGroups, + searchActive, + noSearchResults, + isEmptyWeek, + todayKey, + flags, + setFlag, + draftDayKey, + draft, + setDraft, + unschedOpen, + setUnschedOpen, + selectedId, + setSelectedId, + selectedRow, + flashId, + dragViz, + setDragViz, + dragState, + patchRow, + reorderRows, + endDrag, + startDayDraft, + cancelDraft, + addRow, + scopeTotalCount: scopeData?.totalCount ?? 0, + totalCount: data?.totalCount ?? displayRows.length, + search: filters.search, + }; +} From cbaae41f986b88396151b74544effe527fac5265 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Fri, 17 Jul 2026 12:04:45 -0300 Subject: [PATCH 03/11] feat(work-orders): add board table cells and dialogs --- .../list/table/cells/appt-time-cell.tsx | 126 ++++++++ .../list/table/cells/assigned-to-cell.tsx | 99 +++++++ .../list/table/cells/comp-doc-cell.tsx | 67 +++++ .../list/table/cells/date-cell.tsx | 139 +++++++++ .../list/table/cells/dispatcher-pill.tsx | 40 +++ .../table/cells/editable-wo-number-cell.tsx | 212 ++++++++++++++ .../list/table/cells/flag-cell.tsx | 99 +++++++ .../list/table/cells/highlight-text.tsx | 26 ++ .../list/table/cells/hover-chevron.tsx | 11 + .../list/table/cells/pm-type-cell.tsx | 120 ++++++++ .../list/table/cells/site-cell.tsx | 79 +++++ .../list/table/cells/site-dialog.tsx | 270 +++++++++++++++++ .../list/table/cells/status-cell.tsx | 151 ++++++++++ .../list/table/cells/type-cell.tsx | 71 +++++ .../list/table/cells/vendor-cell.tsx | 57 ++++ .../list/table/cells/vendor-dialog.tsx | 272 ++++++++++++++++++ .../table/cells/wo-table-status-label.tsx | 25 ++ .../list/table/cells/wo-table-type-label.tsx | 19 ++ 18 files changed, 1883 insertions(+) create mode 100644 src/app/(protected)/workorders/_components/list/table/cells/appt-time-cell.tsx create mode 100644 src/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell.tsx create mode 100644 src/app/(protected)/workorders/_components/list/table/cells/comp-doc-cell.tsx create mode 100644 src/app/(protected)/workorders/_components/list/table/cells/date-cell.tsx create mode 100644 src/app/(protected)/workorders/_components/list/table/cells/dispatcher-pill.tsx create mode 100644 src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell.tsx create mode 100644 src/app/(protected)/workorders/_components/list/table/cells/flag-cell.tsx create mode 100644 src/app/(protected)/workorders/_components/list/table/cells/highlight-text.tsx create mode 100644 src/app/(protected)/workorders/_components/list/table/cells/hover-chevron.tsx create mode 100644 src/app/(protected)/workorders/_components/list/table/cells/pm-type-cell.tsx create mode 100644 src/app/(protected)/workorders/_components/list/table/cells/site-cell.tsx create mode 100644 src/app/(protected)/workorders/_components/list/table/cells/site-dialog.tsx create mode 100644 src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx create mode 100644 src/app/(protected)/workorders/_components/list/table/cells/type-cell.tsx create mode 100644 src/app/(protected)/workorders/_components/list/table/cells/vendor-cell.tsx create mode 100644 src/app/(protected)/workorders/_components/list/table/cells/vendor-dialog.tsx create mode 100644 src/app/(protected)/workorders/_components/list/table/cells/wo-table-status-label.tsx create mode 100644 src/app/(protected)/workorders/_components/list/table/cells/wo-table-type-label.tsx diff --git a/src/app/(protected)/workorders/_components/list/table/cells/appt-time-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/appt-time-cell.tsx new file mode 100644 index 00000000..0f2aa025 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/appt-time-cell.tsx @@ -0,0 +1,126 @@ +import { useEffect, useState } from "react"; +import { Clock } from "lucide-react"; +import { Button, Popover } from "@mui/material"; +import { + calcDuration, + fmt12, + formatApptRange, + formatApptTimeValue, + parseApptTime, + TIME_SLOTS, +} from "@/domain/work-orders/utils/wizard-date-utils"; +import { HoverChevron } from "./hover-chevron"; + +type ApptTimeCellProps = { + apptTime: string; + onSave: (value: string) => void; +}; + +export function ApptTimeCell({ apptTime, onSave }: ApptTimeCellProps) { + const [anchorEl, setAnchorEl] = useState(null); + const [start, setStart] = useState(() => parseApptTime(apptTime).start); + const [end, setEnd] = useState(() => parseApptTime(apptTime).end); + + useEffect(() => { + const p = parseApptTime(apptTime); + setStart(p.start); + setEnd(p.end); + }, [apptTime]); + + const parsed = parseApptTime(apptTime); + const display = parsed.start ? formatApptRange(parsed.start, parsed.end) : apptTime; + + const slotStyle = (active: boolean): React.CSSProperties => ({ + background: active ? "var(--color-primary-tint)" : "transparent", + color: active ? "var(--color-primary)" : "var(--color-text-primary)", + fontWeight: active ? 600 : 400, + }); + + const done = () => { + onSave(formatApptTimeValue(start, end)); + setAnchorEl(null); + }; + + return ( + <> + + setAnchorEl(null)} + anchorOrigin={{ vertical: "bottom", horizontal: "left" }} + onClick={(e) => e.stopPropagation()} + slotProps={{ paper: { className: "w-[264px] rounded-lg border border-border p-0" } }} + > +
+
+

+ Start +

+
+ {TIME_SLOTS.map((t) => ( + + ))} +
+
+
+

+ End (optional) +

+
+ {TIME_SLOTS.filter((t) => !start || t > start).map((t) => ( + + ))} +
+
+
+
+ + {start && end ? calcDuration(start, end) : start ? fmt12(start) : "—"} + + +
+
+ + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell.tsx new file mode 100644 index 00000000..92557ea2 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell.tsx @@ -0,0 +1,99 @@ +import { useState } from "react"; +import { Check, Users } from "lucide-react"; +import { Popover } from "@mui/material"; +import type { WorkOrderUser } from "@/domain/work-orders/types/work-order"; +import { DispatcherPill } from "./dispatcher-pill"; +import { HoverChevron } from "./hover-chevron"; + +type AssignedToCellProps = { + dispatcherId: string; + users: WorkOrderUser[]; + highlight?: boolean; + onSave: (id: string, name: string) => void; +}; + +export function AssignedToCell({ dispatcherId, users, highlight, onSave }: AssignedToCellProps) { + const [anchorEl, setAnchorEl] = useState(null); + const [search, setSearch] = useState(""); + const selected = users.find((d) => String(d.id) === dispatcherId); + const filtered = search + ? users.filter((d) => d.name.toLowerCase().includes(search.toLowerCase())) + : users; + + return ( + <> + + { + setAnchorEl(null); + setSearch(""); + }} + anchorOrigin={{ vertical: "bottom", horizontal: "left" }} + onClick={(e) => e.stopPropagation()} + slotProps={{ paper: { className: "w-[212px] rounded-lg border border-border p-1.5" } }} + > +
+ setSearch(e.target.value)} + placeholder="Search dispatcher…" + className="w-full rounded border border-border bg-[var(--color-bg-surface)] px-2 py-1 text-xs text-[var(--color-text-primary)] focus:outline-none" + /> +
+
+ {filtered.map((d) => ( + + ))} +
+
+ + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/comp-doc-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/comp-doc-cell.tsx new file mode 100644 index 00000000..55199255 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/comp-doc-cell.tsx @@ -0,0 +1,67 @@ +import { Ban, FileText, Image as ImageIcon } from "lucide-react"; +import type { CompletionDocStatus } from "@/domain/work-orders/types/work-order-table-row"; +import type { WOType } from "@/domain/work-orders/types/work-order-wizard"; + +function isMediaWO(type: WOType | ""): boolean { + return type === "Emergency" || type === "Reactive"; +} + +type CompDocCellProps = { + status: CompletionDocStatus; + woType: WOType | ""; + mediaCount?: number; + onOpen: () => void; + onOpenMedia: () => void; +}; + +export function CompDocCell({ + status, + woType, + mediaCount = 0, + onOpen, + onOpenMedia, +}: CompDocCellProps) { + if (isMediaWO(woType)) { + return ( + + ); + } + + const generated = status === "Yes"; + const notRequired = status === "NN"; + const color = generated ? "var(--color-success)" : "var(--color-text-muted)"; + const Icon = notRequired ? Ban : FileText; + + return ( + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/date-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/date-cell.tsx new file mode 100644 index 00000000..7eee438c --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/date-cell.tsx @@ -0,0 +1,139 @@ +import { useState } from "react"; +import { CalendarDays, Calendar as CalendarIcon, ChevronDown } from "lucide-react"; +import { Popover } from "@mui/material"; +import { MiniCalendar } from "@/app/(protected)/workorders/_components/wizard/mini-calendar"; +import { WeekPicker } from "@/app/(protected)/workorders/_components/wizard/week-picker"; +import { fmtWeekOf } from "@/domain/work-orders/utils/wizard-date-utils"; +import { HighlightText } from "./highlight-text"; + +type DateCellProps = { + value: string; + q?: string; + highlight?: boolean; + onSave: (date: string) => void; + allowWeekMode?: boolean; + targetWeek?: string; + onSaveWeek?: (weekStart: string) => void; + defaultMode?: "week" | "date"; +}; + +export function DateCell({ + value, + q, + highlight, + onSave, + allowWeekMode, + targetWeek = "", + onSaveWeek, + defaultMode, +}: DateCellProps) { + const [anchorEl, setAnchorEl] = useState(null); + const [cellMode, setCellMode] = useState<"week" | "date">( + allowWeekMode ? (defaultMode ?? (!value && targetWeek ? "week" : "date")) : "date", + ); + const showWeek = allowWeekMode && !value && !!targetWeek; + + return ( + <> + + setAnchorEl(null)} + anchorOrigin={{ vertical: "bottom", horizontal: "left" }} + onClick={(e) => e.stopPropagation()} + slotProps={{ paper: { className: "rounded-lg border border-border p-3" } }} + > + {allowWeekMode && ( +
+ {(["week", "date"] as const).map((m) => { + const active = cellMode === m; + return ( + + ); + })} +
+ )} + {allowWeekMode && cellMode === "week" ? ( + { + onSave(""); + onSaveWeek?.(mon); + setAnchorEl(null); + }} + /> + ) : ( + { + onSave(d); + setAnchorEl(null); + }} + /> + )} +
+ + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/dispatcher-pill.tsx b/src/app/(protected)/workorders/_components/list/table/cells/dispatcher-pill.tsx new file mode 100644 index 00000000..77ac64e3 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/dispatcher-pill.tsx @@ -0,0 +1,40 @@ +import type { WorkOrderUser } from "@/domain/work-orders/types/work-order"; + +import { getDispatcherAvatarColor } from "@/domain/work-orders/mocks/dispatcher-filter-mock-data"; + +import { HighlightText } from "./highlight-text"; + +type DispatcherPillProps = { + user: WorkOrderUser; + + q?: string; +}; + +export function DispatcherPill({ user, q }: DispatcherPillProps) { + const color = getDispatcherAvatarColor(user); + + return ( + + + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell.tsx new file mode 100644 index 00000000..ce4b3462 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell.tsx @@ -0,0 +1,212 @@ +import { useEffect, useState } from "react"; + +import { toast } from "react-toastify"; + +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; + +import { HighlightText } from "./highlight-text"; + +type EditableWONumberCellProps = { + value: string; + + rescheduleCount: number; + + carriedOver?: number; + + q?: string; + + currentId: string | number; + + allOrders: WorkOrderTableRow[]; + + onSave: (v: string) => void; + + onDuplicateFound?: (existing: WorkOrderTableRow) => void; +}; + +function CounterPill({ label, count }: { label: string; count: number }) { + if (count < 2) { + return null; + } + + return ( + + {count} + + ); +} + +export function EditableWONumberCell({ + value, + + rescheduleCount, + + carriedOver = 0, + + q, + + currentId, + + allOrders, + + onSave, + + onDuplicateFound, +}: EditableWONumberCellProps) { + const [editing, setEditing] = useState(false); + + const [draft, setDraft] = useState(value); + + useEffect(() => { + if (!editing) { + setDraft(value); + } + }, [value, editing]); + + const findDuplicate = (d: string) => + allOrders.find((o) => o.woNumber === d && String(o.id) !== String(currentId)); + + const commit = () => { + if (draft === value) { + setEditing(false); + + return; + } + + if (draft.length !== 11) { + toast("Work order number must be exactly 11 digits"); + + return; + } + + const duplicate = findDuplicate(draft); + + if (duplicate) { + onDuplicateFound?.(duplicate); + + setDraft(value); + + setEditing(false); + + return; + } + + onSave(draft); + + setEditing(false); + }; + + const cancel = () => { + setDraft(value); + + setEditing(false); + }; + + const handleBlur = () => { + if (draft !== value && draft.length === 11) { + const duplicate = findDuplicate(draft); + + if (duplicate) { + onDuplicateFound?.(duplicate); + + setDraft(value); + + setEditing(false); + + return; + } + + onSave(draft); + } + + setEditing(false); + }; + + const invalid = editing && draft.length > 0 && draft.length !== 11; + + return ( +
+ {editing ? ( + setDraft(e.target.value.replace(/\D/g, "").slice(0, 11))} + onFocus={(e) => e.target.select()} + onClick={(e) => e.stopPropagation()} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + + commit(); + } else if (e.key === "Escape") { + e.preventDefault(); + + cancel(); + } + }} + onBlur={handleBlur} + placeholder="12345678901" + aria-label="Work order number" + aria-invalid={invalid} + className="font-mono focus:outline-none" + style={{ + fontSize: 13, + + fontWeight: 700, + + color: "var(--foreground)", + + background: "var(--color-bg-surface)", + + border: `1px solid ${invalid ? "var(--color-destructive)" : "var(--color-ring)"}`, + + boxShadow: `0 0 0 1px ${invalid ? "var(--color-destructive)" : "var(--color-accent)"}`, + + borderRadius: 4, + + padding: "2px 4px", + + width: 120, + }} + /> + ) : ( + { + e.stopPropagation(); + + setEditing(true); + }} + className="inline-block cursor-text font-mono transition-colors hover:bg-[var(--color-bg-muted)]" + style={{ + fontSize: 13, + + fontWeight: 700, + + color: value ? "var(--foreground)" : "var(--color-text-muted)", + + borderRadius: 4, + + padding: "2px 4px", + }} + > + {value ? : "-"} + + )} + + + + +
+ ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/flag-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/flag-cell.tsx new file mode 100644 index 00000000..cda95c32 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/flag-cell.tsx @@ -0,0 +1,99 @@ +import { useState } from "react"; +import { Flag, X } from "lucide-react"; +import { Popover } from "@mui/material"; + +const FLAG_COLORS: { name: string; value: string }[] = [ + { name: "Red", value: "#EF4444" }, + { name: "Orange", value: "#F97316" }, + { name: "Amber", value: "#F59E0B" }, + { name: "Yellow", value: "#EAB308" }, + { name: "Green", value: "#22C55E" }, + { name: "Teal", value: "#14B8A6" }, + { name: "Blue", value: "#3B82F6" }, + { name: "Purple", value: "#8B5CF6" }, +]; + +type FlagCellProps = { + color?: string; + disabled?: boolean; + onChange: (color: string | null) => void; +}; + +export function FlagCell({ color, disabled, onChange }: FlagCellProps) { + const [anchorEl, setAnchorEl] = useState(null); + + if (disabled) { + return ( + + + + ); + } + + return ( + <> + + setAnchorEl(null)} + anchorOrigin={{ vertical: "bottom", horizontal: "left" }} + onClick={(e) => e.stopPropagation()} + slotProps={{ paper: { className: "rounded-lg border border-border p-2" } }} + > +
+ {FLAG_COLORS.map((c) => ( + + ))} +
+ +
+ + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/highlight-text.tsx b/src/app/(protected)/workorders/_components/list/table/cells/highlight-text.tsx new file mode 100644 index 00000000..95b10c15 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/highlight-text.tsx @@ -0,0 +1,26 @@ +type HighlightTextProps = { + text: string; + q?: string; +}; + +export function HighlightText({ text, q }: HighlightTextProps) { + if (!q?.trim() || !text) { + return <>{text}; + } + const query = q.trim(); + const lower = text.toLowerCase(); + const idx = lower.indexOf(query.toLowerCase()); + if (idx < 0) { + return <>{text}; + } + const before = text.slice(0, idx); + const match = text.slice(idx, idx + query.length); + const after = text.slice(idx + query.length); + return ( + <> + {before} + {match} + {after} + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/hover-chevron.tsx b/src/app/(protected)/workorders/_components/list/table/cells/hover-chevron.tsx new file mode 100644 index 00000000..69227086 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/hover-chevron.tsx @@ -0,0 +1,11 @@ +import { ChevronDown } from "lucide-react"; + +export function HoverChevron() { + return ( + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/pm-type-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/pm-type-cell.tsx new file mode 100644 index 00000000..4ae184df --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/pm-type-cell.tsx @@ -0,0 +1,120 @@ +import { useState } from "react"; + +import { Check } from "lucide-react"; + +import { Popover } from "@mui/material"; + +import { PmTypeIcon } from "../pm-type-icons"; + +import { HighlightText } from "./highlight-text"; + +import { HoverChevron } from "./hover-chevron"; + +type PMTypeCellProps = { + value: string; + + pmTypes: string[]; + + q?: string; + + onSave: (v: string) => void; +}; + +export function PMTypeCell({ value, pmTypes, q, onSave }: PMTypeCellProps) { + const [anchorEl, setAnchorEl] = useState(null); + + const [search, setSearch] = useState(""); + + const filtered = search + ? pmTypes.filter((p) => p.toLowerCase().includes(search.toLowerCase())) + : pmTypes; + + return ( + <> + + + { + setAnchorEl(null); + + setSearch(""); + }} + anchorOrigin={{ vertical: "bottom", horizontal: "left" }} + onClick={(e) => e.stopPropagation()} + slotProps={{ paper: { className: "w-[244px] rounded-lg border border-border p-1.5" } }} + > +
+ setSearch(e.target.value)} + placeholder="Search PM type…" + className="w-full rounded border border-border bg-[var(--color-bg-surface)] px-2 py-1 text-xs text-[var(--color-text-primary)] focus:outline-none" + /> +
+ +
+ {filtered.map((pm) => ( + + ))} +
+
+ + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/site-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/site-cell.tsx new file mode 100644 index 00000000..204d4f5a --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/site-cell.tsx @@ -0,0 +1,79 @@ +import { useState } from "react"; +import { MapPin } from "lucide-react"; +import type { LocationOption } from "@/domain/work-orders/types/work-order"; +import { HighlightText } from "./highlight-text"; +import { HoverChevron } from "./hover-chevron"; +import { SiteDialog, type SitePatch } from "./site-dialog"; + +type SiteCellProps = { + value: string; + location: string; + locationId: string | number; + pocName?: string; + pocPhone?: string; + pocNotes?: string; + sites: LocationOption[]; + q?: string; + onSave: (patch: SitePatch) => void; +}; + +export function SiteCell({ + value, + location, + locationId, + pocName, + pocPhone, + pocNotes, + sites, + q, + onSave, +}: SiteCellProps) { + const [open, setOpen] = useState(false); + + return ( + <> + + + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog.tsx b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog.tsx new file mode 100644 index 00000000..0fc46d4b --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog.tsx @@ -0,0 +1,270 @@ +import { useEffect, useState, type MouseEvent, type ReactNode } from "react"; +import { ExternalLink, Mail, MapPin, Phone } from "lucide-react"; +import { Dialog, DialogContent, DialogTitle } from "@mui/material"; +import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select"; +import { + DLG_INPUT_CLS, + DLG_INPUT_STYLE, +} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles"; +import { Detail } from "@/app/(protected)/workorders/_components/wizard/wizard-labels"; +import { Button } from "@/components/ui/button"; +import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop"; +import type { Location } from "@/domain/locations/types/location"; +import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail"; +import { getMockWoWizardLocationDetail } from "@/domain/work-orders/mocks/wo-wizard-mock-data"; +import type { LocationOption } from "@/domain/work-orders/types/work-order"; + +export type SitePatch = { + site: string; + location: string; + locationId: string | number; + pocName: string; + pocPhone: string; + pocNotes: string; +}; + +type SiteDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + value: string; + locationId: string | number; + pocName?: string; + pocPhone?: string; + pocNotes?: string; + sites: LocationOption[]; + onSave: (patch: SitePatch) => void; +}; + +function FieldLabel({ children }: { children: ReactNode }) { + return ( + + ); +} + +function extractSiteCode(name: string): string { + const dash = name.indexOf(" — "); + if (dash > 0) { + return name.slice(0, dash).trim(); + } + return name.slice(0, 8).toUpperCase(); +} + +function extractLocationLabel(name: string): string { + const dash = name.indexOf(" — "); + if (dash > 0) { + return name.slice(dash + 3).trim(); + } + return name; +} + +function buildMapsUrl(location: Location): string { + const parts = [location.address, location.city, location.state, location.zipCode] + .filter(Boolean) + .join(", "); + return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(parts)}`; +} + +export function SiteDialog({ + open, + onOpenChange, + value, + locationId, + pocName = "", + pocPhone = "", + pocNotes = "", + sites, + onSave, +}: SiteDialogProps) { + const [code, setCode] = useState(value); + const [locId, setLocId] = useState(String(locationId || "")); + const [pn, setPn] = useState(pocName); + const [pp, setPp] = useState(pocPhone); + const [notes, setNotes] = useState(pocNotes); + const [pocFilledFor, setPocFilledFor] = useState(""); + + const isMockLocation = locId.startsWith("mock-"); + const { data: apiLocationDetail } = useLocationDetail( + isMockLocation || !locId ? undefined : locId, + ); + const locationDetail = isMockLocation ? getMockWoWizardLocationDetail(locId) : apiLocationDetail; + + useEffect(() => { + if (open) { + setCode(value); + setLocId(String(locationId || "")); + setPn(pocName); + setPp(pocPhone); + setNotes(pocNotes); + setPocFilledFor(String(locationId || "")); + } + }, [open, value, locationId, pocName, pocPhone, pocNotes]); + + useEffect(() => { + if (!open || !locId || !locationDetail || pocFilledFor === locId) { + return; + } + setPn(locationDetail.contact ?? ""); + setPp(locationDetail.phone ?? ""); + setPocFilledFor(locId); + }, [open, locId, locationDetail, pocFilledFor]); + + const selected = sites.find((s) => String(s.id) === locId); + + const handlePick = (id: string) => { + const next = sites.find((s) => String(s.id) === id); + setLocId(id); + if (next) { + setCode(extractSiteCode(next.name)); + } + if (id !== pocFilledFor) { + setPocFilledFor(""); + } + }; + + return ( + onOpenChange(false)} + maxWidth={false} + slotProps={{ + ...dialogBlurBackdropSlotProps, + paper: { + className: "w-full", + style: { borderRadius: 12, maxWidth: 480 }, + onClick: (e: MouseEvent) => e.stopPropagation(), + }, + }} + > + e.stopPropagation()}> + + Site + +

Select a site and edit its point of contact

+ +
+
+ Site + + } + searchPlaceholder="Search site code or city…" + options={sites.map((site) => ({ + key: String(site.id), + label: site.name, + }))} + onPick={handlePick} + /> +
+ + {locationDetail && locId && ( +
+ + {locationDetail.phone && } + {locationDetail.contactEmail && ( + + )} + {locationDetail.address && ( + + Open in Google Maps + + )} +
+ )} + +
+ Point of Contact +

+ Auto-filled from records — edit if outdated +

+
+ setPn(e.target.value)} + placeholder="POC name" + className={DLG_INPUT_CLS} + style={DLG_INPUT_STYLE} + /> + setPp(e.target.value)} + placeholder="POC phone" + className={DLG_INPUT_CLS} + style={DLG_INPUT_STYLE} + /> +