From 1cb8362d769a2b6db07d7fc2955f5f94702e42b8 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Fri, 17 Jul 2026 12:04:33 -0300 Subject: [PATCH] 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, + }; +}