From 32609659b7270229d06a59981ebd82126598593d Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Fri, 17 Jul 2026 12:04:33 -0300 Subject: [PATCH 1/7] 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 fef7918f6bedda2a28e976f4982ab28dee3c767b Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Wed, 22 Jul 2026 10:18:27 -0300 Subject: [PATCH 2/7] fix(work-orders): allow read-only open for canceled board rows --- .../list/table/wo-table-row-actions.tsx | 4 +- .../_components/list/table/wo-table-row.tsx | 122 ++++++++++++------ 2 files changed, 83 insertions(+), 43 deletions(-) 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 index c6ceaf10..2ec9da42 100644 --- 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 @@ -3,7 +3,7 @@ import { Eye, Pencil } from "lucide-react"; type WoTableRowActionsProps = { onView: () => void; - onEdit: () => void; + onEdit?: () => void; }; export function WoTableRowActions({ onView, onEdit }: WoTableRowActionsProps) { @@ -61,7 +61,7 @@ export function WoTableRowActions({ onView, onEdit }: WoTableRowActionsProps) {
{mk(Eye, "View details", onView, "#e6f4fb", "var(--primary)")} - {mk(Pencil, "Edit", onEdit, "#e6f4fb", "var(--primary)")} + {onEdit ? mk(Pencil, "Edit", onEdit, "#e6f4fb", "var(--primary)") : null}
); } 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 index f3210b61..b20e4e60 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-row.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-row.tsx @@ -85,23 +85,37 @@ export function WoTableRow({ } const patchAndMaybeAuto = (patch: Partial) => { + if (isCanceled) return; const merged = { ...row, ...patch }; const auto = maybeAutoSchedulePatch(merged); handlers.onPatch(row.id, { ...patch, ...auto }); }; + const patchRow = (patch: Partial) => { + if (isCanceled) return; + handlers.onPatch(row.id, patch); + }; + + /** Block mutation controls on canceled rows while keeping row/View clickable. */ + const mutationCellStyle = isCanceled ? ({ pointerEvents: "none" } as const) : undefined; + return ( handlers.onRowClick(row)} - onDragStart={() => handlers.onDragStart(row.id)} + onDragStart={() => { + if (isCanceled) return; + handlers.onDragStart(row.id); + }} onDragOver={(e) => { + if (isCanceled) return; e.preventDefault(); handlers.onDragOver(row.id); }} onDrop={(e) => { + if (isCanceled) return; e.preventDefault(); handlers.onDrop(); }} @@ -112,7 +126,6 @@ export function WoTableRow({ 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, @@ -131,23 +144,34 @@ export function WoTableRow({ }} > e.stopPropagation()} > - e.stopPropagation()}> - handlers.setFlag(row.id, c)} /> + e.stopPropagation()} + > + handlers.setFlag(row.id, c)} + /> - e.stopPropagation()}> + e.stopPropagation()} + > - handlers.onPatch(row.id, { + patchRow({ site: p.site, location: p.location, locationId: p.locationId, @@ -169,7 +193,7 @@ export function WoTableRow({ } /> - + handlers.onPatch(row.id, { woNumber: v })} + onSave={(v) => patchRow({ woNumber: v })} onDuplicateFound={handlers.onDuplicateFound} /> - e.stopPropagation()}> - handlers.onPatch(row.id, { type: t })} - /> + e.stopPropagation()} + > + patchRow({ type: t })} /> - e.stopPropagation()}> + e.stopPropagation()} + > patchAndMaybeAuto({ dispatcherId: id, dispatcherName: name })} /> - e.stopPropagation()}> + e.stopPropagation()} + > - e.stopPropagation()}> - handlers.onPatch(row.id, { dueDate: date })} - /> + e.stopPropagation()} + > + patchRow({ dueDate: date })} /> - e.stopPropagation()}> - handlers.onPatch(row.id, { pm })} - /> + e.stopPropagation()} + > + patchRow({ pm })} /> - e.stopPropagation()}> + e.stopPropagation()} + > - handlers.onPatch(row.id, { + patchRow({ company: p.company, tech: p.tech, techPhone: p.techPhone, @@ -246,16 +281,21 @@ export function WoTableRow({ } /> - e.stopPropagation()}> - handlers.onPatch(row.id, { apptTime: v })} - /> + e.stopPropagation()} + > + patchRow({ apptTime: v })} /> - e.stopPropagation()}> + e.stopPropagation()} + > handlers.onPatch(row.id, { status })} + onChangeStatus={(status) => patchRow({ status })} onOpenSlideOver={() => handlers.onOpenSlideOver(row)} onPastDueAttempt={() => handlers.onPastDueAttempt?.(row.id)} /> @@ -272,7 +312,7 @@ export function WoTableRow({ e.stopPropagation()}> handlers.onView(row)} - onEdit={() => handlers.onEdit(row)} + onEdit={isCanceled ? undefined : () => handlers.onEdit(row)} /> From 0709bd11728dbc01880e90bd1764329c974b66c3 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Wed, 22 Jul 2026 13:19:49 -0300 Subject: [PATCH 3/7] fix(work-orders): include table cells so board-core slice builds Bring cell modules and minimal wizard helpers into table-core for a self-contained slice build. --- .../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 | 268 +++++++++++ .../table/cells/wo-table-status-label.tsx | 25 + .../list/table/cells/wo-table-type-label.tsx | 19 + .../_components/wizard/mini-calendar.tsx | 115 +++++ .../_components/wizard/week-picker.tsx | 108 +++++ .../wizard/wizard-field-select.tsx | 167 +++++++ .../_components/wizard/wizard-input-styles.ts | 9 + .../_components/wizard/wizard-labels.tsx | 180 ++++++++ src/app/(protected)/workorders/index.tsx | 427 +++++------------- .../work-orders/mappers/work-order-mapper.ts | 2 + .../work-orders/types/work-order-common.ts | 2 + 26 files changed, 2569 insertions(+), 320 deletions(-) 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 create mode 100644 src/app/(protected)/workorders/_components/wizard/mini-calendar.tsx create mode 100644 src/app/(protected)/workorders/_components/wizard/week-picker.tsx create mode 100644 src/app/(protected)/workorders/_components/wizard/wizard-field-select.tsx create mode 100644 src/app/(protected)/workorders/_components/wizard/wizard-input-styles.ts create mode 100644 src/app/(protected)/workorders/_components/wizard/wizard-labels.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} + /> +