feat(work-orders): add board table core and filters

This commit is contained in:
Arthur Bassi 2026-07-17 12:04:33 -03:00
parent af0e4aa1a6
commit 1cb8362d76
13 changed files with 1523 additions and 0 deletions

View file

@ -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<string, LucideIcon> = {
"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<LucideIcon> & { pmType: string };
export function PmTypeIcon({ pmType, ...props }: PmTypeIconProps) {
return createElement(getPmTypeIcon(pmType), props);
}

View file

@ -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;

View file

@ -0,0 +1,31 @@
import { WO_TABLE_COLS } from "./wo-table-cols";
export function WoTableHead() {
return (
<thead className="sticky top-0 z-10">
<tr>
{WO_TABLE_COLS.map((col) => (
<th
key={col.key}
style={{
width: col.w,
minWidth: col.w,
padding: "11px 14px",
textAlign: "left",
fontSize: 10.5,
fontWeight: 700,
textTransform: "uppercase",
letterSpacing: "0.06em",
color: "var(--foreground)",
whiteSpace: "nowrap",
background: "var(--color-bg-muted)",
borderBottom: "1px solid var(--border)",
}}
>
{col.label}
</th>
))}
</tr>
</thead>
);
}

View file

@ -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<WorkOrderTableDraft>) => 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 (
<tr
style={{
height: WO_TABLE_ROW_HEIGHT,
borderBottom: "1px solid var(--color-border)",
borderLeft: "2px solid var(--color-primary)",
background: "var(--color-primary-tint)",
}}
>
<td style={{ padding: "0 0 0 6px" }} />
<td style={{ padding: "0 2px 0 8px" }}>
<Flag size={14} strokeWidth={1.75} style={{ color: "var(--color-border)" }} />
</td>
<td className="group/cell" style={{ padding: "0 14px" }}>
<SiteCell
value={draft.site}
location={draft.location}
locationId={draft.locationId}
pocName={draft.pocName}
pocPhone={draft.pocPhone}
pocNotes={draft.pocNotes}
sites={sites}
onSave={(p) =>
patch({
site: p.site,
location: p.location,
locationId: p.locationId,
pocName: p.pocName,
pocPhone: p.pocPhone,
pocNotes: p.pocNotes,
})
}
/>
</td>
<td style={{ padding: "0 14px" }}>
<EditableWONumberCell
value={draft.woNumber}
rescheduleCount={0}
currentId="draft"
allOrders={allRows as never[]}
onSave={(v) => patch({ woNumber: v })}
/>
</td>
<td className="group/cell" style={{ padding: "0 14px" }}>
<TypeCell value={draft.type} onSave={(t) => patch({ type: t })} />
</td>
<td className="group/cell" style={{ padding: "0 14px" }}>
<AssignedToCell
dispatcherId={draft.dispatcherId}
users={users}
highlight={!draft.dispatcherId}
onSave={(id) => {
patch({ dispatcherId: id, status: draft.status || "Incomplete" });
}}
/>
</td>
<td className="group/cell" style={{ padding: "0 14px" }}>
<DateCell
value={draft.scheduledOn}
allowWeekMode
targetWeek={draft.targetWeek}
defaultMode={weekDefault ? "week" : "date"}
highlight={!draft.scheduledOn && !draft.targetWeek}
onSave={(date) => patch({ scheduledOn: date, scheduleWeekOnly: false, targetWeek: "" })}
onSaveWeek={(week) =>
patch({ scheduledOn: "", targetWeek: week, scheduleWeekOnly: true })
}
/>
</td>
<td className="group/cell" style={{ padding: "0 14px" }}>
<DateCell value={draft.dueDate} onSave={(date) => patch({ dueDate: date })} />
</td>
<td className="group/cell" style={{ padding: "0 14px" }}>
<PMTypeCell value={draft.pm} pmTypes={pmTypes} onSave={(pm) => patch({ pm })} />
</td>
<td className="group/cell" style={{ padding: "0 14px" }}>
<VendorCell
company={draft.company}
tech={draft.tech}
techPhone={draft.techPhone}
vendors={vendors}
onSave={(p) => patch({ company: p.company, tech: p.tech, techPhone: p.techPhone })}
/>
</td>
<td className="group/cell" style={{ padding: "0 14px" }}>
<ApptTimeCell
apptTime={
draft.apptStart ? `${draft.apptStart}${draft.apptEnd ? ` – ${draft.apptEnd}` : ""}` : ""
}
onSave={(v) => {
const [start, end] = v.split(" – ");
patch({ apptStart: start ?? "", apptEnd: end ?? "" });
}}
/>
</td>
<td className="group/cell" style={{ padding: "0 14px" }}>
<StatusCell
wo={draftRow as never}
onChangeStatus={(status) => patch({ status })}
onOpenSlideOver={() => {}}
/>
</td>
<td style={{ padding: "0 14px" }}>
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>—</span>
</td>
<td style={{ padding: "0 4px" }}>
<div className="flex items-center gap-0.5">
<button
type="button"
onClick={onSave}
disabled={!canSave}
title={canSave ? "Save" : "Schedule On is required"}
className="rounded p-1 transition-colors disabled:opacity-30"
style={{ color: "var(--color-primary)" }}
onMouseEnter={(e) => {
if (canSave) {
(e.currentTarget as HTMLButtonElement).style.background =
"var(--color-primary-tint)";
}
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLButtonElement).style.background = "transparent";
}}
>
<Check size={13} />
</button>
<button
type="button"
onClick={onCancel}
title="Cancel"
className="rounded p-1 transition-colors"
style={{ color: "var(--color-text-muted)" }}
onMouseEnter={(e) => {
(e.currentTarget as HTMLButtonElement).style.background = "var(--color-bg-muted)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLButtonElement).style.background = "transparent";
}}
>
<X size={13} />
</button>
</div>
</td>
</tr>
);
}

View file

@ -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,
) => (
<button
type="button"
title={title}
onClick={(e) => {
e.stopPropagation();
onClick();
}}
className="flex items-center justify-center transition-colors"
style={{
width: 28,
height: 28,
borderRadius: 6,
color: "var(--muted-foreground)",
background: "transparent",
}}
onMouseEnter={(e) => {
const b = e.currentTarget as HTMLButtonElement;
b.style.background = hoverBg;
b.style.color = hoverColor;
}}
onMouseLeave={(e) => {
const b = e.currentTarget as HTMLButtonElement;
b.style.background = "transparent";
b.style.color = "var(--muted-foreground)";
}}
>
<Icon size={15} />
</button>
);
return (
<div className="flex items-center justify-end gap-1 opacity-0 transition-opacity group-hover/row:opacity-100">
{mk(Eye, "View details", onView, "#e6f4fb", "var(--primary)")}
{mk(Pencil, "Edit", onEdit, "#e6f4fb", "var(--primary)")}
</div>
);
}

View file

@ -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<WorkOrderTableRow>) => 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<WorkOrderTableRow>) => {
const merged = { ...row, ...patch };
const auto = maybeAutoSchedulePatch(merged);
handlers.onPatch(row.id, { ...patch, ...auto });
};
return (
<tr
id={`wo-row-${row.id}`}
draggable={!isCanceled}
title={isCanceled ? "This work order has been cancelled" : undefined}
onClick={() => 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)";
}
}}
>
<td
style={{ padding: "0 0 0 6px", boxShadow: firstCellShadow }}
onClick={(e) => e.stopPropagation()}
>
<span
className="flex items-center justify-center"
style={{
cursor: isDragging ? "grabbing" : "grab",
color: "var(--color-text-muted)",
}}
>
<GripVertical size={14} />
</span>
</td>
<td style={{ padding: "0 2px 0 8px" }} onClick={(e) => e.stopPropagation()}>
<FlagCell color={flagColor} onChange={(c) => handlers.setFlag(row.id, c)} />
</td>
<td className="group/cell" style={{ padding: "0 14px" }} onClick={(e) => e.stopPropagation()}>
<SiteCell
value={row.site}
location={row.location}
locationId={row.locationId}
pocName={row.pocName}
pocPhone={row.pocPhone}
pocNotes={row.pocNotes}
sites={sites}
q={search}
onSave={(p) =>
handlers.onPatch(row.id, {
site: p.site,
location: p.location,
locationId: p.locationId,
pocName: p.pocName,
pocPhone: p.pocPhone,
pocNotes: p.pocNotes,
})
}
/>
</td>
<td style={{ padding: "0 14px", whiteSpace: "nowrap" }}>
<EditableWONumberCell
value={row.woNumber}
rescheduleCount={row.rescheduleCount}
carriedOver={row.carriedOver}
q={search}
currentId={row.id}
allOrders={allRows}
onSave={(v) => handlers.onPatch(row.id, { woNumber: v })}
onDuplicateFound={handlers.onDuplicateFound}
/>
</td>
<td className="group/cell" style={{ padding: "0 14px" }} onClick={(e) => e.stopPropagation()}>
<TypeCell
value={row.type}
q={search}
onSave={(t) => handlers.onPatch(row.id, { type: t })}
/>
</td>
<td className="group/cell" style={{ padding: "0 14px" }} onClick={(e) => e.stopPropagation()}>
<AssignedToCell
dispatcherId={row.dispatcherId}
users={users}
highlight={!row.dispatcherId}
onSave={(id, name) => patchAndMaybeAuto({ dispatcherId: id, dispatcherName: name })}
/>
</td>
<td className="group/cell" style={{ padding: "0 14px" }} onClick={(e) => e.stopPropagation()}>
<DateCell
value={row.scheduledOn}
q={search}
highlight={forceScheduleHighlight || row.isPastDue}
allowWeekMode
targetWeek={row.targetWeek}
onSave={(date) => patchAndMaybeAuto(applySchedulePatch(date, false))}
onSaveWeek={(week) =>
patchAndMaybeAuto({
scheduledOn: "",
targetWeek: week,
scheduleWeekOnly: true,
dayGroup: "",
dayLabel: "",
})
}
/>
</td>
<td className="group/cell" style={{ padding: "0 14px" }} onClick={(e) => e.stopPropagation()}>
<DateCell
value={row.dueDate}
q={search}
onSave={(date) => handlers.onPatch(row.id, { dueDate: date })}
/>
</td>
<td className="group/cell" style={{ padding: "0 14px" }} onClick={(e) => e.stopPropagation()}>
<PMTypeCell
value={row.pm}
pmTypes={pmTypes}
q={search}
onSave={(pm) => handlers.onPatch(row.id, { pm })}
/>
</td>
<td className="group/cell" style={{ padding: "0 14px" }} onClick={(e) => e.stopPropagation()}>
<VendorCell
company={row.company}
tech={row.tech}
techPhone={row.techPhone}
vendors={vendors}
q={search}
onSave={(p) =>
handlers.onPatch(row.id, {
company: p.company,
tech: p.tech,
techPhone: p.techPhone,
})
}
/>
</td>
<td className="group/cell" style={{ padding: "0 14px" }} onClick={(e) => e.stopPropagation()}>
<ApptTimeCell
apptTime={row.apptTime}
onSave={(v) => handlers.onPatch(row.id, { apptTime: v })}
/>
</td>
<td className="group/cell" style={{ padding: "0 14px" }} onClick={(e) => e.stopPropagation()}>
<StatusCell
wo={row}
onChangeStatus={(status) => handlers.onPatch(row.id, { status })}
onOpenSlideOver={() => handlers.onOpenSlideOver(row)}
onPastDueAttempt={() => handlers.onPastDueAttempt?.(row.id)}
/>
</td>
<td style={{ padding: "0 14px" }} onClick={(e) => e.stopPropagation()}>
<CompDocCell
status={row.docStatus}
woType={row.type}
mediaCount={row.mediaCount}
onOpen={() => handlers.onOpenCompDoc(row)}
onOpenMedia={() => handlers.onOpenMedia(row)}
/>
</td>
<td style={{ padding: "0 8px" }} onClick={(e) => e.stopPropagation()}>
<WoTableRowActions
onView={() => handlers.onView(row)}
onEdit={() => handlers.onEdit(row)}
/>
</td>
</tr>
);
}

