Merge pull request #36 from Sea-Haven-Industries/feature/wo-domain-core

feat(work-orders): add domain core types, api, mappers and use-cases
This commit is contained in:
Alexandre Brandizzi 2026-07-21 14:07:44 -03:00 • committed by GitHub
commit 31e4fb1907
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 2758 additions and 315 deletions

View file

@ -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("") },
);
};

View file

@ -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("");
}}
>
<Typography variant="body2">
<strong>{item.number}</strong> — {item.title}
<strong>{item.woNumber}</strong> — {item.title}
</Typography>
</Box>
))}

View file

@ -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() {
</TableCell>
</TableRow>
) : (
rows.map((row: WorkOrderListItem) => (
rows.map((row: WorkOrderTableRow) => (
<TableRow key={String(row.id)} hover>
<TableCell>
<Button
@ -280,7 +280,7 @@ export default function WorkOrdersListPage() {
className="font-semibold"
onClick={() => navigate(`/workorders/${row.id}`)}
>
{row.number || row.id}
{row.woNumber || row.id}
</Button>
</TableCell>
<TableCell>{row.title}</TableCell>
@ -290,12 +290,10 @@ export default function WorkOrdersListPage() {
<WorkOrderStatusBadge status={row.status || "Open"} />
</TableCell>
<TableCell className="whitespace-nowrap">
{formatDateTime(row.createdDate)}
</TableCell>
<TableCell>{row.assignedTo || "Unassigned"}</TableCell>
<TableCell className="whitespace-nowrap">
{formatDateTime(row.lastUpdated)}
{formatDateTime(row.scheduledOn)}
</TableCell>
<TableCell>{row.dispatcherName || "Unassigned"}</TableCell>
<TableCell className="whitespace-nowrap">{formatDateTime(row.dueDate)}</TableCell>
<TableCell align="right">
<Tooltip title="View">
<IconButton

View file

@ -1,6 +1,18 @@
import { API_PATHS } from "@/api/api-paths";
import { apiDelete, apiGet, apiPost } from "@/api/api";
import { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response";
import {
isWoTableMockEnabled,
shouldUseMockWorkOrderDetail,
} from "@/domain/work-orders/mocks/is-wo-table-mock-enabled";
import {
buildMockWorkOrderDetailFromTableRow,
getMockWorkOrderLocations,
getMockWorkOrderTableRow,
getMockWorkOrderVendors,
getMockWorkOrdersList,
MOCK_WO_TABLE_USERS,
} from "@/domain/work-orders/mocks/wo-table-mock-data";
import {
mapDispatchComment,
mapDispatchDetail,
@ -15,7 +27,7 @@ import {
mapWorkOrderDetail,
mapWorkOrderFormToCreatePayload,
mapWorkOrderFormToUpdatePayload,
mapWorkOrderListItems,
mapWorkOrderTableRows,
} from "@/domain/work-orders/mappers/work-order-mapper";
import type { WorkOrderFormSchemaValues } from "@/domain/work-orders/schemas/work-order-schema";
import type {
@ -30,9 +42,12 @@ import type {
WorkOrderDetail,
WorkOrderFormValues,
WorkOrderUser,
WorkOrdersListParams,
WorkOrdersListResult,
} from "@/domain/work-orders/types/work-order";
import {
DISPATCH_LOCATION_WO_PAGE_SIZE,
type WorkOrdersListParams,
type WorkOrdersListResult,
} from "@/domain/work-orders/types/work-order-list";
function buildSearchParams(params: WorkOrdersListParams): Record<string, string | number> {
const searchParams: Record<string, string | number> = {
@ -56,6 +71,18 @@ function buildSearchParams(params: WorkOrdersListParams): Record<string, string
if (params.locationId) {
searchParams.locationId = params.locationId;
}
if (params.dateFrom) {
searchParams.dateFrom = params.dateFrom;
}
if (params.dateTo) {
searchParams.dateTo = params.dateTo;
}
if (params.type) {
searchParams.type = params.type;
}
if (params.assigneeIds) {
searchParams.assigneeIds = params.assigneeIds;
}
return searchParams;
}
@ -75,8 +102,17 @@ function mapUser(raw: unknown): WorkOrderUser {
};
}
async function mockNetworkDelay(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 250));
}
export const workOrdersApi = {
getList: async (params: WorkOrdersListParams = {}): Promise<WorkOrdersListResult> => {
if (isWoTableMockEnabled()) {
await mockNetworkDelay();
return getMockWorkOrdersList(params);
}
const searchParams = buildSearchParams(params);
const data = await apiGet<unknown>(API_PATHS.workOrder.getList, { searchParams });
const paginated = handlePaginatedResponse<unknown>(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<WorkOrdersListResult> => {
return workOrdersApi.getList({
locationId,
search,
page: 1,
pageSize: DISPATCH_LOCATION_WO_PAGE_SIZE,
sortBy: "number",
sortDir: "asc",
});
},
fetchUsers: async (): Promise<WorkOrderUser[]> => {
if (isWoTableMockEnabled()) {
return MOCK_WO_TABLE_USERS;
}
const data = await apiGet<unknown>(API_PATHS.user.list);
const users = handleApiResponse<unknown[]>(data);
return Array.isArray(users) ? users.map(mapUser) : [];
},
getById: async (id: string | number): Promise<WorkOrderDetail> => {
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<unknown>(API_PATHS.workOrder.getById, {
searchParams: { id },
});
@ -117,6 +178,22 @@ export const workOrdersApi = {
id: string | number,
values: WorkOrderFormSchemaValues | WorkOrderFormValues,
): Promise<WorkOrderDetail> => {
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<unknown>(API_PATHS.workOrder.editWorkorder, payload);
return mapWorkOrderDetail(handleApiResponse(data));
@ -142,50 +219,44 @@ export const workOrdersApi = {
},
changeStatus: async (id: string | number, status: string): Promise<void> => {
if (shouldUseMockWorkOrderDetail(id)) {
await mockNetworkDelay();
return;
}
await apiPost<unknown>(API_PATHS.workOrder.changeStatus, undefined, {
searchParams: { id, status },
});
},
changeAssignment: async (id: string | number, assignTo: string | number): Promise<void> => {
if (shouldUseMockWorkOrderDetail(id)) {
await mockNetworkDelay();
return;
}
await apiPost<unknown>(API_PATHS.workOrder.changeAssignment, undefined, {
searchParams: { id, assignTo: assignTo || "" },
});
},
fetchLocations: async (): Promise<LocationOption[]> => {
if (isWoTableMockEnabled()) {
return getMockWorkOrderLocations();
}
const data = await apiGet<unknown>(API_PATHS.contact.getLocations);
const items = handleApiResponse<unknown[]>(data);
return Array.isArray(items) ? mapLocationOptions(items) : [];
},
fetchEmployeesDropdown: async (): Promise<EmployeeOption[]> => {
try {
const data = await apiGet<unknown>(API_PATHS.employee.dropdown);
const items = handleApiResponse<unknown[]>(data);
return Array.isArray(items) ? mapEmployeeOptions(items) : [];
} catch {
const data = await apiGet<unknown>(API_PATHS.employee.getList, {
searchParams: { page: 1, pageSize: 1000 },
});
const paginated = handlePaginatedResponse<unknown>(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<unknown>(API_PATHS.employee.dropdown);
const items = handleApiResponse<unknown[]>(data);
return Array.isArray(items) ? mapEmployeeOptions(items) : [];
},
fetchVendorsDropdown: async (trade?: string, siteZip?: string): Promise<VendorDropdownItem[]> => {
if (isWoTableMockEnabled()) {
return getMockWorkOrderVendors();
}
const searchParams: Record<string, string> = {};
if (trade) {
searchParams.trade = trade;

View file

@ -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<WizardWOStatus, WorkOrderStatus> = {
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<Record<WorkOrderStatus, WizardWOStatus>> = {
Open: "Incomplete",
"In Progress": "In Progress",
"On Hold": "Pending",
Done: "Completed",
};
const TYPE_TO_PRIORITY: Record<WOType, string> = {
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,
};
}

View file

@ -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<string, unknown> {
return typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
}
function toWorkOrderStatus(value: string): WorkOrderStatus {
if (WORK_ORDER_STATUS_OPTIONS.includes(value as WorkOrderStatus)) {
return value as WorkOrderStatus;
}
return "Open";
}
function readString(record: Record<string, unknown>, ...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<string, unknown>): 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<string, unknown>): 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,

View file

@ -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<string, string>;
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))
);
}

View file

@ -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-");
}

View file

@ -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-");
}

View file

@ -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<WorkOrderTableRow, "scheduledOn" | "dayGroup" | "dayLabel"> {
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);
}

View file

@ -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<string, Location> = {
"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: [],
};
}

View file

@ -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<CompletionDocStatus, "">;

View file

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

View file

@ -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<string>;
types: Set<WOType>;
dispatchers: Set<string>;
statuses: Set<WizardWOStatus>;
pmTypes: Set<string>;
vendorTechs: Set<string>;
docs: Set<CompletionDocFilter>;
}
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<CompletionDocFilter, string> = {
No: "Pending",
Yes: "Uploaded",
NN: "N/N",
};
export const COMPLETION_DOC_OPTIONS: CompletionDocFilter[] = ["No", "Yes", "NN"];
export const WO_TYPE_DESCRIPTIONS: Record<WOType, string> = Object.fromEntries(
WO_TYPES.map((type) => [type, WO_TYPE_META[type].desc]),
) as Record<WOType, string>;
export { ALL_WIZARD_STATUSES, WO_TYPES };
export type AdvancedFilterSetKey =
"sites" | "types" | "dispatchers" | "statuses" | "pmTypes" | "vendorTechs" | "docs";

View file

@ -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 }[];
}

View file

@ -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: "",
};
}

View file

@ -0,0 +1,9 @@
import type { WOType } from "./work-order-wizard";
export type WorkOrderTypeFilter = WOType | "All";
export interface WorkOrderBarFilters {
weekMonday: string;
dispatcherIds: Set<string>;
type: WorkOrderTypeFilter;
}

View file

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

View file

@ -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[] = [];

View file

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

View file

@ -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<WOType, { desc: string; bg: string; fg: string }> = {
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<WizardWOStatus, string> = {
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;
}

View file

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

View file

@ -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<EmployeeOption[]> {
const data = await apiGet<unknown>(API_PATHS.employee.getList, {
searchParams: { page: 1, pageSize: 1000 },
});
const paginated = handlePaginatedResponse<unknown>(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<EmployeeOption[]> {
try {
const data = await apiGet<unknown>(API_PATHS.employee.dropdown);
const items = handleApiResponse<unknown[]>(data);
return Array.isArray(items) ? mapEmployeeOptions(items) : [];
} catch {
return fetchEmployeesFromList();
}
}

View file

@ -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<WorkOrderDetail> {
if (shouldUseMockWoWizardCreate(values.locationId)) {
await new Promise((resolve) => setTimeout(resolve, 400));
return buildMockWorkOrderDetail(values);
}
return workOrdersApi.create(values);
}
export function useCreateWorkOrderFromWizard(
onSuccess?: () => void,
): UseMutationResult<WorkOrderDetail, Error, WorkOrderFormValues> {
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");
},
});
}

View file

@ -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<LocationOption[], Error>
export function useWorkOrderEmployeesDropdown(): UseQueryResult<EmployeeOption[], Error> {
return useQuery({
queryKey: [...queryKeys.employees.all, "dropdown"] as const,
queryFn: () => workOrdersApi.fetchEmployeesDropdown(),
queryFn: () => fetchWorkOrderEmployees(),
staleTime: 5 * 60 * 1000,
});
}

View file

@ -20,32 +20,32 @@ function invalidateWorkOrders(queryClient: ReturnType<typeof useQueryClient>) {
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,

View file

@ -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<WorkOrderTableRow>;
async function loadFormAndUpdate(
id: string | number,
patch: (
values: ReturnType<typeof mapWorkOrderDetailToFormValues>,
) => ReturnType<typeof mapWorkOrderDetailToFormValues>,
) {
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<ReturnType<typeof workOrdersApi.getById>>) {
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),
};
}

View file

@ -11,7 +11,7 @@ export function useWorkOrdersList(
params: WorkOrdersListParams,
): UseQueryResult<WorkOrdersListResult, Error> {
return useQuery({
queryKey: queryKeys.workOrders.list(params as Record<string, unknown>),
queryKey: queryKeys.workOrders.list(params),
queryFn: () => workOrdersApi.getList(params),
placeholderData: keepPreviousData,
});
@ -24,3 +24,16 @@ export function useWorkOrderUsers(): UseQueryResult<WorkOrderUser[], Error> {
staleTime: 5 * 60 * 1000,
});
}
export function useWorkOrdersByLocation(
locationId: string | number | undefined,
search = "",
enabled = true,
): UseQueryResult<WorkOrdersListResult, Error> {
return useQuery({
queryKey: queryKeys.workOrders.byLocation(locationId ?? "", search),
queryFn: () => workOrdersApi.getListByLocation(locationId!, search),
enabled: enabled && locationId !== undefined && locationId !== "",
staleTime: 30 * 1000,
});
}

View file

@ -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<string>,
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<string>,
(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<string>,
(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<string>,
(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";
}

View file

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

View file

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

View file

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

View file

@ -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)}`;
}

View file

@ -1,4 +1,4 @@
type WorkOrdersListParams = Record<string, unknown>;
import type { WorkOrdersListParams } from "@/domain/work-orders/types/work-order-list";
export const queryKeys = {
auth: {