mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-01 12:03:11 +00:00
feat(work-orders): add board table core and filters
This commit is contained in:
parent
af0e4aa1a6
commit
1cb8362d76
13 changed files with 1523 additions and 0 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import type { useWorkOrdersTableData } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-data";
|
||||
|
||||
export type ReturnTypeOfUseWorkOrdersTableData = ReturnType<typeof useWorkOrdersTableData>;
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
58
src/app/(protected)/workorders/_hooks/use-table-sort.ts
Normal file
58
src/app/(protected)/workorders/_hooks/use-table-sort.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue