diff --git a/src/app/(protected)/workorders/[id].tsx b/src/app/(protected)/workorders/[id].tsx
index 55a32726..a282040c 100644
--- a/src/app/(protected)/workorders/[id].tsx
+++ b/src/app/(protected)/workorders/[id].tsx
@@ -192,7 +192,10 @@ export default function WorkOrderViewPage() {
}
commentMutation.mutate(
- { text: commentText.trim(), commentType: activeTab },
+ {
+ text: commentText.trim(),
+ commentType: activeTab === "customer" ? "customer" : "internal",
+ },
{ onSuccess: () => setCommentText("") },
);
};
diff --git a/src/app/(protected)/workorders/_components/dispatch-create-modal.tsx b/src/app/(protected)/workorders/_components/dispatch-create-modal.tsx
index 1595d50a..5dd6952a 100644
--- a/src/app/(protected)/workorders/_components/dispatch-create-modal.tsx
+++ b/src/app/(protected)/workorders/_components/dispatch-create-modal.tsx
@@ -96,7 +96,7 @@ export function DispatchCreateModal({
.filter(
(item) =>
!woSearch ||
- item.number.includes(woSearch) ||
+ item.woNumber.includes(woSearch) ||
item.title.toLowerCase().includes(woSearch.toLowerCase()),
)
.slice(0, 10),
@@ -180,13 +180,13 @@ export function DispatchCreateModal({
onClick={() => {
setSelectedWOs((prev) => [
...prev,
- { id: item.id, number: item.number, title: item.title },
+ { id: item.id, number: item.woNumber, title: item.title },
]);
setWoSearch("");
}}
>
- {item.number} — {item.title}
+ {item.woNumber} — {item.title}
))}
diff --git a/src/app/(protected)/workorders/index.tsx b/src/app/(protected)/workorders/index.tsx
index c33f1a2e..5fabb151 100644
--- a/src/app/(protected)/workorders/index.tsx
+++ b/src/app/(protected)/workorders/index.tsx
@@ -28,7 +28,7 @@ import {
Typography,
} from "@mui/material";
import { useDebounce } from "@/hooks/use-debounce";
-import type { WorkOrderListItem } from "@/domain/work-orders/types/work-order";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import {
useWorkOrderUsers,
useWorkOrdersList,
@@ -271,7 +271,7 @@ export default function WorkOrdersListPage() {
) : (
- rows.map((row: WorkOrderListItem) => (
+ rows.map((row: WorkOrderTableRow) => (
{row.title}
@@ -290,12 +290,10 @@ export default function WorkOrdersListPage() {
- {formatDateTime(row.createdDate)}
-
- {row.assignedTo || "Unassigned"}
-
- {formatDateTime(row.lastUpdated)}
+ {formatDateTime(row.scheduledOn)}
+ {row.dispatcherName || "Unassigned"}
+ {formatDateTime(row.dueDate)}
{
const searchParams: Record = {
@@ -56,6 +71,18 @@ function buildSearchParams(params: WorkOrdersListParams): Record {
+ await new Promise((resolve) => setTimeout(resolve, 250));
+}
+
export const workOrdersApi = {
getList: async (params: WorkOrdersListParams = {}): Promise => {
+ if (isWoTableMockEnabled()) {
+ await mockNetworkDelay();
+ return getMockWorkOrdersList(params);
+ }
+
const searchParams = buildSearchParams(params);
const data = await apiGet(API_PATHS.workOrder.getList, { searchParams });
const paginated = handlePaginatedResponse(data, {
@@ -85,20 +121,45 @@ export const workOrdersApi = {
});
return {
- items: mapWorkOrderListItems(paginated.items),
+ items: mapWorkOrderTableRows(paginated.items),
totalCount: paginated.totalCount,
page: paginated.page,
pageSize: paginated.pageSize,
};
},
+ getListByLocation: async (
+ locationId: string | number,
+ search = "",
+ ): Promise => {
+ return workOrdersApi.getList({
+ locationId,
+ search,
+ page: 1,
+ pageSize: DISPATCH_LOCATION_WO_PAGE_SIZE,
+ sortBy: "number",
+ sortDir: "asc",
+ });
+ },
+
fetchUsers: async (): Promise => {
+ if (isWoTableMockEnabled()) {
+ return MOCK_WO_TABLE_USERS;
+ }
const data = await apiGet(API_PATHS.user.list);
const users = handleApiResponse(data);
return Array.isArray(users) ? users.map(mapUser) : [];
},
getById: async (id: string | number): Promise => {
+ if (shouldUseMockWorkOrderDetail(id)) {
+ await mockNetworkDelay();
+ const row = getMockWorkOrderTableRow(id);
+ if (!row) {
+ throw new Error("Mock work order not found");
+ }
+ return buildMockWorkOrderDetailFromTableRow(row);
+ }
const data = await apiGet(API_PATHS.workOrder.getById, {
searchParams: { id },
});
@@ -117,6 +178,22 @@ export const workOrdersApi = {
id: string | number,
values: WorkOrderFormSchemaValues | WorkOrderFormValues,
): Promise => {
+ if (shouldUseMockWorkOrderDetail(id)) {
+ await mockNetworkDelay();
+ const row = getMockWorkOrderTableRow(id);
+ if (!row) {
+ throw new Error("Mock work order not found");
+ }
+ return buildMockWorkOrderDetailFromTableRow({
+ ...row,
+ woNumber: values.workOrderNumber || row.woNumber,
+ dueDate: values.dueDate || row.dueDate,
+ scheduledOn: values.scheduledDate || row.scheduledOn,
+ dispatcherName: values.assignedTo || row.dispatcherName,
+ pm: values.problem || row.pm,
+ type: (values.source || row.type) as typeof row.type,
+ });
+ }
const payload = mapWorkOrderFormToUpdatePayload(id, values);
const data = await apiPost(API_PATHS.workOrder.editWorkorder, payload);
return mapWorkOrderDetail(handleApiResponse(data));
@@ -142,50 +219,44 @@ export const workOrdersApi = {
},
changeStatus: async (id: string | number, status: string): Promise => {
+ if (shouldUseMockWorkOrderDetail(id)) {
+ await mockNetworkDelay();
+ return;
+ }
await apiPost(API_PATHS.workOrder.changeStatus, undefined, {
searchParams: { id, status },
});
},
changeAssignment: async (id: string | number, assignTo: string | number): Promise => {
+ if (shouldUseMockWorkOrderDetail(id)) {
+ await mockNetworkDelay();
+ return;
+ }
await apiPost(API_PATHS.workOrder.changeAssignment, undefined, {
searchParams: { id, assignTo: assignTo || "" },
});
},
fetchLocations: async (): Promise => {
+ if (isWoTableMockEnabled()) {
+ return getMockWorkOrderLocations();
+ }
const data = await apiGet(API_PATHS.contact.getLocations);
const items = handleApiResponse(data);
return Array.isArray(items) ? mapLocationOptions(items) : [];
},
fetchEmployeesDropdown: async (): Promise => {
- try {
- const data = await apiGet(API_PATHS.employee.dropdown);
- const items = handleApiResponse(data);
- return Array.isArray(items) ? mapEmployeeOptions(items) : [];
- } catch {
- const data = await apiGet(API_PATHS.employee.getList, {
- searchParams: { page: 1, pageSize: 1000 },
- });
- const paginated = handlePaginatedResponse(data);
- const items = paginated.items.map((item) => {
- const record = (typeof item === "object" && item !== null ? item : {}) as Record<
- string,
- unknown
- >;
- const firstName = String(record.firstName ?? record.FirstName ?? "");
- const lastName = String(record.lastName ?? record.LastName ?? "");
- return {
- id: record.id ?? record.Id,
- name: `${firstName} ${lastName}`.trim(),
- };
- });
- return mapEmployeeOptions(items);
- }
+ const data = await apiGet(API_PATHS.employee.dropdown);
+ const items = handleApiResponse(data);
+ return Array.isArray(items) ? mapEmployeeOptions(items) : [];
},
fetchVendorsDropdown: async (trade?: string, siteZip?: string): Promise => {
+ if (isWoTableMockEnabled()) {
+ return getMockWorkOrderVendors();
+ }
const searchParams: Record = {};
if (trade) {
searchParams.trade = trade;
diff --git a/src/domain/work-orders/mappers/wizard-draft-mapper.ts b/src/domain/work-orders/mappers/wizard-draft-mapper.ts
new file mode 100644
index 00000000..d15215c4
--- /dev/null
+++ b/src/domain/work-orders/mappers/wizard-draft-mapper.ts
@@ -0,0 +1,122 @@
+import type { WorkOrderFormValues } from "@/domain/work-orders/types/work-order-detail";
+import type {
+ WOType,
+ WizardWOStatus,
+ WorkOrderWizardDraft,
+} from "@/domain/work-orders/types/work-order-wizard";
+import type { WorkOrderStatus } from "@/domain/work-orders/types/work-order-common";
+import { formatApptRange, todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
+
+const WIZARD_TO_API_STATUS: Record = {
+ Incomplete: "Open",
+ Pending: "Open",
+ Scheduled: "Open",
+ "En Route": "In Progress",
+ "On Site": "In Progress",
+ "In Progress": "In Progress",
+ Completed: "Done",
+ Rescheduled: "On Hold",
+ "Pending Quote": "On Hold",
+};
+
+const API_TO_WIZARD_STATUS: Partial> = {
+ Open: "Incomplete",
+ "In Progress": "In Progress",
+ "On Hold": "Pending",
+ Done: "Completed",
+};
+
+const TYPE_TO_PRIORITY: Record = {
+ Emergency: "Sev 1",
+ Reactive: "Sev 2",
+ Overdue: "Sev 2",
+ "Add-On": "Sev 3",
+ PM: "Sev 3",
+};
+
+export function mapWizardStatusToApi(status: WizardWOStatus | ""): WorkOrderStatus {
+ if (!status) {
+ return "Open";
+ }
+ return WIZARD_TO_API_STATUS[status] ?? "Open";
+}
+
+export function mapApiStatusToWizard(status: string): WizardWOStatus | "" {
+ if (!status) {
+ return "";
+ }
+ if (API_TO_WIZARD_STATUS[status as WorkOrderStatus]) {
+ return API_TO_WIZARD_STATUS[status as WorkOrderStatus]!;
+ }
+ const wizardStatuses = Object.keys(WIZARD_TO_API_STATUS) as WizardWOStatus[];
+ if (wizardStatuses.includes(status as WizardWOStatus)) {
+ return status as WizardWOStatus;
+ }
+ if (status === "Canceled" || status === "Cancelled") {
+ return "Canceled" as WizardWOStatus;
+ }
+ return "Incomplete";
+}
+
+export function mapTypeToPriority(type: WOType | ""): string {
+ if (!type) {
+ return "Sev 3";
+ }
+ return TYPE_TO_PRIORITY[type];
+}
+
+function buildTitle(draft: WorkOrderWizardDraft): string {
+ if (draft.woNumber) {
+ return `${draft.type || "Work Order"} — #${draft.woNumber}`;
+ }
+ if (draft.locationName) {
+ return `${draft.type || "Work Order"} — ${draft.locationName}`;
+ }
+ return draft.type || "Work Order";
+}
+
+function buildDescription(draft: WorkOrderWizardDraft): string {
+ const parts: string[] = [];
+
+ if (draft.pocNotes) {
+ parts.push(`POC notes: ${draft.pocNotes}`);
+ }
+ if (draft.pocName || draft.pocPhone) {
+ parts.push(`POC: ${[draft.pocName, draft.pocPhone].filter(Boolean).join(" · ")}`);
+ }
+ if (draft.apptStart) {
+ parts.push(`Appt: ${formatApptRange(draft.apptStart, draft.apptEnd)}`);
+ }
+ if (draft.vendorNotes) {
+ parts.push(`Vendor notes: ${draft.vendorNotes}`);
+ }
+ if (draft.vendorName) {
+ parts.push(`Vendor: ${draft.vendorName}`);
+ }
+
+ return parts.join("\n");
+}
+
+export function mapWizardDraftToFormValues(draft: WorkOrderWizardDraft): WorkOrderFormValues {
+ return {
+ internalWONumber: "",
+ workOrderNumber: draft.woNumber,
+ title: buildTitle(draft),
+ locationId: draft.locationId,
+ priority: mapTypeToPriority(draft.type),
+ status: mapWizardStatusToApi(draft.status),
+ createdDate: todayIso(),
+ dueDate: draft.dueDate,
+ scheduledDate: draft.scheduleWeekOnly ? "" : draft.scheduledOn,
+ completedDate: "",
+ assignedTo: draft.assigneeName,
+ description: buildDescription(draft),
+ po: "",
+ tt: "",
+ problem: draft.pm,
+ trade: "",
+ subTrade: "",
+ vendorNTE: "",
+ source: draft.type,
+ };
+}
diff --git a/src/domain/work-orders/mappers/work-order-mapper.ts b/src/domain/work-orders/mappers/work-order-mapper.ts
index 3e6cd5e3..687aa3b1 100644
--- a/src/domain/work-orders/mappers/work-order-mapper.ts
+++ b/src/domain/work-orders/mappers/work-order-mapper.ts
@@ -1,3 +1,4 @@
+import { mapApiStatusToWizard } from "@/domain/work-orders/mappers/wizard-draft-mapper";
import type {
DispatchChecklistItem,
DispatchComment,
@@ -9,7 +10,6 @@ import type {
LocationOption,
TaskListTemplate,
UpdateDispatchPayload,
- UpliftRequest,
VendorDropdownItem,
WorkOrderAuditEntry,
WorkOrderComment,
@@ -18,12 +18,29 @@ import type {
WorkOrderFormValues,
WorkOrderListItem,
} from "@/domain/work-orders/types/work-order";
+import type {
+ CompletionDocStatus,
+ WorkOrderTableRow,
+} from "@/domain/work-orders/types/work-order-table-row";
+import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
+import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
+import { formatApptRange } from "@/domain/work-orders/utils/wizard-date-utils";
+import type { UpliftRequest } from "@/domain/uplifts/types/uplift";
+import type { WorkOrderStatus } from "@/domain/work-orders/types/work-order-common";
+import { WORK_ORDER_STATUS_OPTIONS } from "@/domain/work-orders/types/work-order-common";
import { toDateInputValue } from "@/lib/time-utils";
function asRecord(raw: unknown): Record {
return typeof raw === "object" && raw !== null ? (raw as Record) : {};
}
+function toWorkOrderStatus(value: string): WorkOrderStatus {
+ if (WORK_ORDER_STATUS_OPTIONS.includes(value as WorkOrderStatus)) {
+ return value as WorkOrderStatus;
+ }
+ return "Open";
+}
+
function readString(record: Record, ...keys: string[]): string {
for (const key of keys) {
const value = record[key];
@@ -92,6 +109,114 @@ export function mapWorkOrderListItems(rawItems: unknown[]): WorkOrderListItem[]
return rawItems.map(mapWorkOrderListItem);
}
+const WO_TYPES_SET = new Set(["PM", "Reactive", "Emergency", "Add-On", "Overdue"]);
+
+function readWoType(record: Record): WOType | "" {
+ const raw = readString(record, "type", "Type", "source", "Source", "woType", "WoType");
+ if (WO_TYPES_SET.has(raw)) {
+ return raw as WOType;
+ }
+ return "";
+}
+
+function readDocStatus(record: Record): CompletionDocStatus {
+ const raw = readString(record, "docStatus", "DocStatus", "completionDoc", "CompletionDoc");
+ if (raw === "Yes" || raw === "No" || raw === "NN") {
+ return raw;
+ }
+ return "";
+}
+
+export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
+ const item = asRecord(raw);
+ const scheduledOn = toDateInputValue(
+ readString(item, "scheduledOn", "scheduledDate", "ScheduledDate", "ScheduledOn"),
+ );
+ const { dayGroup, dayLabel } = deriveDayFields(scheduledOn);
+ const woNumber = readString(
+ item,
+ "woNumber",
+ "internalWONumber",
+ "InternalWONumber",
+ "number",
+ "workOrderNumber",
+ "WorkerOrderNumber",
+ );
+ const site = readString(item, "site", "Site", "locationCode", "LocationCode");
+ const location = readString(item, "location", "Location", "locationName", "LocationName");
+ const dispatcherId = readString(
+ item,
+ "dispatcherId",
+ "DispatcherId",
+ "assigneeId",
+ "AssigneeId",
+ "employeeId",
+ "EmployeeId",
+ );
+ const dispatcherName = readString(
+ item,
+ "dispatcherName",
+ "DispatcherName",
+ "assignedTo",
+ "AssignedTo",
+ "employeeName",
+ "EmployeeName",
+ "assignee",
+ "Assignee",
+ );
+ const apptStart = readString(item, "apptStart", "ApptStart", "appointmentStart");
+ const apptEnd = readString(item, "apptEnd", "ApptEnd", "appointmentEnd");
+ const apptTime =
+ readString(item, "apptTime", "ApptTime") ||
+ (apptStart ? formatApptRange(apptStart, apptEnd) : "");
+ const apiStatus = readString(item, "status", "Status");
+ const locationIdRaw = item.locationId ?? item.LocationId;
+
+ const row: WorkOrderTableRow = {
+ id: readId(item),
+ woNumber,
+ site: site || location.slice(0, 8),
+ type: readWoType(item),
+ dispatcherId,
+ dispatcherName,
+ scheduledOn,
+ targetWeek: readString(item, "targetWeek", "TargetWeek"),
+ dayGroup,
+ dayLabel,
+ location,
+ locationId:
+ typeof locationIdRaw === "string" || typeof locationIdRaw === "number" ? locationIdRaw : "",
+ pm: readString(item, "pm", "PM", "problem", "Problem", "service", "Service"),
+ company: readString(item, "company", "Company", "vendorName", "VendorName"),
+ tech: readString(item, "tech", "Tech", "technician", "Technician"),
+ techPhone: readString(item, "techPhone", "TechPhone"),
+ apptStart,
+ apptEnd,
+ apptTime,
+ status: mapApiStatusToWizard(apiStatus),
+ docStatus: readDocStatus(item),
+ pocName: readString(item, "pocName", "PocName", "POCName"),
+ pocPhone: readString(item, "pocPhone", "PocPhone", "POCPhone"),
+ pocNotes: readString(item, "pocNotes", "PocNotes", "POCNotes"),
+ dueDate: toDateInputValue(readString(item, "dueDate", "DueDate")),
+ rescheduleCount: readNumber(item, "rescheduleCount", "RescheduleCount") ?? 0,
+ carriedOver: readNumber(item, "carriedOver", "CarriedOver") ?? 0,
+ originalWeek: readString(item, "originalWeek", "OriginalWeek"),
+ originalDate: toDateInputValue(readString(item, "originalDate", "OriginalDate")),
+ isPastDue: readBool(item, "isPastDue", "IsPastDue"),
+ scheduleWeekOnly: readBool(item, "scheduleWeekOnly", "ScheduleWeekOnly"),
+ mediaCount: readNumber(item, "mediaCount", "MediaCount") ?? 0,
+ title: readString(item, "title", "Title", "workerOrderTitle", "WorkerOrderTitle"),
+ priority: readString(item, "priority", "Priority"),
+ };
+
+ return row;
+}
+
+export function mapWorkOrderTableRows(rawItems: unknown[]): WorkOrderTableRow[] {
+ return rawItems.map(mapWorkOrderTableRow);
+}
+
export function mapWorkOrderComment(raw: unknown): WorkOrderComment {
const item = asRecord(raw);
@@ -187,7 +312,7 @@ export function mapWorkOrderDetailToFormValues(detail: WorkOrderDetail): WorkOrd
title: detail.title,
locationId: detail.locationId,
priority: detail.priority,
- status: detail.status,
+ status: toWorkOrderStatus(detail.status),
createdDate: detail.createdDate,
dueDate: detail.dueDate,
scheduledDate: detail.scheduledDate,
diff --git a/src/domain/work-orders/mocks/dispatcher-filter-mock-data.ts b/src/domain/work-orders/mocks/dispatcher-filter-mock-data.ts
new file mode 100644
index 00000000..d54cf96a
--- /dev/null
+++ b/src/domain/work-orders/mocks/dispatcher-filter-mock-data.ts
@@ -0,0 +1,45 @@
+import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
+
+export type MockDispatcherUser = WorkOrderUser & { color: string };
+
+/** UI-only dispatchers until the list filter is wired to the API. */
+export const MOCK_DISPATCHER_USERS: MockDispatcherUser[] = [
+ { id: "mock-dispatcher-sarah", name: "Sarah Chen", color: "#F59E0B" },
+ { id: "mock-dispatcher-alyssa", name: "Alyssa Reeves", color: "#0D9488" },
+ { id: "mock-dispatcher-derek", name: "Derek Walsh", color: "#6366F1" },
+ { id: "mock-dispatcher-morgan", name: "Morgan Blake", color: "#F43F5E" },
+ { id: "mock-dispatcher-james", name: "James Torres", color: "#7C3AED" },
+ { id: "mock-dispatcher-rachel", name: "Rachel Kim", color: "#0EA5E9" },
+ { id: "mock-dispatcher-tiffany", name: "Tiffany Moore", color: "#16A34A" },
+ { id: "mock-dispatcher-brandon", name: "Brandon Lee", color: "#EA580C" },
+];
+
+const MOCK_DISPATCHER_COLOR_BY_ID = Object.fromEntries(
+ MOCK_DISPATCHER_USERS.map((dispatcher) => [dispatcher.id, dispatcher.color]),
+) as Record;
+
+const FALLBACK_AVATAR_COLORS = [
+ "#F59E0B",
+ "#0D9488",
+ "#6366F1",
+ "#F43F5E",
+ "#7C3AED",
+ "#0EA5E9",
+ "#16A34A",
+ "#EA580C",
+];
+
+function paletteColorFromKey(key: string): string {
+ let hash = 0;
+ for (let i = 0; i < key.length; i += 1) {
+ hash = key.charCodeAt(i) + ((hash << 5) - hash);
+ }
+ return FALLBACK_AVATAR_COLORS[Math.abs(hash) % FALLBACK_AVATAR_COLORS.length];
+}
+
+export function getDispatcherAvatarColor(user: WorkOrderUser): string {
+ return (
+ MOCK_DISPATCHER_COLOR_BY_ID[String(user.id)] ??
+ paletteColorFromKey(String(user.id || user.name))
+ );
+}
diff --git a/src/domain/work-orders/mocks/is-wo-table-mock-enabled.ts b/src/domain/work-orders/mocks/is-wo-table-mock-enabled.ts
new file mode 100644
index 00000000..5daac141
--- /dev/null
+++ b/src/domain/work-orders/mocks/is-wo-table-mock-enabled.ts
@@ -0,0 +1,10 @@
+import { isAuthBypassEnabled } from "@/lib/auth/auth-bypass";
+
+/** Use seeded WO table rows instead of GetWorkOrderList (dev / auth bypass). */
+export function isWoTableMockEnabled(): boolean {
+ return isAuthBypassEnabled() || import.meta.env.VITE_WO_TABLE_MOCK === "true";
+}
+
+export function shouldUseMockWorkOrderDetail(id: string | number): boolean {
+ return isWoTableMockEnabled() && String(id).startsWith("mock-wo-");
+}
diff --git a/src/domain/work-orders/mocks/is-wo-wizard-mock-enabled.ts b/src/domain/work-orders/mocks/is-wo-wizard-mock-enabled.ts
new file mode 100644
index 00000000..9420dd2e
--- /dev/null
+++ b/src/domain/work-orders/mocks/is-wo-wizard-mock-enabled.ts
@@ -0,0 +1,10 @@
+import { env } from "@/lib/env";
+import { isAuthBypassEnabled } from "@/lib/auth/auth-bypass";
+
+export function isWoWizardMockEnabled(): boolean {
+ return env.isDev;
+}
+
+export function shouldUseMockWoWizardCreate(locationId: string): boolean {
+ return isAuthBypassEnabled() || locationId.startsWith("mock-");
+}
diff --git a/src/domain/work-orders/mocks/wo-table-mock-data.ts b/src/domain/work-orders/mocks/wo-table-mock-data.ts
new file mode 100644
index 00000000..d1860745
--- /dev/null
+++ b/src/domain/work-orders/mocks/wo-table-mock-data.ts
@@ -0,0 +1,521 @@
+import type {
+ LocationOption,
+ VendorDropdownItem,
+ WorkOrderDetail,
+ WorkOrderUser,
+} from "@/domain/work-orders/types/work-order";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+import type { WorkOrdersListParams } from "@/domain/work-orders/types/work-order-list";
+import { MOCK_DISPATCHER_USERS } from "@/domain/work-orders/mocks/dispatcher-filter-mock-data";
+import {
+ MOCK_WO_WIZARD_LOCATIONS,
+ MOCK_WO_WIZARD_SERVICES,
+ MOCK_WO_WIZARD_VENDORS,
+} from "@/domain/work-orders/mocks/wo-wizard-mock-data";
+import { currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils";
+import { deriveDayFields, isDateInWeek } from "@/domain/work-orders/utils/table-day-utils";
+import { enrichTableRow } from "@/domain/work-orders/utils/table-filter-utils";
+import { addDaysIso, todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
+
+export const MOCK_WO_TABLE_USERS: WorkOrderUser[] = MOCK_DISPATCHER_USERS.map(({ id, name }) => ({
+ id,
+ name,
+}));
+
+function sched(iso: string): Pick {
+ const { dayGroup, dayLabel } = deriveDayFields(iso);
+ return { scheduledOn: iso, dayGroup, dayLabel };
+}
+
+type RowSeed = {
+ id: string | number;
+ woNumber: string;
+ site: string;
+ location: string;
+ locationId: string | number;
+ type: WorkOrderTableRow["type"];
+ dispatcherId: string;
+ dispatcherName: string;
+ scheduledOn?: string;
+ targetWeek?: string;
+ dueDate: string;
+ pm: string;
+ company: string;
+ tech: string;
+ techPhone: string;
+ apptStart: string;
+ apptEnd: string;
+ apptTime: string;
+ status: WorkOrderTableRow["status"];
+ docStatus: WorkOrderTableRow["docStatus"];
+ pocName: string;
+ pocPhone: string;
+ pocNotes: string;
+ rescheduleCount: number;
+ carriedOver: number;
+ originalWeek: string;
+ originalDate: string;
+ scheduleWeekOnly: boolean;
+ mediaCount: number;
+ title?: string;
+ priority?: string;
+};
+
+function buildRow(seed: RowSeed): WorkOrderTableRow {
+ const scheduledOn = seed.scheduledOn ?? "";
+ const dayFields = scheduledOn ? sched(scheduledOn) : deriveDayFields("");
+ return enrichTableRow({
+ title: seed.title ?? `${seed.type || "Work Order"} — ${seed.site}`,
+ priority: seed.priority ?? "Sev 3",
+ ...seed,
+ ...dayFields,
+ scheduledOn,
+ targetWeek: seed.targetWeek ?? "",
+ isPastDue: false,
+ });
+}
+
+function buildMockRows(): WorkOrderTableRow[] {
+ const mon = currentWeekMonday();
+ const tue = addDaysIso(mon, 1);
+ const wed = addDaysIso(mon, 2);
+ const thu = addDaysIso(mon, 3);
+ const fri = addDaysIso(mon, 4);
+ const yesterday = addDaysIso(todayIso(), -1);
+
+ const sarah = MOCK_DISPATCHER_USERS[0];
+ const alyssa = MOCK_DISPATCHER_USERS[1];
+ const derek = MOCK_DISPATCHER_USERS[2];
+ const morgan = MOCK_DISPATCHER_USERS[3];
+
+ return [
+ buildRow({
+ id: "mock-wo-1",
+ woNumber: "20260623001",
+ site: "CHI-1042",
+ location: "Chicago Loop",
+ locationId: "mock-loc-1",
+ type: "PM",
+ dispatcherId: String(sarah.id),
+ dispatcherName: sarah.name,
+ ...sched(mon),
+ dueDate: fri,
+ pm: "HVAC — Quarterly PM",
+ company: "Midwest HVAC Co.",
+ tech: "Mike Torres",
+ techPhone: "(312) 555-0198",
+ apptStart: "09:00",
+ apptEnd: "11:00",
+ apptTime: "09:00 – 11:00",
+ status: "Scheduled",
+ docStatus: "No",
+ pocName: "Maria Gonzalez",
+ pocPhone: "(312) 555-0142",
+ pocNotes: "Loading dock B",
+ rescheduleCount: 0,
+ carriedOver: 0,
+ originalWeek: "",
+ originalDate: "",
+ scheduleWeekOnly: false,
+ mediaCount: 0,
+ }),
+ buildRow({
+ id: "mock-wo-2",
+ woNumber: "20260623002",
+ site: "IND-2208",
+ location: "Indianapolis North",
+ locationId: "mock-loc-2",
+ type: "Emergency",
+ dispatcherId: String(alyssa.id),
+ dispatcherName: alyssa.name,
+ ...sched(tue),
+ dueDate: tue,
+ pm: "Plumbing — Leak repair",
+ company: "Great Lakes Plumbing",
+ tech: "Jen Walsh",
+ techPhone: "(317) 555-4412",
+ apptStart: "08:00",
+ apptEnd: "",
+ apptTime: "08:00",
+ status: "On Site",
+ docStatus: "No",
+ pocName: "Chris Bennett",
+ pocPhone: "(317) 555-2208",
+ pocNotes: "",
+ rescheduleCount: 1,
+ carriedOver: 0,
+ originalWeek: "",
+ originalDate: "",
+ scheduleWeekOnly: false,
+ mediaCount: 3,
+ }),
+ buildRow({
+ id: "mock-wo-3",
+ woNumber: "20260623003",
+ site: "MIL-3315",
+ location: "Milwaukee West",
+ locationId: "mock-loc-3",
+ type: "Reactive",
+ dispatcherId: String(derek.id),
+ dispatcherName: derek.name,
+ ...sched(wed),
+ dueDate: thu,
+ pm: "Electrical — Panel inspection",
+ company: "SparkLine Electric",
+ tech: "Pat Nguyen",
+ techPhone: "",
+ apptStart: "13:00",
+ apptEnd: "15:00",
+ apptTime: "13:00 – 15:00",
+ status: "In Progress",
+ docStatus: "Yes",
+ pocName: "Taylor Brooks",
+ pocPhone: "(414) 555-3315",
+ pocNotes: "Badge required at front desk",
+ rescheduleCount: 0,
+ carriedOver: 0,
+ originalWeek: "",
+ originalDate: "",
+ scheduleWeekOnly: false,
+ mediaCount: 0,
+ }),
+ buildRow({
+ id: "mock-wo-4",
+ woNumber: "20260623004",
+ site: "CHI-1042",
+ location: "Chicago Loop",
+ locationId: "mock-loc-1",
+ type: "Add-On",
+ dispatcherId: String(morgan.id),
+ dispatcherName: morgan.name,
+ ...sched(thu),
+ dueDate: fri,
+ pm: "Roof — Membrane patch",
+ company: "Midwest HVAC Co.",
+ tech: "Mike Torres",
+ techPhone: "(312) 555-0198",
+ apptStart: "10:30",
+ apptEnd: "12:00",
+ apptTime: "10:30 – 12:00",
+ status: "Pending",
+ docStatus: "NN",
+ pocName: "Maria Gonzalez",
+ pocPhone: "(312) 555-0142",
+ pocNotes: "",
+ rescheduleCount: 0,
+ carriedOver: 0,
+ originalWeek: "",
+ originalDate: "",
+ scheduleWeekOnly: false,
+ mediaCount: 0,
+ }),
+ buildRow({
+ id: "mock-wo-5",
+ woNumber: "20260623005",
+ site: "IND-2208",
+ location: "Indianapolis North",
+ locationId: "mock-loc-2",
+ type: "PM",
+ dispatcherId: "",
+ dispatcherName: "",
+ ...sched(fri),
+ dueDate: fri,
+ pm: "HVAC — Quarterly PM",
+ company: "",
+ tech: "",
+ techPhone: "",
+ apptStart: "",
+ apptEnd: "",
+ apptTime: "",
+ status: "Incomplete",
+ docStatus: "No",
+ pocName: "",
+ pocPhone: "",
+ pocNotes: "",
+ rescheduleCount: 0,
+ carriedOver: 0,
+ originalWeek: "",
+ originalDate: "",
+ scheduleWeekOnly: false,
+ mediaCount: 0,
+ }),
+ buildRow({
+ id: "mock-wo-6",
+ woNumber: "20260623006",
+ site: "MIL-3315",
+ location: "Milwaukee West",
+ locationId: "mock-loc-3",
+ type: "Overdue",
+ dispatcherId: String(sarah.id),
+ dispatcherName: sarah.name,
+ ...sched(mon),
+ dueDate: yesterday,
+ pm: "Plumbing — Leak repair",
+ company: "Great Lakes Plumbing",
+ tech: "Jen Walsh",
+ techPhone: "(317) 555-4412",
+ apptStart: "07:00",
+ apptEnd: "09:00",
+ apptTime: "07:00 – 09:00",
+ status: "Scheduled",
+ docStatus: "No",
+ pocName: "Taylor Brooks",
+ pocPhone: "(414) 555-3315",
+ pocNotes: "",
+ rescheduleCount: 2,
+ carriedOver: 1,
+ originalWeek: "",
+ originalDate: yesterday,
+ scheduleWeekOnly: false,
+ mediaCount: 0,
+ }),
+ buildRow({
+ id: "mock-wo-7",
+ woNumber: "20260623007",
+ site: "CHI-1042",
+ location: "Chicago Loop",
+ locationId: "mock-loc-1",
+ type: "Reactive",
+ dispatcherId: String(alyssa.id),
+ dispatcherName: alyssa.name,
+ scheduledOn: "",
+ targetWeek: mon,
+ dueDate: wed,
+ pm: "Electrical — Panel inspection",
+ company: "SparkLine Electric",
+ tech: "Pat Nguyen",
+ techPhone: "",
+ apptStart: "",
+ apptEnd: "",
+ apptTime: "",
+ status: "Incomplete",
+ docStatus: "No",
+ pocName: "Maria Gonzalez",
+ pocPhone: "(312) 555-0142",
+ pocNotes: "Week-only target",
+ rescheduleCount: 0,
+ carriedOver: 0,
+ originalWeek: mon,
+ originalDate: "",
+ scheduleWeekOnly: true,
+ mediaCount: 0,
+ }),
+ buildRow({
+ id: "mock-wo-8",
+ woNumber: "20260623008",
+ site: "IND-2208",
+ location: "Indianapolis North",
+ locationId: "mock-loc-2",
+ type: "PM",
+ dispatcherId: String(derek.id),
+ dispatcherName: derek.name,
+ scheduledOn: "",
+ targetWeek: "",
+ dueDate: thu,
+ pm: "HVAC — Quarterly PM",
+ company: "Midwest HVAC Co.",
+ tech: "Mike Torres",
+ techPhone: "(312) 555-0198",
+ apptStart: "",
+ apptEnd: "",
+ apptTime: "",
+ status: "Pending",
+ docStatus: "No",
+ pocName: "Chris Bennett",
+ pocPhone: "(317) 555-2208",
+ pocNotes: "",
+ rescheduleCount: 0,
+ carriedOver: 0,
+ originalWeek: "",
+ originalDate: "",
+ scheduleWeekOnly: false,
+ mediaCount: 0,
+ }),
+ buildRow({
+ id: "mock-wo-9",
+ woNumber: "20260623009",
+ site: "MIL-3315",
+ location: "Milwaukee West",
+ locationId: "mock-loc-3",
+ type: "PM",
+ dispatcherId: String(morgan.id),
+ dispatcherName: morgan.name,
+ ...sched(wed),
+ dueDate: fri,
+ pm: "Roof — Membrane patch",
+ company: "SparkLine Electric",
+ tech: "Pat Nguyen",
+ techPhone: "",
+ apptStart: "14:00",
+ apptEnd: "16:00",
+ apptTime: "14:00 – 16:00",
+ status: "En Route",
+ docStatus: "No",
+ pocName: "Taylor Brooks",
+ pocPhone: "(414) 555-3315",
+ pocNotes: "",
+ rescheduleCount: 0,
+ carriedOver: 0,
+ originalWeek: "",
+ originalDate: "",
+ scheduleWeekOnly: false,
+ mediaCount: 0,
+ }),
+ buildRow({
+ id: "mock-wo-10",
+ woNumber: "20260623010",
+ site: "CHI-1042",
+ location: "Chicago Loop",
+ locationId: "mock-loc-1",
+ type: "Reactive",
+ dispatcherId: String(sarah.id),
+ dispatcherName: sarah.name,
+ ...sched(tue),
+ dueDate: wed,
+ pm: "Plumbing — Leak repair",
+ company: "Great Lakes Plumbing",
+ tech: "Jen Walsh",
+ techPhone: "(317) 555-4412",
+ apptStart: "11:00",
+ apptEnd: "12:30",
+ apptTime: "11:00 – 12:30",
+ status: "Completed",
+ docStatus: "Yes",
+ pocName: "Maria Gonzalez",
+ pocPhone: "(312) 555-0142",
+ pocNotes: "",
+ rescheduleCount: 0,
+ carriedOver: 0,
+ originalWeek: "",
+ originalDate: "",
+ scheduleWeekOnly: false,
+ mediaCount: 2,
+ }),
+ ];
+}
+
+let cachedRows: WorkOrderTableRow[] | null = null;
+
+export function getMockWorkOrderTableRows(): WorkOrderTableRow[] {
+ if (!cachedRows) {
+ cachedRows = buildMockRows();
+ }
+ return cachedRows;
+}
+
+function rowMatchesSearch(row: WorkOrderTableRow, search: string): boolean {
+ const q = search.trim().toLowerCase();
+ if (!q) {
+ return true;
+ }
+ const haystack = [
+ row.site,
+ row.location,
+ row.woNumber,
+ row.type,
+ row.dispatcherName,
+ row.pm,
+ row.company,
+ row.tech,
+ row.status,
+ row.title,
+ ]
+ .join(" ")
+ .toLowerCase();
+ return haystack.includes(q);
+}
+
+export function getMockWorkOrdersList(params: WorkOrdersListParams = {}): {
+ items: WorkOrderTableRow[];
+ totalCount: number;
+ page: number;
+ pageSize: number;
+} {
+ let items = getMockWorkOrderTableRows();
+
+ if (params.search) {
+ items = items.filter((row) => rowMatchesSearch(row, params.search!));
+ }
+
+ if (params.type) {
+ items = items.filter((row) => row.type === params.type);
+ }
+
+ if (params.dateFrom && params.dateTo) {
+ const weekMonday = params.dateFrom;
+ items = items.filter((row) => {
+ if (row.scheduledOn) {
+ return row.scheduledOn >= params.dateFrom! && row.scheduledOn <= params.dateTo!;
+ }
+ if (row.targetWeek) {
+ return isDateInWeek(row.targetWeek, weekMonday);
+ }
+ return true;
+ });
+ }
+
+ const page = params.page ?? 1;
+ const pageSize = params.pageSize ?? items.length;
+
+ return {
+ items,
+ totalCount: items.length,
+ page,
+ pageSize,
+ };
+}
+
+export function getMockWorkOrderTableRow(id: string | number): WorkOrderTableRow | undefined {
+ return getMockWorkOrderTableRows().find((row) => String(row.id) === String(id));
+}
+
+export function buildMockWorkOrderDetailFromTableRow(row: WorkOrderTableRow): WorkOrderDetail {
+ return {
+ id: row.id,
+ internalWONumber: row.woNumber,
+ workOrderNumber: row.woNumber,
+ title: row.title,
+ location: row.location,
+ locationId: String(row.locationId),
+ locationZip: "",
+ assignedTo: row.dispatcherName,
+ dueDate: row.dueDate,
+ createdDate: addDaysIso(todayIso(), -7),
+ createdDateFull: new Date().toISOString(),
+ description: [
+ row.pocNotes && `POC notes: ${row.pocNotes}`,
+ row.pocName && `POC: ${row.pocName} · ${row.pocPhone}`,
+ row.apptTime && row.apptTime !== "—" && `Appt: ${row.apptTime}`,
+ row.company && `Vendor: ${row.company}`,
+ ]
+ .filter(Boolean)
+ .join("\n"),
+ priority: row.priority,
+ status:
+ row.status === "Completed" ? "Done" : row.status === "Incomplete" ? "Open" : "In Progress",
+ po: "",
+ tt: "",
+ problem: row.pm,
+ trade: "",
+ subTrade: "",
+ vendorNTE: "",
+ scheduledDate: row.scheduledOn,
+ completedDate: row.status === "Completed" ? todayIso() : "",
+ source: row.type,
+ comments: [],
+ auditLog: [],
+ dispatches: [],
+ };
+}
+
+export function getMockWorkOrderLocations(): LocationOption[] {
+ return MOCK_WO_WIZARD_LOCATIONS;
+}
+
+export function getMockWorkOrderVendors(): VendorDropdownItem[] {
+ return MOCK_WO_WIZARD_VENDORS;
+}
+
+export function getMockPmTypes(): string[] {
+ return MOCK_WO_WIZARD_SERVICES.map((s) => s.value).filter(Boolean);
+}
diff --git a/src/domain/work-orders/mocks/wo-wizard-mock-data.ts b/src/domain/work-orders/mocks/wo-wizard-mock-data.ts
new file mode 100644
index 00000000..075a250d
--- /dev/null
+++ b/src/domain/work-orders/mocks/wo-wizard-mock-data.ts
@@ -0,0 +1,158 @@
+import type { Location } from "@/domain/locations/types/location";
+import type { DropdownOption } from "@/domain/settings/dropdown-options/types/dropdown-option";
+import type {
+ LocationOption,
+ VendorDropdownItem,
+ WorkOrderDetail,
+ WorkOrderFormValues,
+ WorkOrderUser,
+} from "@/domain/work-orders/types/work-order";
+
+export const MOCK_WO_WIZARD_USERS: WorkOrderUser[] = [
+ { id: "mock-user-1", name: "Alex Rivera" },
+ { id: "mock-user-2", name: "Jordan Kim" },
+ { id: "mock-user-3", name: "Sam Patel" },
+];
+
+export const MOCK_WO_WIZARD_LOCATIONS: LocationOption[] = [
+ { id: "mock-loc-1", name: "CHI-1042 — Chicago Loop" },
+ { id: "mock-loc-2", name: "IND-2208 — Indianapolis North" },
+ { id: "mock-loc-3", name: "MIL-3315 — Milwaukee West" },
+];
+
+const MOCK_LOCATION_DETAILS: Record = {
+ "mock-loc-1": {
+ id: "mock-loc-1",
+ name: "CHI-1042 — Chicago Loop",
+ address: "233 S Wacker Dr",
+ city: "Chicago",
+ state: "IL",
+ zipCode: "60606",
+ phone: "(312) 555-0142",
+ contact: "Maria Gonzalez",
+ contactEmail: "maria.gonzalez@example.com",
+ status: "Active",
+ },
+ "mock-loc-2": {
+ id: "mock-loc-2",
+ name: "IND-2208 — Indianapolis North",
+ address: "8702 Keystone Crossing",
+ city: "Indianapolis",
+ state: "IN",
+ zipCode: "46240",
+ phone: "(317) 555-2208",
+ contact: "Chris Bennett",
+ contactEmail: "chris.bennett@example.com",
+ status: "Active",
+ },
+ "mock-loc-3": {
+ id: "mock-loc-3",
+ name: "MIL-3315 — Milwaukee West",
+ address: "411 E Wisconsin Ave",
+ city: "Milwaukee",
+ state: "WI",
+ zipCode: "53202",
+ phone: "(414) 555-3315",
+ contact: "Taylor Brooks",
+ contactEmail: "taylor.brooks@example.com",
+ status: "Active",
+ },
+};
+
+export const MOCK_WO_WIZARD_SERVICES: DropdownOption[] = [
+ {
+ id: "mock-svc-1",
+ category: "Problem",
+ value: "HVAC — Quarterly PM",
+ parentValue: null,
+ sortOrder: 1,
+ isActive: true,
+ },
+ {
+ id: "mock-svc-2",
+ category: "Problem",
+ value: "Plumbing — Leak repair",
+ parentValue: null,
+ sortOrder: 2,
+ isActive: true,
+ },
+ {
+ id: "mock-svc-3",
+ category: "Problem",
+ value: "Electrical — Panel inspection",
+ parentValue: null,
+ sortOrder: 3,
+ isActive: true,
+ },
+ {
+ id: "mock-svc-4",
+ category: "Problem",
+ value: "Roof — Membrane patch",
+ parentValue: null,
+ sortOrder: 4,
+ isActive: true,
+ },
+];
+
+export const MOCK_WO_WIZARD_VENDORS: VendorDropdownItem[] = [
+ {
+ id: "mock-vendor-1",
+ companyName: "Midwest HVAC Co.",
+ tradeSpecialties: "HVAC",
+ address: "1200 Industrial Pkwy, Chicago, IL 60632",
+ distanceMiles: 4.2,
+ },
+ {
+ id: "mock-vendor-2",
+ companyName: "Great Lakes Plumbing",
+ tradeSpecialties: "Plumbing",
+ address: "455 Water St, Milwaukee, WI 53202",
+ distanceMiles: 8.7,
+ },
+ {
+ id: "mock-vendor-3",
+ companyName: "SparkLine Electric",
+ tradeSpecialties: "Electrical",
+ address: "88 Volt Ave, Indianapolis, IN 46225",
+ distanceMiles: 12.1,
+ },
+];
+
+export function getMockWoWizardLocationDetail(locationId: string): Location | undefined {
+ return MOCK_LOCATION_DETAILS[locationId];
+}
+
+export function buildMockWorkOrderDetail(values: WorkOrderFormValues): WorkOrderDetail {
+ const id = `mock-wo-${Date.now()}`;
+ const now = new Date().toISOString();
+
+ return {
+ id,
+ internalWONumber: values.internalWONumber || id,
+ workOrderNumber: values.workOrderNumber || "MOCK-0001",
+ title: values.title || "Mock Work Order",
+ location:
+ MOCK_WO_WIZARD_LOCATIONS.find((l) => String(l.id) === values.locationId)?.name ?? "Mock Site",
+ locationId: values.locationId,
+ locationZip: getMockWoWizardLocationDetail(values.locationId)?.zipCode ?? "",
+ assignedTo: values.assignedTo,
+ dueDate: values.dueDate,
+ createdDate: values.createdDate,
+ createdDateFull: now,
+ description: values.description,
+ priority: values.priority,
+ status: values.status,
+ po: values.po,
+ tt: values.tt,
+ problem: values.problem,
+ trade: values.trade,
+ subTrade: values.subTrade,
+ vendorNTE: values.vendorNTE,
+ scheduledDate: values.scheduledDate,
+ completedDate: values.completedDate,
+ source: values.source,
+ comments: [],
+ auditLog: [],
+ dispatches: [],
+ };
+}
diff --git a/src/domain/work-orders/types/completion-doc.ts b/src/domain/work-orders/types/completion-doc.ts
new file mode 100644
index 00000000..0c6377f5
--- /dev/null
+++ b/src/domain/work-orders/types/completion-doc.ts
@@ -0,0 +1,42 @@
+import type { ElementType } from "react";
+import type { CompletionDocStatus } from "@/domain/work-orders/types/work-order-table-row";
+
+export interface CompDocData {
+ notes: string;
+ generatedAt?: string;
+}
+
+export interface DocFields {
+ street: string;
+ city: string;
+ state: string;
+ zip: string;
+ dateCompleted: string;
+ amazonRep: string;
+ seaRep: string;
+}
+
+export type DocState = "draft" | "generated" | "not-required";
+
+export interface CompletionDocStateConfig {
+ pillLabel: string;
+ pillBg: string;
+ pillText: string;
+ cardBg: string;
+ cardBorder: string;
+ cardIconBg: string;
+ iconColor: string;
+ cardTitle: string;
+ icon: ElementType;
+}
+
+export interface CompDocSiteInfo {
+ street: string;
+ city: string;
+ state: string;
+ zip: string;
+ label: string;
+ fullAddress: string;
+}
+
+export type CompDocSaveStatus = Exclude;
diff --git a/src/domain/work-orders/types/dispatch.ts b/src/domain/work-orders/types/dispatch.ts
new file mode 100644
index 00000000..d5df044c
--- /dev/null
+++ b/src/domain/work-orders/types/dispatch.ts
@@ -0,0 +1,80 @@
+export interface WorkOrderDispatchSummary {
+ id: string | number;
+ dispatchNumber: string;
+ poNumber: string;
+ vendorName: string;
+ nteAmount: number | null;
+ status: string;
+ dispatchedAt: string;
+}
+
+export interface DispatchChecklistItem {
+ id: string | number;
+ itemText: string;
+ isCompleted: boolean;
+ completedBy: string | null;
+ completedAt: string | null;
+}
+
+export interface DispatchSignoff {
+ id: string | number;
+ signoffType: string;
+ name: string;
+ signature: string;
+ signatureMethod: string;
+ signedAt: string;
+}
+
+export interface DispatchComment {
+ id: string | number;
+ createdDate: string;
+ commenttext: string;
+ userName: string;
+ commenter: string | null;
+}
+
+export interface DispatchWorkOrderRef {
+ id: string | number;
+ woNumber: string;
+}
+
+export interface DispatchDetail {
+ id: string | number;
+ dispatchNumber: string;
+ poNumber: string;
+ vendorName: string;
+ vendorEmail: string;
+ woNumber: string;
+ status: string;
+ nteAmount: number | null;
+ scheduledDate: string;
+ completedDate: string;
+ description: string;
+ dispatchedAt: string;
+ acknowledgedAt: string;
+ verifiedAt: string;
+ verifiedBy: string;
+ checklistItems: DispatchChecklistItem[];
+ signoffs: DispatchSignoff[];
+ comments: DispatchComment[];
+ workOrders: DispatchWorkOrderRef[];
+}
+
+export interface DispatchToVendorPayload {
+ workOrderIds: (string | number)[];
+ vendorIds: (string | number)[];
+ nteAmount: number | null;
+ scheduledDate: string | null;
+ description: string;
+ taskListTemplateId: number | null;
+ customChecklistItems: string[] | null;
+}
+
+export interface UpdateDispatchPayload {
+ id: string | number;
+ status: string;
+ nteAmount: number | null;
+ scheduledDate: string | null;
+ completedDate: string | null;
+ description: string;
+}
diff --git a/src/domain/work-orders/types/work-order-advanced-filters.ts b/src/domain/work-orders/types/work-order-advanced-filters.ts
new file mode 100644
index 00000000..a01eed47
--- /dev/null
+++ b/src/domain/work-orders/types/work-order-advanced-filters.ts
@@ -0,0 +1,74 @@
+import { ASSIGNEE_FILTER_UNASSIGNED } from "./work-order-list";
+import {
+ ALL_WIZARD_STATUSES,
+ WO_TYPE_META,
+ WO_TYPES,
+ type WizardWOStatus,
+ type WOType,
+} from "./work-order-wizard";
+
+export { ASSIGNEE_FILTER_UNASSIGNED };
+
+export type DateRangeKey =
+ | "this-week"
+ | "last-week"
+ | "this-month"
+ | "last-3-months"
+ | "next-week"
+ | "next-month"
+ | "custom";
+
+export type CompletionDocFilter = "No" | "Yes" | "NN";
+
+export interface WorkOrderAdvancedFilters {
+ dateRange: DateRangeKey;
+ customFrom: string;
+ customTo: string;
+ sites: Set;
+ types: Set;
+ dispatchers: Set;
+ statuses: Set;
+ pmTypes: Set;
+ vendorTechs: Set;
+ docs: Set;
+}
+
+export const EMPTY_ADVANCED_FILTERS: WorkOrderAdvancedFilters = {
+ dateRange: "this-week",
+ customFrom: "",
+ customTo: "",
+ sites: new Set(),
+ types: new Set(),
+ dispatchers: new Set(),
+ statuses: new Set(),
+ pmTypes: new Set(),
+ vendorTechs: new Set(),
+ docs: new Set(),
+};
+
+export const DATE_RANGE_OPTIONS: { key: DateRangeKey; label: string }[] = [
+ { key: "this-week", label: "This week" },
+ { key: "last-week", label: "Last week" },
+ { key: "this-month", label: "This month" },
+ { key: "last-3-months", label: "Last 3 months" },
+ { key: "next-week", label: "Next week" },
+ { key: "next-month", label: "Next month" },
+ { key: "custom", label: "Custom" },
+];
+
+export const DOC_FILTER_LABELS: Record = {
+ No: "Pending",
+ Yes: "Uploaded",
+ NN: "N/N",
+};
+
+export const COMPLETION_DOC_OPTIONS: CompletionDocFilter[] = ["No", "Yes", "NN"];
+
+export const WO_TYPE_DESCRIPTIONS: Record = Object.fromEntries(
+ WO_TYPES.map((type) => [type, WO_TYPE_META[type].desc]),
+) as Record;
+
+export { ALL_WIZARD_STATUSES, WO_TYPES };
+
+export type AdvancedFilterSetKey =
+ "sites" | "types" | "dispatchers" | "statuses" | "pmTypes" | "vendorTechs" | "docs";
diff --git a/src/domain/work-orders/types/work-order-common.ts b/src/domain/work-orders/types/work-order-common.ts
new file mode 100644
index 00000000..0b0f47c0
--- /dev/null
+++ b/src/domain/work-orders/types/work-order-common.ts
@@ -0,0 +1,48 @@
+export const WORK_ORDER_STATUS_OPTIONS = [
+ "Open",
+ "In Progress",
+ "On Hold",
+ "Done",
+ "Cancelled",
+] as const;
+
+export const WORK_ORDER_PRIORITY_OPTIONS = ["Sev 1", "Sev 2", "Sev 3", "Sev 4", "Sev 5"] as const;
+
+export const DISPATCH_STATUS_OPTIONS = [
+ "Sent",
+ "Acknowledged",
+ "In Progress",
+ "Completed",
+ "Verified",
+ "Cancelled",
+] as const;
+
+export type WorkOrderStatus = (typeof WORK_ORDER_STATUS_OPTIONS)[number];
+export type WorkOrderPriority = (typeof WORK_ORDER_PRIORITY_OPTIONS)[number];
+export type DispatchStatus = (typeof DISPATCH_STATUS_OPTIONS)[number];
+
+export type WorkOrderCommentType = "customer" | "internal";
+
+export interface LocationOption {
+ id: string | number;
+ name: string;
+}
+
+export interface EmployeeOption {
+ id: string | number;
+ name: string;
+}
+
+export interface VendorDropdownItem {
+ id: string | number;
+ companyName: string;
+ tradeSpecialties: string;
+ address: string;
+ distanceMiles: number | null;
+}
+
+export interface TaskListTemplate {
+ id: string | number;
+ name: string;
+ items: { id?: string | number; itemText?: string }[];
+}
diff --git a/src/domain/work-orders/types/work-order-detail.ts b/src/domain/work-orders/types/work-order-detail.ts
new file mode 100644
index 00000000..9e6c802d
--- /dev/null
+++ b/src/domain/work-orders/types/work-order-detail.ts
@@ -0,0 +1,97 @@
+import type { WorkOrderCommentType, WorkOrderPriority, WorkOrderStatus } from "./work-order-common";
+import type { WorkOrderDispatchSummary } from "./dispatch";
+
+export interface WorkOrderComment {
+ id: string | number;
+ createdDate: string;
+ commenttext: string;
+ commentType: WorkOrderCommentType | string;
+ userName: string;
+ commenter: string | null;
+ recordType: string | null;
+}
+
+export interface WorkOrderAuditEntry {
+ id: string | number;
+ fieldName: string;
+ oldValue: string;
+ newValue: string;
+ action: string;
+ createdAt: string;
+ userName: string;
+}
+
+export interface WorkOrderDetail {
+ id: string | number;
+ internalWONumber: string;
+ workOrderNumber: string;
+ title: string;
+ location: string;
+ locationId: string;
+ locationZip: string;
+ assignedTo: string;
+ dueDate: string;
+ createdDate: string;
+ createdDateFull: string;
+ description: string;
+ priority: WorkOrderPriority | string;
+ status: WorkOrderStatus | string;
+ po: string;
+ tt: string;
+ problem: string;
+ trade: string;
+ subTrade: string;
+ vendorNTE: string;
+ scheduledDate: string;
+ completedDate: string;
+ source: string;
+ comments: WorkOrderComment[];
+ auditLog: WorkOrderAuditEntry[];
+ dispatches: WorkOrderDispatchSummary[];
+}
+
+export interface WorkOrderFormValues {
+ internalWONumber: string;
+ workOrderNumber: string;
+ title: string;
+ locationId: string;
+ priority: string;
+ status: WorkOrderStatus;
+ createdDate: string;
+ dueDate: string;
+ scheduledDate: string;
+ completedDate: string;
+ assignedTo: string;
+ description: string;
+ po: string;
+ tt: string;
+ problem: string;
+ trade: string;
+ subTrade: string;
+ vendorNTE: string;
+ source: string;
+}
+
+export function createDefaultWorkOrderFormValues(): WorkOrderFormValues {
+ return {
+ internalWONumber: "",
+ workOrderNumber: "",
+ title: "",
+ locationId: "",
+ priority: "",
+ status: "Open",
+ createdDate: new Date().toISOString().split("T")[0] ?? "",
+ dueDate: "",
+ scheduledDate: "",
+ completedDate: "",
+ assignedTo: "",
+ description: "",
+ po: "",
+ tt: "",
+ problem: "",
+ trade: "",
+ subTrade: "",
+ vendorNTE: "",
+ source: "",
+ };
+}
diff --git a/src/domain/work-orders/types/work-order-list-filters.ts b/src/domain/work-orders/types/work-order-list-filters.ts
new file mode 100644
index 00000000..aac7217a
--- /dev/null
+++ b/src/domain/work-orders/types/work-order-list-filters.ts
@@ -0,0 +1,9 @@
+import type { WOType } from "./work-order-wizard";
+
+export type WorkOrderTypeFilter = WOType | "All";
+
+export interface WorkOrderBarFilters {
+ weekMonday: string;
+ dispatcherIds: Set;
+ type: WorkOrderTypeFilter;
+}
diff --git a/src/domain/work-orders/types/work-order-list.ts b/src/domain/work-orders/types/work-order-list.ts
new file mode 100644
index 00000000..29c8ee6d
--- /dev/null
+++ b/src/domain/work-orders/types/work-order-list.ts
@@ -0,0 +1,61 @@
+import type { WorkOrderPriority, WorkOrderStatus } from "./work-order-common";
+import type { WorkOrderTableRow } from "./work-order-table-row";
+
+export type SortDirection = "asc" | "desc" | "";
+
+export type WorkOrdersSortableColumn = "number" | "title";
+
+export const ASSIGNEE_FILTER_UNASSIGNED = "__unassigned" as const;
+
+export interface WorkOrderListItem {
+ id: string | number;
+ number: string;
+ title: string;
+ location: string;
+ priority: WorkOrderPriority | string;
+ status: WorkOrderStatus | string;
+ createdDate: string;
+ dueDate: string;
+ assignedTo: string;
+ lastUpdated: string;
+}
+
+export interface WorkOrdersListParams {
+ search?: string;
+ page?: number;
+ pageSize?: number;
+ sortBy?: WorkOrdersSortableColumn | string;
+ sortDir?: SortDirection;
+ status?: string;
+ assignee?: string;
+ locationId?: string | number;
+ /** Phase 2: filter bar week range (Monday ISO). */
+ dateFrom?: string;
+ /** Phase 2: filter bar week range (Friday ISO). */
+ dateTo?: string;
+ /** Phase 2: filter bar WO type. */
+ type?: string;
+ /** Phase 2: filter bar dispatcher ids (format TBD by API contract). */
+ assigneeIds?: string;
+}
+
+export interface WorkOrderUser {
+ id: string | number;
+ name: string;
+}
+
+export interface WorkOrdersListResult {
+ items: WorkOrderTableRow[];
+ totalCount: number;
+ page: number;
+ pageSize: number;
+}
+
+export interface SelectedWorkOrderRef {
+ id: string | number;
+ number: string;
+ title: string;
+}
+
+/** Max page size when loading WOs at a location for dispatch multi-select. */
+export const DISPATCH_LOCATION_WO_PAGE_SIZE = 50;
diff --git a/src/domain/work-orders/types/work-order-media.ts b/src/domain/work-orders/types/work-order-media.ts
new file mode 100644
index 00000000..eb346814
--- /dev/null
+++ b/src/domain/work-orders/types/work-order-media.ts
@@ -0,0 +1,24 @@
+export type FileCategory = "image" | "video" | "pdf" | "doc" | "other";
+
+export type MediaCategory =
+ "Photo" | "Video" | "Receipt" | "Quote" | "Invoice" | "Report" | "Other";
+
+export interface MediaFile {
+ id: string;
+ name: string;
+ fileType: FileCategory;
+ url: string;
+ category: MediaCategory;
+}
+
+export const MEDIA_CATEGORIES: MediaCategory[] = [
+ "Photo",
+ "Video",
+ "Receipt",
+ "Quote",
+ "Invoice",
+ "Report",
+ "Other",
+];
+
+export const EMPTY_MEDIA: MediaFile[] = [];
diff --git a/src/domain/work-orders/types/work-order-table-row.ts b/src/domain/work-orders/types/work-order-table-row.ts
new file mode 100644
index 00000000..89a06774
--- /dev/null
+++ b/src/domain/work-orders/types/work-order-table-row.ts
@@ -0,0 +1,99 @@
+import type { WizardWOStatus, WOType } from "./work-order-wizard";
+
+export type CompletionDocStatus = "Yes" | "No" | "NN" | "";
+
+export const WORK_ORDERS_TABLE_PAGE_SIZE = 500;
+
+export interface WorkOrderTableRow {
+ id: string | number;
+ woNumber: string;
+ site: string;
+ type: WOType | "";
+ dispatcherId: string;
+ dispatcherName: string;
+ scheduledOn: string;
+ targetWeek: string;
+ dayGroup: string;
+ dayLabel: string;
+ location: string;
+ locationId: string | number;
+ pm: string;
+ company: string;
+ tech: string;
+ techPhone: string;
+ apptStart: string;
+ apptEnd: string;
+ apptTime: string;
+ status: WizardWOStatus | "";
+ docStatus: CompletionDocStatus;
+ pocName: string;
+ pocPhone: string;
+ pocNotes: string;
+ dueDate: string;
+ rescheduleCount: number;
+ carriedOver: number;
+ originalWeek: string;
+ originalDate: string;
+ isPastDue: boolean;
+ scheduleWeekOnly: boolean;
+ mediaCount: number;
+ title: string;
+ priority: string;
+}
+
+export interface WorkOrderTableDraft {
+ site: string;
+ woNumber: string;
+ type: WOType | "";
+ dispatcherId: string;
+ scheduledOn: string;
+ schedNotes: string;
+ dueDate: string;
+ location: string;
+ locationId: string | number;
+ pm: string;
+ company: string;
+ tech: string;
+ techPhone: string;
+ apptStart: string;
+ apptEnd: string;
+ status: WizardWOStatus | "";
+ pocName: string;
+ pocPhone: string;
+ pocNotes: string;
+ scheduleWeekOnly: boolean;
+ targetWeek: string;
+}
+
+export const EMPTY_TABLE_DRAFT: WorkOrderTableDraft = {
+ site: "",
+ woNumber: "",
+ type: "",
+ dispatcherId: "",
+ scheduledOn: "",
+ schedNotes: "",
+ dueDate: "",
+ location: "",
+ locationId: "",
+ pm: "",
+ company: "",
+ tech: "",
+ techPhone: "",
+ apptStart: "",
+ apptEnd: "",
+ status: "Incomplete",
+ pocName: "",
+ pocPhone: "",
+ pocNotes: "",
+ scheduleWeekOnly: false,
+ targetWeek: "",
+};
+
+export interface WorkOrderDayGroup {
+ key: string;
+ label: string;
+ date: string;
+ items: WorkOrderTableRow[];
+}
+
+export const UNSCHEDULED_DAY_KEY = "__unscheduled__" as const;
diff --git a/src/domain/work-orders/types/work-order-wizard.ts b/src/domain/work-orders/types/work-order-wizard.ts
new file mode 100644
index 00000000..bca5dfff
--- /dev/null
+++ b/src/domain/work-orders/types/work-order-wizard.ts
@@ -0,0 +1,149 @@
+export const WO_TYPES = ["PM", "Reactive", "Emergency", "Add-On", "Overdue"] as const;
+
+export type WOType = (typeof WO_TYPES)[number];
+
+export const ALL_WIZARD_STATUSES = [
+ "Incomplete",
+ "Pending",
+ "Scheduled",
+ "En Route",
+ "On Site",
+ "In Progress",
+ "Completed",
+ "Rescheduled",
+ "Pending Quote",
+] as const;
+
+export type WizardWOStatus = (typeof ALL_WIZARD_STATUSES)[number];
+
+export interface WorkOrderWizardDraft {
+ locationId: string;
+ locationName: string;
+ woNumber: string;
+ type: WOType | "";
+ assigneeId: string;
+ assigneeName: string;
+ scheduledOn: string;
+ dueDate: string;
+ pm: string;
+ vendorId: string;
+ vendorName: string;
+ techPhone: string;
+ vendorNotes: string;
+ apptStart: string;
+ apptEnd: string;
+ status: WizardWOStatus | "";
+ pocName: string;
+ pocPhone: string;
+ pocNotes: string;
+ scheduleWeekOnly: boolean;
+}
+
+export const EMPTY_WIZARD_DRAFT: WorkOrderWizardDraft = {
+ locationId: "",
+ locationName: "",
+ woNumber: "",
+ type: "",
+ assigneeId: "",
+ assigneeName: "",
+ scheduledOn: "",
+ dueDate: "",
+ pm: "",
+ vendorId: "",
+ vendorName: "",
+ techPhone: "",
+ vendorNotes: "",
+ apptStart: "",
+ apptEnd: "",
+ status: "Incomplete",
+ pocName: "",
+ pocPhone: "",
+ pocNotes: "",
+ scheduleWeekOnly: false,
+};
+
+export const WIZARD_STEPS: { title: string; nav: string; subtitle: string }[] = [
+ {
+ title: "Type & schedule",
+ nav: "What and when",
+ subtitle: "Set the work-order type and when it should happen.",
+ },
+ {
+ title: "Location & service",
+ nav: "Where and what work",
+ subtitle: "Pick the site and the service to be performed.",
+ },
+ {
+ title: "Vendor & time",
+ nav: "Who and the window",
+ subtitle: "Assign a vendor crew and the appointment window.",
+ },
+];
+
+export const WO_TYPE_META: Record = {
+ PM: {
+ desc: "Scheduled preventive maintenance",
+ bg: "var(--type-pm-bg)",
+ fg: "var(--type-pm-text)",
+ },
+ Reactive: {
+ desc: "Tenant-reported repair",
+ bg: "var(--type-reactive-bg)",
+ fg: "var(--type-reactive-text)",
+ },
+ Emergency: {
+ desc: "Urgent same-day dispatch",
+ bg: "var(--type-emergency-bg)",
+ fg: "var(--type-emergency-text)",
+ },
+ "Add-On": {
+ desc: "Extra scope on an existing visit",
+ bg: "var(--type-addon-bg)",
+ fg: "var(--type-addon-text)",
+ },
+ Overdue: {
+ desc: "No client response — due date passed",
+ bg: "var(--type-overdue-bg)",
+ fg: "var(--type-overdue-text)",
+ },
+};
+
+export const WIZARD_STATUS_DOTS: Record = {
+ Incomplete: "#9CA3AF",
+ Pending: "#D97706",
+ Scheduled: "#3B82F6",
+ "En Route": "#0EA5E9",
+ "On Site": "#10B981",
+ "In Progress": "#2563EB",
+ Completed: "#16A34A",
+ Rescheduled: "#F59E0B",
+ "Pending Quote": "#9333EA",
+};
+
+export const TIME_SLOTS: string[] = (() => {
+ const out: string[] = [];
+ for (let m = 5 * 60; m <= 22 * 60; m += 30) {
+ const h = Math.floor(m / 60);
+ const mm = m % 60;
+ out.push(`${String(h).padStart(2, "0")}:${String(mm).padStart(2, "0")}`);
+ }
+ return out;
+})();
+
+export function canContinueWizardStep1(draft: WorkOrderWizardDraft): boolean {
+ return Boolean(draft.type);
+}
+
+export function canCreateWorkOrderFromWizard(draft: WorkOrderWizardDraft): boolean {
+ return Boolean(draft.locationId);
+}
+
+export function isWizardStepValid(step: number, draft: WorkOrderWizardDraft): boolean {
+ if (step === 1) {
+ return canContinueWizardStep1(draft);
+ }
+ if (step === 2) {
+ return Boolean(draft.locationId);
+ }
+ return true;
+}
diff --git a/src/domain/work-orders/types/work-order.ts b/src/domain/work-orders/types/work-order.ts
index 34f63bc5..c1440bff 100644
--- a/src/domain/work-orders/types/work-order.ts
+++ b/src/domain/work-orders/types/work-order.ts
@@ -1,270 +1,61 @@
-export interface WorkOrderListItem {
- id: string | number;
- number: string;
- title: string;
- location: string;
- priority: string;
- status: string;
- createdDate: string;
- dueDate: string;
- assignedTo: string;
- lastUpdated: string;
-}
+export type {
+ DispatchChecklistItem,
+ DispatchComment,
+ DispatchDetail,
+ DispatchSignoff,
+ DispatchToVendorPayload,
+ DispatchWorkOrderRef,
+ UpdateDispatchPayload,
+ WorkOrderDispatchSummary,
+} from "./dispatch";
-export interface WorkOrdersListParams {
- search?: string;
- page?: number;
- pageSize?: number;
- sortBy?: string;
- sortDir?: string;
- status?: string;
- assignee?: string;
- locationId?: string | number;
-}
+export type {
+ DispatchStatus,
+ EmployeeOption,
+ LocationOption,
+ TaskListTemplate,
+ VendorDropdownItem,
+ WorkOrderCommentType,
+ WorkOrderPriority,
+ WorkOrderStatus,
+} from "./work-order-common";
-export interface WorkOrderUser {
- id: string | number;
- name: string;
-}
+export {
+ DISPATCH_STATUS_OPTIONS,
+ WORK_ORDER_PRIORITY_OPTIONS,
+ WORK_ORDER_STATUS_OPTIONS,
+} from "./work-order-common";
-export interface WorkOrdersListResult {
- items: WorkOrderListItem[];
- totalCount: number;
- page: number;
- pageSize: number;
-}
+export type {
+ WorkOrderAuditEntry,
+ WorkOrderComment,
+ WorkOrderDetail,
+ WorkOrderFormValues,
+} from "./work-order-detail";
-export interface WorkOrderComment {
- id: string | number;
- createdDate: string;
- commenttext: string;
- commentType: string;
- userName: string;
- commenter: string | null;
- recordType: string | null;
-}
+export { createDefaultWorkOrderFormValues } from "./work-order-detail";
-export interface WorkOrderAuditEntry {
- id: string | number;
- fieldName: string;
- oldValue: string;
- newValue: string;
- action: string;
- createdAt: string;
- userName: string;
-}
+export type {
+ SelectedWorkOrderRef,
+ SortDirection,
+ WorkOrderListItem,
+ WorkOrdersListParams,
+ WorkOrdersListResult,
+ WorkOrderUser,
+ WorkOrdersSortableColumn,
+} from "./work-order-list";
-export interface WorkOrderDispatchSummary {
- id: string | number;
- dispatchNumber: string;
- poNumber: string;
- vendorName: string;
- nteAmount: number | null;
- status: string;
- dispatchedAt: string;
-}
+export { ASSIGNEE_FILTER_UNASSIGNED, DISPATCH_LOCATION_WO_PAGE_SIZE } from "./work-order-list";
-export interface WorkOrderDetail {
- id: string | number;
- internalWONumber: string;
- workOrderNumber: string;
- title: string;
- location: string;
- locationId: string;
- locationZip: string;
- assignedTo: string;
- dueDate: string;
- createdDate: string;
- createdDateFull: string;
- description: string;
- priority: string;
- status: string;
- po: string;
- tt: string;
- problem: string;
- trade: string;
- subTrade: string;
- vendorNTE: string;
- scheduledDate: string;
- completedDate: string;
- source: string;
- comments: WorkOrderComment[];
- auditLog: WorkOrderAuditEntry[];
- dispatches: WorkOrderDispatchSummary[];
-}
+export type {
+ CompletionDocStatus,
+ WorkOrderDayGroup,
+ WorkOrderTableDraft,
+ WorkOrderTableRow,
+} from "./work-order-table-row";
-export interface WorkOrderFormValues {
- internalWONumber: string;
- workOrderNumber: string;
- title: string;
- locationId: string;
- priority: string;
- status: string;
- createdDate: string;
- dueDate: string;
- scheduledDate: string;
- completedDate: string;
- assignedTo: string;
- description: string;
- po: string;
- tt: string;
- problem: string;
- trade: string;
- subTrade: string;
- vendorNTE: string;
- source: string;
-}
-
-export interface LocationOption {
- id: string | number;
- name: string;
-}
-
-export interface EmployeeOption {
- id: string | number;
- name: string;
-}
-
-export interface VendorDropdownItem {
- id: string | number;
- companyName: string;
- tradeSpecialties: string;
- address: string;
- distanceMiles: number | null;
-}
-
-export interface TaskListTemplate {
- id: string | number;
- name: string;
- items: { id?: string | number; itemText?: string }[];
-}
-
-export interface DispatchChecklistItem {
- id: string | number;
- itemText: string;
- isCompleted: boolean;
- completedBy: string | null;
- completedAt: string | null;
-}
-
-export interface DispatchSignoff {
- id: string | number;
- signoffType: string;
- name: string;
- signature: string;
- signatureMethod: string;
- signedAt: string;
-}
-
-export interface DispatchComment {
- id: string | number;
- createdDate: string;
- commenttext: string;
- userName: string;
- commenter: string | null;
-}
-
-export interface DispatchWorkOrderRef {
- id: string | number;
- woNumber: string;
-}
-
-export interface DispatchDetail {
- id: string | number;
- dispatchNumber: string;
- poNumber: string;
- vendorName: string;
- vendorEmail: string;
- woNumber: string;
- status: string;
- nteAmount: number | null;
- scheduledDate: string;
- completedDate: string;
- description: string;
- dispatchedAt: string;
- acknowledgedAt: string;
- verifiedAt: string;
- verifiedBy: string;
- checklistItems: DispatchChecklistItem[];
- signoffs: DispatchSignoff[];
- comments: DispatchComment[];
- workOrders: DispatchWorkOrderRef[];
-}
-
-export interface DispatchToVendorPayload {
- workOrderIds: (string | number)[];
- vendorIds: (string | number)[];
- nteAmount: number | null;
- scheduledDate: string | null;
- description: string;
- taskListTemplateId: number | null;
- customChecklistItems: string[] | null;
-}
-
-export interface UpdateDispatchPayload {
- id: string | number;
- status: string;
- nteAmount: number | null;
- scheduledDate: string | null;
- completedDate: string | null;
- description: string;
-}
-
-export interface UpliftRequest {
- id: string | number;
- status: string;
- currentNTE: number | null;
- requestedNTE: number;
- delta: number;
- vendorReason: string;
- requestedAt: string;
- requestedByVendorName: string;
- decidedAt: string;
- decidedByName: string;
- decisionNote: string;
- requiredTier: number;
- canDecide: boolean;
-}
-
-export const WORK_ORDER_STATUS_OPTIONS = [
- "Open",
- "In Progress",
- "On Hold",
- "Done",
- "Cancelled",
-] as const;
-
-export const WORK_ORDER_PRIORITY_OPTIONS = ["Sev 1", "Sev 2", "Sev 3", "Sev 4", "Sev 5"] as const;
-
-export const DISPATCH_STATUS_OPTIONS = [
- "Sent",
- "Acknowledged",
- "In Progress",
- "Completed",
- "Verified",
- "Cancelled",
-] as const;
-
-export function createDefaultWorkOrderFormValues(): WorkOrderFormValues {
- return {
- internalWONumber: "",
- workOrderNumber: "",
- title: "",
- locationId: "",
- priority: "",
- status: "Open",
- createdDate: new Date().toISOString().split("T")[0] ?? "",
- dueDate: "",
- scheduledDate: "",
- completedDate: "",
- assignedTo: "",
- description: "",
- po: "",
- tt: "",
- problem: "",
- trade: "",
- subTrade: "",
- vendorNTE: "",
- source: "",
- };
-}
+export {
+ EMPTY_TABLE_DRAFT,
+ UNSCHEDULED_DAY_KEY,
+ WORK_ORDERS_TABLE_PAGE_SIZE,
+} from "./work-order-table-row";
diff --git a/src/domain/work-orders/use-cases/fetch-work-order-employees.ts b/src/domain/work-orders/use-cases/fetch-work-order-employees.ts
new file mode 100644
index 00000000..56746004
--- /dev/null
+++ b/src/domain/work-orders/use-cases/fetch-work-order-employees.ts
@@ -0,0 +1,35 @@
+import { API_PATHS } from "@/api/api-paths";
+import { apiGet } from "@/api/api";
+import { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response";
+import { mapEmployeeOptions } from "@/domain/work-orders/mappers/work-order-mapper";
+import type { EmployeeOption } from "@/domain/work-orders/types/work-order";
+
+async function fetchEmployeesFromList(): Promise {
+ const data = await apiGet(API_PATHS.employee.getList, {
+ searchParams: { page: 1, pageSize: 1000 },
+ });
+ const paginated = handlePaginatedResponse(data);
+ const items = paginated.items.map((item) => {
+ const record = (typeof item === "object" && item !== null ? item : {}) as Record<
+ string,
+ unknown
+ >;
+ const firstName = String(record.firstName ?? record.FirstName ?? "");
+ const lastName = String(record.lastName ?? record.LastName ?? "");
+ return {
+ id: record.id ?? record.Id,
+ name: `${firstName} ${lastName}`.trim(),
+ };
+ });
+ return mapEmployeeOptions(items);
+}
+
+export async function fetchWorkOrderEmployees(): Promise {
+ try {
+ const data = await apiGet(API_PATHS.employee.dropdown);
+ const items = handleApiResponse(data);
+ return Array.isArray(items) ? mapEmployeeOptions(items) : [];
+ } catch {
+ return fetchEmployeesFromList();
+ }
+}
diff --git a/src/domain/work-orders/use-cases/use-create-work-order-from-wizard.ts b/src/domain/work-orders/use-cases/use-create-work-order-from-wizard.ts
new file mode 100644
index 00000000..ae204d3a
--- /dev/null
+++ b/src/domain/work-orders/use-cases/use-create-work-order-from-wizard.ts
@@ -0,0 +1,35 @@
+import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
+import { toast } from "react-toastify";
+import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
+import { shouldUseMockWoWizardCreate } from "@/domain/work-orders/mocks/is-wo-wizard-mock-enabled";
+import { buildMockWorkOrderDetail } from "@/domain/work-orders/mocks/wo-wizard-mock-data";
+import type { WorkOrderFormValues } from "@/domain/work-orders/types/work-order-detail";
+import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order";
+import { queryKeys } from "@/infra/query-key/query-key";
+
+async function createWorkOrderFromWizard(values: WorkOrderFormValues): Promise {
+ if (shouldUseMockWoWizardCreate(values.locationId)) {
+ await new Promise((resolve) => setTimeout(resolve, 400));
+ return buildMockWorkOrderDetail(values);
+ }
+
+ return workOrdersApi.create(values);
+}
+
+export function useCreateWorkOrderFromWizard(
+ onSuccess?: () => void,
+): UseMutationResult {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: createWorkOrderFromWizard,
+ onSuccess: () => {
+ void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
+ toast.success("Work Order created successfully!");
+ onSuccess?.();
+ },
+ onError: (error: Error) => {
+ toast.error(error.message || "Failed to create work order");
+ },
+ });
+}
diff --git a/src/domain/work-orders/use-cases/use-work-order-detail.ts b/src/domain/work-orders/use-cases/use-work-order-detail.ts
index 5b451fc6..3b7c1456 100644
--- a/src/domain/work-orders/use-cases/use-work-order-detail.ts
+++ b/src/domain/work-orders/use-cases/use-work-order-detail.ts
@@ -1,5 +1,6 @@
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
+import { fetchWorkOrderEmployees } from "@/domain/work-orders/use-cases/fetch-work-order-employees";
import type {
DispatchDetail,
EmployeeOption,
@@ -32,7 +33,7 @@ export function useWorkOrderLocations(): UseQueryResult
export function useWorkOrderEmployeesDropdown(): UseQueryResult {
return useQuery({
queryKey: [...queryKeys.employees.all, "dropdown"] as const,
- queryFn: () => workOrdersApi.fetchEmployeesDropdown(),
+ queryFn: () => fetchWorkOrderEmployees(),
staleTime: 5 * 60 * 1000,
});
}
diff --git a/src/domain/work-orders/use-cases/use-work-order-mutations.ts b/src/domain/work-orders/use-cases/use-work-order-mutations.ts
index 2c8f1813..ed877ab5 100644
--- a/src/domain/work-orders/use-cases/use-work-order-mutations.ts
+++ b/src/domain/work-orders/use-cases/use-work-order-mutations.ts
@@ -20,32 +20,32 @@ function invalidateWorkOrders(queryClient: ReturnType) {
interface AddWorkOrderCommentInput {
text: string;
- commentType: string;
+ commentType: "customer" | "internal";
}
-interface ChangeWorkOrderAssignmentInput {
- userId: string | number;
- userName: string;
-}
-
-interface AddDispatchCommentInput {
+export interface AddDispatchCommentInput {
text: string;
sendEmail: boolean;
}
-interface UpdateChecklistItemInput {
+export interface UpdateChecklistItemInput {
itemId: string | number;
isCompleted: boolean;
completedBy: string;
}
-interface AddDispatchSignoffInput {
+export interface AddDispatchSignoffInput {
signoffType: string;
name: string;
signature: string;
signatureMethod: string;
}
+interface ChangeWorkOrderAssignmentInput {
+ userId: string | number;
+ userName: string;
+}
+
export function useCreateWorkOrder(): UseMutationResult<
WorkOrderDetail,
Error,
diff --git a/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts b/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts
new file mode 100644
index 00000000..bfe345f2
--- /dev/null
+++ b/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts
@@ -0,0 +1,154 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { toast } from "react-toastify";
+import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
+import {
+ mapApiStatusToWizard,
+ mapWizardStatusToApi,
+} from "@/domain/work-orders/mappers/wizard-draft-mapper";
+import {
+ mapWorkOrderDetailToFormValues,
+ mapWorkOrderTableRow,
+} from "@/domain/work-orders/mappers/work-order-mapper";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+import type { WizardWOStatus } from "@/domain/work-orders/types/work-order-wizard";
+import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
+import { enrichTableRow } from "@/domain/work-orders/utils/table-filter-utils";
+import { queryKeys } from "@/infra/query-key/query-key";
+
+export type WorkOrderTablePatch = Partial;
+
+async function loadFormAndUpdate(
+ id: string | number,
+ patch: (
+ values: ReturnType,
+ ) => ReturnType,
+) {
+ const detail = await workOrdersApi.getById(id);
+ const values = mapWorkOrderDetailToFormValues(detail);
+ const next = patch(values);
+ await workOrdersApi.update(id, next);
+ return detail;
+}
+
+export function useWorkOrderTableMutations(
+ onPatch: (id: string | number, patch: WorkOrderTablePatch) => void,
+) {
+ const queryClient = useQueryClient();
+
+ const invalidate = () => {
+ void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
+ };
+
+ const patchMutation = useMutation({
+ mutationFn: async ({
+ id,
+ patch,
+ useFullUpdate,
+ }: {
+ id: string | number;
+ patch: WorkOrderTablePatch;
+ useFullUpdate?: boolean;
+ }) => {
+ if (patch.status !== undefined && !useFullUpdate) {
+ const apiStatus = mapWizardStatusToApi(patch.status as WizardWOStatus);
+ await workOrdersApi.changeStatus(id, apiStatus);
+ return patch;
+ }
+ if (patch.dispatcherId !== undefined && !useFullUpdate) {
+ await workOrdersApi.changeAssignment(id, patch.dispatcherId || "");
+ return {
+ ...patch,
+ dispatcherName: patch.dispatcherName ?? "",
+ };
+ }
+ await loadFormAndUpdate(id, (values) => ({
+ ...values,
+ workOrderNumber: patch.woNumber ?? values.workOrderNumber,
+ internalWONumber: patch.woNumber ?? values.internalWONumber,
+ locationId: patch.locationId != null ? String(patch.locationId) : values.locationId,
+ dueDate: patch.dueDate ?? values.dueDate,
+ scheduledDate: patch.scheduledOn ?? values.scheduledDate,
+ assignedTo: patch.dispatcherName ?? values.assignedTo,
+ problem: patch.pm ?? values.problem,
+ source: patch.type ?? values.source,
+ priority: values.priority,
+ status:
+ patch.status !== undefined
+ ? mapWizardStatusToApi(patch.status as WizardWOStatus)
+ : values.status,
+ description: [
+ values.description,
+ patch.pocNotes ? `POC notes: ${patch.pocNotes}` : "",
+ patch.pocName || patch.pocPhone
+ ? `POC: ${[patch.pocName, patch.pocPhone].filter(Boolean).join(" · ")}`
+ : "",
+ ]
+ .filter(Boolean)
+ .join("\n"),
+ }));
+ return patch;
+ },
+ onMutate: async ({ id, patch }) => {
+ onPatch(id, patch);
+ },
+ onSuccess: () => {
+ invalidate();
+ },
+ onError: (error: Error) => {
+ toast.error(error.message || "Failed to update work order");
+ invalidate();
+ },
+ });
+
+ const patchField = (id: string | number, patch: WorkOrderTablePatch, useFullUpdate = false) => {
+ patchMutation.mutate({ id, patch, useFullUpdate });
+ };
+
+ return {
+ patchField,
+ isPending: patchMutation.isPending,
+ };
+}
+
+export function tableRowFromDetail(detail: Awaited>) {
+ return enrichTableRow(
+ mapWorkOrderTableRow({
+ ...detail,
+ status: mapApiStatusToWizard(detail.status),
+ scheduledOn: detail.scheduledDate,
+ woNumber: detail.internalWONumber || detail.workOrderNumber,
+ dispatcherName: detail.assignedTo,
+ pm: detail.problem,
+ type: detail.source,
+ }),
+ );
+}
+
+export function maybeAutoSchedulePatch(row: WorkOrderTableRow): WorkOrderTablePatch {
+ const hasDate = !!row.scheduledOn;
+ const hasDispatcher = !!row.dispatcherId;
+ if (hasDate && hasDispatcher && row.status === "Incomplete") {
+ return { status: "Scheduled" };
+ }
+ return {};
+}
+
+export function applySchedulePatch(
+ scheduledOn: string,
+ scheduleWeekOnly: boolean,
+): WorkOrderTablePatch {
+ if (scheduleWeekOnly) {
+ return {
+ scheduledOn: "",
+ targetWeek: scheduledOn,
+ scheduleWeekOnly: true,
+ ...deriveDayFields(""),
+ };
+ }
+ return {
+ scheduledOn,
+ scheduleWeekOnly: false,
+ targetWeek: "",
+ ...deriveDayFields(scheduledOn),
+ };
+}
diff --git a/src/domain/work-orders/use-cases/use-work-orders-list.ts b/src/domain/work-orders/use-cases/use-work-orders-list.ts
index a0b6ef07..13defc06 100644
--- a/src/domain/work-orders/use-cases/use-work-orders-list.ts
+++ b/src/domain/work-orders/use-cases/use-work-orders-list.ts
@@ -11,7 +11,7 @@ export function useWorkOrdersList(
params: WorkOrdersListParams,
): UseQueryResult {
return useQuery({
- queryKey: queryKeys.workOrders.list(params as Record),
+ queryKey: queryKeys.workOrders.list(params),
queryFn: () => workOrdersApi.getList(params),
placeholderData: keepPreviousData,
});
@@ -24,3 +24,16 @@ export function useWorkOrderUsers(): UseQueryResult {
staleTime: 5 * 60 * 1000,
});
}
+
+export function useWorkOrdersByLocation(
+ locationId: string | number | undefined,
+ search = "",
+ enabled = true,
+): UseQueryResult {
+ return useQuery({
+ queryKey: queryKeys.workOrders.byLocation(locationId ?? "", search),
+ queryFn: () => workOrdersApi.getListByLocation(locationId!, search),
+ enabled: enabled && locationId !== undefined && locationId !== "",
+ staleTime: 30 * 1000,
+ });
+}
diff --git a/src/domain/work-orders/utils/advanced-filters-utils.ts b/src/domain/work-orders/utils/advanced-filters-utils.ts
new file mode 100644
index 00000000..ad132df4
--- /dev/null
+++ b/src/domain/work-orders/utils/advanced-filters-utils.ts
@@ -0,0 +1,240 @@
+import {
+ ASSIGNEE_FILTER_UNASSIGNED,
+ DATE_RANGE_OPTIONS,
+ DOC_FILTER_LABELS,
+ EMPTY_ADVANCED_FILTERS,
+ type CompletionDocFilter,
+ type DateRangeKey,
+ type WorkOrderAdvancedFilters,
+} from "@/domain/work-orders/types/work-order-advanced-filters";
+import {
+ MONTH_NAMES,
+ addDaysIso,
+ mondayOf,
+ pad2,
+ todayIso,
+} from "@/domain/work-orders/utils/wizard-date-utils";
+
+export interface AdvancedFilterChip {
+ key: string;
+ group: string;
+ value?: string;
+ count?: number;
+ accent?: boolean;
+ clear: (filters: WorkOrderAdvancedFilters) => WorkOrderAdvancedFilters;
+}
+
+function cloneFilters(filters: WorkOrderAdvancedFilters): WorkOrderAdvancedFilters {
+ return {
+ ...filters,
+ sites: new Set(filters.sites),
+ types: new Set(filters.types),
+ dispatchers: new Set(filters.dispatchers),
+ statuses: new Set(filters.statuses),
+ pmTypes: new Set(filters.pmTypes),
+ vendorTechs: new Set(filters.vendorTechs),
+ docs: new Set(filters.docs),
+ };
+}
+
+export function countAdvancedFilters(filters: WorkOrderAdvancedFilters): number {
+ let count = 0;
+ if (filters.dateRange !== "this-week") count += 1;
+ if (filters.sites.size) count += 1;
+ if (filters.types.size) count += 1;
+ if (filters.dispatchers.size) count += 1;
+ if (filters.statuses.size) count += 1;
+ if (filters.pmTypes.size) count += 1;
+ if (filters.vendorTechs.size) count += 1;
+ if (filters.docs.size) count += 1;
+ return count;
+}
+
+function fmtChipDate(iso: string): string {
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) return "";
+ const [, m, d] = iso.split("-").map(Number);
+ return `${MONTH_NAMES[m - 1].slice(0, 3)} ${d}`;
+}
+
+function monthStart(y: number, m: number): string {
+ return `${y}-${pad2(m + 1)}-01`;
+}
+
+function monthEnd(y: number, m: number): string {
+ return `${y}-${pad2(m + 1)}-${pad2(new Date(y, m + 1, 0).getDate())}`;
+}
+
+export function dateRangeFor(filters: WorkOrderAdvancedFilters): [string, string] {
+ const today = todayIso();
+ const ty = Number(today.slice(0, 4));
+ const tm = Number(today.slice(5, 7)) - 1;
+ const thisWeekMonday = mondayOf(today);
+ const lastWeekMonday = addDaysIso(thisWeekMonday, -7);
+
+ switch (filters.dateRange) {
+ case "this-week":
+ return [thisWeekMonday, addDaysIso(thisWeekMonday, 4)];
+ case "last-week":
+ return [lastWeekMonday, addDaysIso(lastWeekMonday, 4)];
+ case "this-month":
+ return [monthStart(ty, tm), monthEnd(ty, tm)];
+ case "last-3-months": {
+ const d = new Date(ty, tm - 2, 1);
+ return [monthStart(d.getFullYear(), d.getMonth()), monthEnd(ty, tm)];
+ }
+ case "next-week":
+ return [today, addDaysIso(today, 7)];
+ case "next-month":
+ return [today, addDaysIso(today, 30)];
+ case "custom":
+ return [filters.customFrom || "0000-01-01", filters.customTo || "9999-12-31"];
+ }
+}
+
+function setChip(
+ chips: AdvancedFilterChip[],
+ key: string,
+ group: string,
+ set: Set,
+ nameOf: (value: string) => string,
+ clear: (filters: WorkOrderAdvancedFilters) => WorkOrderAdvancedFilters,
+) {
+ if (!set.size) return;
+ if (set.size <= 2) {
+ chips.push({ key, group, value: [...set].map(nameOf).join(", "), clear });
+ return;
+ }
+ chips.push({ key, group, count: set.size, clear });
+}
+
+export function buildAdvancedFilterChips(
+ filters: WorkOrderAdvancedFilters,
+ options?: {
+ siteNameOf?: (id: string) => string;
+ dispatcherNameOf?: (id: string) => string;
+ },
+): AdvancedFilterChip[] {
+ const chips: AdvancedFilterChip[] = [];
+ const siteNameOf = options?.siteNameOf ?? ((v) => v);
+ const dispatcherNameOf = options?.dispatcherNameOf ?? ((v) => v);
+
+ if (filters.dateRange !== "this-week") {
+ const value =
+ filters.dateRange === "custom"
+ ? `${fmtChipDate(filters.customFrom) || "Any"} – ${fmtChipDate(filters.customTo) || "Any"}`
+ : (DATE_RANGE_OPTIONS.find((o) => o.key === filters.dateRange)?.label ?? "Custom");
+ chips.push({
+ key: "date",
+ group: "Date",
+ value,
+ clear: (current) => ({
+ ...current,
+ dateRange: "this-week",
+ customFrom: "",
+ customTo: "",
+ }),
+ });
+ }
+
+ setChip(chips, "sites", "Site", filters.sites, siteNameOf, (current) => ({
+ ...current,
+ sites: new Set(),
+ }));
+
+ setChip(
+ chips,
+ "types",
+ "Type of WO",
+ filters.types as Set,
+ (v) => v,
+ (current) => ({ ...current, types: new Set() }),
+ );
+
+ const realDispatchers = new Set(
+ [...filters.dispatchers].filter((id) => id !== ASSIGNEE_FILTER_UNASSIGNED),
+ );
+ setChip(chips, "disp", "Assigned to", realDispatchers, dispatcherNameOf, (current) => ({
+ ...current,
+ dispatchers: new Set(
+ [...current.dispatchers].filter((id) => id === ASSIGNEE_FILTER_UNASSIGNED),
+ ),
+ }));
+
+ if (filters.dispatchers.has(ASSIGNEE_FILTER_UNASSIGNED)) {
+ chips.push({
+ key: "disp-unassigned",
+ group: "Assigned to",
+ value: "Unassigned",
+ accent: true,
+ clear: (current) => {
+ const next = new Set(current.dispatchers);
+ next.delete(ASSIGNEE_FILTER_UNASSIGNED);
+ return { ...current, dispatchers: next };
+ },
+ });
+ }
+
+ setChip(
+ chips,
+ "status",
+ "Status",
+ filters.statuses as Set,
+ (v) => v,
+ (current) => ({ ...current, statuses: new Set() }),
+ );
+
+ setChip(
+ chips,
+ "pm",
+ "PM type",
+ filters.pmTypes,
+ (v) => v,
+ (current) => ({ ...current, pmTypes: new Set() }),
+ );
+
+ setChip(
+ chips,
+ "vendor",
+ "Vendor / tech",
+ filters.vendorTechs,
+ (v) => v,
+ (current) => ({ ...current, vendorTechs: new Set() }),
+ );
+
+ setChip(
+ chips,
+ "doc",
+ "Comp doc",
+ filters.docs as Set,
+ (v) => DOC_FILTER_LABELS[v as CompletionDocFilter] ?? v,
+ (current) => ({ ...current, docs: new Set() }),
+ );
+
+ return chips;
+}
+
+export function clearAdvancedFilterGroup(
+ filters: WorkOrderAdvancedFilters,
+ chipKey: string,
+): WorkOrderAdvancedFilters | null {
+ const chips = buildAdvancedFilterChips(filters);
+ const chip = chips.find((c) => c.key === chipKey);
+ if (!chip) return filters;
+ const next = chip.clear(cloneFilters(filters));
+ return countAdvancedFilters(next) > 0 ? next : null;
+}
+
+export function getInitialAdvancedFilters(
+ applied: WorkOrderAdvancedFilters | null,
+): WorkOrderAdvancedFilters {
+ if (!applied) return cloneFilters(EMPTY_ADVANCED_FILTERS);
+ return cloneFilters(applied);
+}
+
+export function cloneAdvancedFilters(filters: WorkOrderAdvancedFilters): WorkOrderAdvancedFilters {
+ return cloneFilters(filters);
+}
+
+export function isDefaultDateRange(dateRange: DateRangeKey): boolean {
+ return dateRange === "this-week";
+}
diff --git a/src/domain/work-orders/utils/list-week-utils.ts b/src/domain/work-orders/utils/list-week-utils.ts
new file mode 100644
index 00000000..221ad4bd
--- /dev/null
+++ b/src/domain/work-orders/utils/list-week-utils.ts
@@ -0,0 +1,68 @@
+import { addDaysIso, mondayOf, todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
+
+export interface WorkWeek {
+ mondayIso: string;
+ label: string;
+ live: boolean;
+}
+
+export function currentWeekMonday(): string {
+ return mondayOf(todayIso());
+}
+
+export function weekRange(mondayIso: string): [string, string] {
+ return [mondayIso, addDaysIso(mondayIso, 4)];
+}
+
+export function compactWeekLabel(mondayIso: string): string {
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(mondayIso)) {
+ return "";
+ }
+ const fridayIso = addDaysIso(mondayIso, 4);
+ const [, sm, sd] = mondayIso.split("-").map(Number);
+ const [, em, ed] = fridayIso.split("-").map(Number);
+ return `${sm}/${sd}–${em}/${ed}`;
+}
+
+export function isLiveWeek(mondayIso: string): boolean {
+ return mondayIso === currentWeekMonday();
+}
+
+export function isFutureWeek(mondayIso: string): boolean {
+ return mondayIso > currentWeekMonday();
+}
+
+export function nextWeekMonday(): string {
+ return addDaysIso(currentWeekMonday(), 7);
+}
+
+function toWorkWeek(mondayIso: string): WorkWeek {
+ return {
+ mondayIso,
+ label: compactWeekLabel(mondayIso),
+ live: isLiveWeek(mondayIso),
+ };
+}
+
+export function buildWeekWindow(anchorMonday: string, before = 4, after = 4): WorkWeek[] {
+ const weeks: WorkWeek[] = [];
+ const liveMonday = currentWeekMonday();
+ const upcomingMonday = nextWeekMonday();
+
+ for (let i = -before; i <= after; i += 1) {
+ const mondayIso = addDaysIso(anchorMonday, i * 7);
+ if (isFutureWeek(mondayIso) && mondayIso !== upcomingMonday) {
+ continue;
+ }
+ weeks.push(toWorkWeek(mondayIso));
+ }
+
+ for (const requiredMonday of [liveMonday, upcomingMonday]) {
+ if (!weeks.some((week) => week.mondayIso === requiredMonday)) {
+ weeks.push(toWorkWeek(requiredMonday));
+ }
+ }
+
+ weeks.sort((a, b) => a.mondayIso.localeCompare(b.mondayIso));
+ return weeks;
+}
diff --git a/src/domain/work-orders/utils/table-day-utils.ts b/src/domain/work-orders/utils/table-day-utils.ts
new file mode 100644
index 00000000..49dc123b
--- /dev/null
+++ b/src/domain/work-orders/utils/table-day-utils.ts
@@ -0,0 +1,46 @@
+import { addDaysIso, pad2 } from "@/domain/work-orders/utils/wizard-date-utils";
+
+export const WEEKDAY_LABELS = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"] as const;
+
+export function deriveDayFields(rawDate: string): {
+ dayGroup: string;
+ dayLabel: string;
+} {
+ const dayGroup = rawDate ? rawDate.replace(/^\d{4}-(\d{2})-(\d{2})$/, "$1/$2") : "";
+ const dayLabel = rawDate
+ ? (() => {
+ const d = new Date(`${rawDate}T12:00:00`);
+ return `${WEEKDAY_LABELS[d.getDay()]} · ${dayGroup}`;
+ })()
+ : "";
+ return { dayGroup, dayLabel };
+}
+
+export function buildWeekSkeleton(weekMonday: string): {
+ key: string;
+ label: string;
+ date: string;
+ items: never[];
+}[] {
+ const base = new Date(`${weekMonday}T12:00:00`);
+ return Array.from({ length: 5 }, (_, i) => {
+ const d = new Date(base);
+ d.setDate(base.getDate() + i);
+ const key = `${pad2(d.getMonth() + 1)}/${pad2(d.getDate())}`;
+ const iso = `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
+ return {
+ key,
+ label: `${WEEKDAY_LABELS[d.getDay()]} · ${key}`,
+ date: iso,
+ items: [],
+ };
+ });
+}
+
+export function isDateInWeek(iso: string, weekMonday: string): boolean {
+ if (!iso || !/^\d{4}-\d{2}-\d{2}$/.test(iso)) {
+ return false;
+ }
+ const friday = addDaysIso(weekMonday, 4);
+ return iso >= weekMonday && iso <= friday;
+}
diff --git a/src/domain/work-orders/utils/table-filter-utils.ts b/src/domain/work-orders/utils/table-filter-utils.ts
new file mode 100644
index 00000000..afe15972
--- /dev/null
+++ b/src/domain/work-orders/utils/table-filter-utils.ts
@@ -0,0 +1,166 @@
+import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters";
+import { ASSIGNEE_FILTER_UNASSIGNED } from "@/domain/work-orders/types/work-order-list";
+import type { WorkOrderBarFilters } from "@/domain/work-orders/types/work-order-list-filters";
+import type {
+ WorkOrderDayGroup,
+ WorkOrderTableRow,
+} from "@/domain/work-orders/types/work-order-table-row";
+import { dateRangeFor } from "@/domain/work-orders/utils/advanced-filters-utils";
+import { isDateInWeek } from "@/domain/work-orders/utils/table-day-utils";
+import { weekRange } from "@/domain/work-orders/utils/list-week-utils";
+
+const TERMINAL_STATUSES = new Set(["Completed", "Canceled", "Done", "Cancelled"]);
+
+export function computeIsPastDue(row: WorkOrderTableRow): boolean {
+ if (!row.dueDate || TERMINAL_STATUSES.has(row.status)) {
+ return false;
+ }
+ const today = new Date().toISOString().slice(0, 10);
+ return row.dueDate < today;
+}
+
+export function enrichTableRow(row: WorkOrderTableRow): WorkOrderTableRow {
+ return {
+ ...row,
+ isPastDue: computeIsPastDue(row),
+ };
+}
+
+export function matchesAdvancedFilters(
+ row: WorkOrderTableRow,
+ filters: WorkOrderAdvancedFilters,
+): boolean {
+ const [from, to] = dateRangeFor(filters);
+ if (row.scheduledOn && (row.scheduledOn < from || row.scheduledOn > to)) {
+ return false;
+ }
+ if (filters.sites.size && !filters.sites.has(row.site)) {
+ return false;
+ }
+ if (filters.types.size && !filters.types.has(row.type as never)) {
+ return false;
+ }
+ if (filters.dispatchers.size) {
+ const unassignedMatch =
+ filters.dispatchers.has(ASSIGNEE_FILTER_UNASSIGNED) && !row.dispatcherId;
+ const idMatch = !!row.dispatcherId && filters.dispatchers.has(row.dispatcherId);
+ if (!unassignedMatch && !idMatch) {
+ return false;
+ }
+ }
+ if (filters.statuses.size && (!row.status || !filters.statuses.has(row.status as never))) {
+ return false;
+ }
+ if (filters.pmTypes.size && !filters.pmTypes.has(row.pm)) {
+ return false;
+ }
+ if (filters.vendorTechs.size && !filters.vendorTechs.has(row.tech)) {
+ return false;
+ }
+ if (filters.docs.size && !filters.docs.has(row.docStatus as never)) {
+ return false;
+ }
+ return true;
+}
+
+function rowMatchesSearch(row: WorkOrderTableRow, query: string): boolean {
+ const q = query.trim().toLowerCase();
+ if (!q) {
+ return true;
+ }
+ const haystack = [
+ row.site,
+ row.location,
+ row.woNumber,
+ row.type,
+ row.dispatcherName,
+ row.scheduledOn,
+ row.dueDate,
+ row.pm,
+ row.company,
+ row.tech,
+ row.status,
+ row.title,
+ ]
+ .join(" ")
+ .toLowerCase();
+ return haystack.includes(q);
+}
+
+export function filterByBarAndSearch(
+ rows: WorkOrderTableRow[],
+ bar: WorkOrderBarFilters,
+ search: string,
+): WorkOrderTableRow[] {
+ const [weekFrom, weekTo] = weekRange(bar.weekMonday);
+
+ return rows.filter((row) => {
+ if (!rowMatchesSearch(row, search)) {
+ return false;
+ }
+
+ if (bar.type !== "All" && row.type !== bar.type) {
+ return false;
+ }
+
+ if (bar.dispatcherIds.size > 0) {
+ const unassignedMatch =
+ bar.dispatcherIds.has(ASSIGNEE_FILTER_UNASSIGNED) && !row.dispatcherId;
+ const idMatch = !!row.dispatcherId && bar.dispatcherIds.has(String(row.dispatcherId));
+ if (!unassignedMatch && !idMatch) {
+ return false;
+ }
+ }
+
+ if (row.scheduledOn) {
+ if (row.scheduledOn < weekFrom || row.scheduledOn > weekTo) {
+ return false;
+ }
+ } else if (row.targetWeek) {
+ if (!isDateInWeek(row.targetWeek, bar.weekMonday)) {
+ return false;
+ }
+ }
+
+ return true;
+ });
+}
+
+export function splitScheduledUnscheduled(rows: WorkOrderTableRow[]): {
+ scheduled: WorkOrderTableRow[];
+ unscheduled: WorkOrderTableRow[];
+} {
+ const scheduled: WorkOrderTableRow[] = [];
+ const unscheduled: WorkOrderTableRow[] = [];
+
+ for (const row of rows) {
+ if (!row.scheduledOn) {
+ unscheduled.push(row);
+ } else {
+ scheduled.push(row);
+ }
+ }
+
+ return { scheduled, unscheduled };
+}
+
+export function buildDayGroups(scheduled: WorkOrderTableRow[]): WorkOrderDayGroup[] {
+ const groups: WorkOrderDayGroup[] = [];
+
+ for (const row of scheduled) {
+ const existing = groups.find((group) => group.key === row.dayGroup);
+ if (existing) {
+ existing.items.push(row);
+ } else {
+ groups.push({
+ key: row.dayGroup,
+ label: row.dayLabel,
+ date: row.scheduledOn,
+ items: [row],
+ });
+ }
+ }
+
+ groups.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
+ return groups;
+}
diff --git a/src/domain/work-orders/utils/wizard-date-utils.ts b/src/domain/work-orders/utils/wizard-date-utils.ts
new file mode 100644
index 00000000..5f03d7c4
--- /dev/null
+++ b/src/domain/work-orders/utils/wizard-date-utils.ts
@@ -0,0 +1,148 @@
+export const MONTH_NAMES = [
+ "January",
+ "February",
+ "March",
+ "April",
+ "May",
+ "June",
+ "July",
+ "August",
+ "September",
+ "October",
+ "November",
+ "December",
+];
+
+export function pad2(n: number): string {
+ return String(n).padStart(2, "0");
+}
+
+export function todayIso(): string {
+ const d = new Date();
+ return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
+}
+
+export function fmtDate(iso: string): string {
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) {
+ return "";
+ }
+ const [y, m, d] = iso.split("-").map(Number);
+ return `${MONTH_NAMES[m - 1].slice(0, 3)} ${d}, ${y}`;
+}
+
+export function addDaysIso(iso: string, days: number): string {
+ const d = new Date(`${iso}T12:00:00`);
+ d.setDate(d.getDate() + days);
+ return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
+}
+
+export function mondayOf(iso: string): string {
+ const d = new Date(`${iso}T12:00:00`);
+ const dow = d.getDay();
+ d.setDate(d.getDate() + (dow === 0 ? -6 : 1 - dow));
+ return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
+}
+
+export function weeksOfMonth(y: number, m: number): string[] {
+ const firstIso = `${y}-${pad2(m)}-01`;
+ const lastIso = `${y}-${pad2(m)}-${pad2(new Date(y, m, 0).getDate())}`;
+ const out: string[] = [];
+ for (let mon = mondayOf(firstIso); mon <= lastIso; mon = addDaysIso(mon, 7)) {
+ if (addDaysIso(mon, 4) >= firstIso) {
+ out.push(mon);
+ }
+ }
+ return out;
+}
+
+export function weekRangeLabel(mondayIso: string): string {
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(mondayIso)) {
+ return "";
+ }
+ const fri = addDaysIso(mondayIso, 4);
+ const [, sm, sd] = mondayIso.split("-").map(Number);
+ const [, em, ed] = fri.split("-").map(Number);
+ return `${MONTH_NAMES[sm - 1].slice(0, 3)} ${sd} – ${MONTH_NAMES[em - 1].slice(0, 3)} ${ed}`;
+}
+
+export function fmtWeekRange(startIso: string): string {
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(startIso)) {
+ return fmtDate(startIso);
+ }
+ return weekRangeLabel(startIso);
+}
+
+export function fmt12(t: string): string {
+ const [h, m] = t.split(":").map(Number);
+ const ampm = h < 12 ? "AM" : "PM";
+ const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h;
+ return `${h12}:${String(m).padStart(2, "0")} ${ampm}`;
+}
+
+export function calcDuration(s: string, e: string): string {
+ const [sh, sm] = s.split(":").map(Number);
+ const [eh, em] = e.split(":").map(Number);
+ const mins = eh * 60 + em - (sh * 60 + sm);
+ if (mins <= 0) {
+ return "";
+ }
+ const h = Math.floor(mins / 60);
+ const m = mins % 60;
+ if (h && m) {
+ return `${h}h ${m}m`;
+ }
+ if (h) {
+ return `${h}h`;
+ }
+ return `${m}m`;
+}
+
+export function formatApptRange(start: string, end: string): string {
+ if (!start) {
+ return "—";
+ }
+ return end ? `${fmt12(start)} – ${fmt12(end)}` : fmt12(start);
+}
+
+export const TIME_SLOTS: string[] = (() => {
+ const out: string[] = [];
+ for (let m = 5 * 60; m <= 22 * 60; m += 30) {
+ const h = Math.floor(m / 60);
+ const mm = m % 60;
+ out.push(`${String(h).padStart(2, "0")}:${String(mm).padStart(2, "0")}`);
+ }
+ return out;
+})();
+
+export function parseApptTime(appt: string): { start: string; end: string } {
+ const m = appt.match(/(\d{1,2}):(\d{2})\s*[–-]\s*(\d{1,2}):(\d{2})/);
+ if (m) {
+ return {
+ start: `${m[1].padStart(2, "0")}:${m[2]}`,
+ end: `${m[3].padStart(2, "0")}:${m[4]}`,
+ };
+ }
+ const single = appt.match(/(\d{1,2}):(\d{2})\s*(AM|PM)/i);
+ if (single) {
+ let h = Number(single[1]) % 12;
+ if (/pm/i.test(single[3])) {
+ h += 12;
+ }
+ return { start: `${pad2(h)}:${single[2]}`, end: "" };
+ }
+ return { start: "", end: "" };
+}
+
+export function formatApptTimeValue(start: string, end: string): string {
+ if (!start) {
+ return "";
+ }
+ return end ? `${start} – ${end}` : start;
+}
+
+export function fmtWeekOf(mondayIso: string): string {
+ if (!mondayIso) {
+ return "";
+ }
+ return `Week of ${weekRangeLabel(mondayIso)}`;
+}
diff --git a/src/infra/query-key/query-key.ts b/src/infra/query-key/query-key.ts
index c2951f72..21544914 100644
--- a/src/infra/query-key/query-key.ts
+++ b/src/infra/query-key/query-key.ts
@@ -1,4 +1,4 @@
-type WorkOrdersListParams = Record;
+import type { WorkOrdersListParams } from "@/domain/work-orders/types/work-order-list";
export const queryKeys = {
auth: {