View file

@ -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 (
<tr style={{ background: "rgba(217, 119, 6, 0.08)" }}>
<td
colSpan={WO_TABLE_COLS.length}
onClick={onToggleCollapse}
style={{
height: 40,
padding: "0 14px",
cursor: "pointer",
boxShadow: "inset 3px 0 0 var(--warning)",
borderTop: "1px solid #FDE68A",
borderBottom: "1px solid #FDE68A",
}}
>
<div className="flex items-center gap-2.5">
<AlertTriangle size={14} className="shrink-0" style={{ color: "var(--warning)" }} />
<span
className="uppercase"
style={{
fontFamily: "var(--font-mono)",
fontSize: 12,
fontWeight: 700,
letterSpacing: "0.08em",
color: "var(--warning)",
}}
>
{label}
</span>
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
{count} work order{count !== 1 ? "s" : ""}
</span>
<button
type="button"
className="flex items-center gap-1 transition-opacity hover:opacity-70"
style={{ fontSize: 12.5, fontWeight: 600, color: "var(--primary)" }}
onClick={(e) => {
e.stopPropagation();
onAddWo();
}}
>
<Plus size={13} />
Add WO
</button>
<span className="ml-auto flex items-center" style={{ color: "var(--warning)" }}>
{collapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
</span>
</div>
</td>
</tr>
);
}
return (
<tr style={{ background: "#eef1f6" }}>
<td
colSpan={WO_TABLE_COLS.length}
style={{
height: 40,
padding: "0 14px",
borderTop: "1px solid var(--border)",
borderBottom: "1px solid var(--border)",
}}
>
<div className="flex items-center gap-2.5">
<span
className="uppercase"
style={{
fontFamily: "var(--font-sans)",
fontSize: 12.5,
fontWeight: 500,
letterSpacing: "0.02em",
color: "var(--muted-foreground)",
}}
>
{label}
</span>
<span
style={{
fontSize: 11,
fontWeight: 500,
color: "var(--muted-foreground)",
}}
>
{count} WO{count !== 1 ? "s" : ""}
</span>
{isToday && (
<span
className="uppercase"
style={{
fontFamily: "var(--font-mono)",
fontSize: 9,
fontWeight: 700,
color: "#ffffff",
background: "var(--success)",
borderRadius: 9999,
padding: "1px 5px",
letterSpacing: "0.04em",
}}
>
Today
</span>
)}
<button
type="button"
className="flex items-center gap-1 transition-opacity hover:opacity-70"
style={{ fontSize: 12.5, fontWeight: 600, color: "var(--primary)" }}
onClick={(e) => {
e.stopPropagation();
onAddWo();
}}
>
<Plus size={13} />
Add WO
</button>
</div>
</td>
</tr>
);
}

View file

@ -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<StatusVariant, WoTableStatusStyle> = {
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];
}

View file

@ -0,0 +1,14 @@
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
export const WO_TABLE_TYPE_TEXT: Record<WOType, string> = {
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];
}

View file

@ -0,0 +1,3 @@
import type { useWorkOrdersTableData } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-data";
export type ReturnTypeOfUseWorkOrdersTableData = ReturnType<typeof useWorkOrdersTableData>;

View file

@ -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 (
<div className="min-h-0 flex-1 overflow-hidden px-2 py-4">
<div className="relative h-full overflow-auto" style={CARD_STYLE}>
{children}
</div>
</div>
);
}
function WoTableEmptyState({
message,
actionLabel,
onAction,
}: {
message: string;
actionLabel: string;
onAction?: () => void;
}) {
return (
<div
className="flex h-full flex-col items-center justify-center text-center"
style={{ gap: 14, padding: 48 }}
>
<SearchX size={34} style={{ color: "var(--color-text-muted)" }} />
<p style={{ fontSize: 14, color: "var(--muted-foreground)" }}>{message}</p>
{onAction && (
<button
type="button"
onClick={onAction}
className="transition-opacity hover:opacity-80"
style={{ fontSize: 13, fontWeight: 500, color: "var(--color-accent)" }}
>
{actionLabel}
</button>
)}
</div>
);
}
export function WoTable({
tableData,
users,
sites,
vendors,
pmTypes,
onOpenSlideOver,
onOpenCompDoc,
onOpenMedia,
onClearSearch,
onOpenAdvancedFilters,
}: WoTableProps) {
const [scheduleHighlightId, setScheduleHighlightId] = useState<string | number | null>(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<WorkOrderTableRow>) => {
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) => (
<WoTableRow
key={String(row.id)}
row={row}
allRows={tableData.rows}
users={users}
sites={sites}
vendors={vendors}
pmTypes={pmTypes}
search={tableData.search}
forceScheduleHighlight={forceScheduleHighlight || scheduleHighlightId === row.id}
flagColor={tableData.flags[String(row.id)]}
isSelected={tableData.selectedId === row.id}
isFlashing={tableData.flashId === row.id}
dragViz={tableData.dragViz}
handlers={handlers}
/>
);
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 (
<WoTableShell>
<div className="flex h-full items-center justify-center p-12">
<CircularProgress />
</div>
</WoTableShell>
);
}
const emptyAdv = tableData.advActive && tableData.advResults.length === 0;
if (emptyAdv) {
return (
<WoTableShell>
<WoTableEmptyState
message="No work orders match these filters"
actionLabel="Adjust filters"
onAction={onOpenAdvancedFilters}
/>
</WoTableShell>
);
}
if (!tableData.advActive && tableData.noSearchResults) {
return (
<WoTableShell>
<WoTableEmptyState
message="No work orders match your search"
actionLabel="Clear search"
onAction={onClearSearch}
/>
</WoTableShell>
);
}
return (
<WoTableShell>
<table
style={{
width: "100%",
minWidth: WO_TABLE_MIN_WIDTH,
borderCollapse: "collapse",
}}
>
<WoTableHead />
<tbody>
{tableData.advActive ? (
tableData.advResults.map((row) => renderRow(row))
) : (
<>
{(tableData.unscheduled.length > 0 ||
tableData.draftDayKey === UNSCHEDULED_DAY_KEY) && (
<>
<WoTableSectionHeader
variant="unscheduled"
label="Unscheduled"
count={tableData.unscheduled.length}
collapsed={!tableData.unschedOpen}
onToggleCollapse={() => tableData.setUnschedOpen((v) => !v)}
onAddWo={() => tableData.startDayDraft(UNSCHEDULED_DAY_KEY)}
/>
{tableData.unschedOpen && (
<>
{tableData.draftDayKey === UNSCHEDULED_DAY_KEY && (
<WoTableInlineRow
draft={tableData.draft}
draftDayKey={tableData.draftDayKey}
users={users}
sites={sites}
vendors={vendors}
pmTypes={pmTypes}
allRows={tableData.rows}
onChange={tableData.setDraft}
onSave={handleSaveDraft}
onCancel={tableData.cancelDraft}
/>
)}
{tableData.unscheduled.map((row) => renderRow(row, true))}
</>
)}
</>
)}
{tableData.displayGroups.map((group) => (
<Fragment key={group.key}>
<WoTableSectionHeader
variant="day"
label={group.label}
count={group.items.length}
isToday={group.key === tableData.todayKey}
onAddWo={() => tableData.startDayDraft(group.key)}
/>
{tableData.draftDayKey === group.key && (
<WoTableInlineRow
draft={tableData.draft}
draftDayKey={tableData.draftDayKey}
users={users}
sites={sites}
vendors={vendors}
pmTypes={pmTypes}
allRows={tableData.rows}
onChange={tableData.setDraft}
onSave={handleSaveDraft}
onCancel={tableData.cancelDraft}
/>
)}
{group.items.map((row) => renderRow(row))}
</Fragment>
))}
</>
)}
</tbody>
</table>
</WoTableShell>
);
}

View file

@ -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<WorkOrdersSortableColumn | "">(initialColumn);
const [sortDir, setSortDir] = useState<SortDirection>("");
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,
};
}

View file

@ -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<typeof useWorkOrdersListFilters>;
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<Record<string, Partial<WorkOrderTableRow>>>({});
const [localRows, setLocalRows] = useState<WorkOrderTableRow[]>([]);
const [rowOrder, setRowOrder] = useState<string[] | null>(null);
const [flags, setFlagsState] = useState<Record<string, string>>({});
const [draftDayKey, setDraftDayKey] = useState<string | null>(null);
const [draft, setDraft] = useState<WorkOrderTableDraft>(EMPTY_TABLE_DRAFT);
const [unschedOpen, setUnschedOpen] = useState(true);
const [selectedId, setSelectedId] = useState<string | number | null>(null);
const [flashId, setFlashId] = useState<string | number | null>(null);
const [dragViz, setDragViz] = useState<DragVizState>({ dragId: null, overId: null });
const dragState = useRef<DragVizState>({ 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<WorkOrderTableRow>) => {
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,
};
}