mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-02 08:33:14 +00:00
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:
commit
31e4fb1907
34 changed files with 2758 additions and 315 deletions
|
|
@ -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("") },
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
122
src/domain/work-orders/mappers/wizard-draft-mapper.ts
Normal file
122
src/domain/work-orders/mappers/wizard-draft-mapper.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
45
src/domain/work-orders/mocks/dispatcher-filter-mock-data.ts
Normal file
45
src/domain/work-orders/mocks/dispatcher-filter-mock-data.ts
Normal 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))
|
||||
);
|
||||
}
|
||||
10
src/domain/work-orders/mocks/is-wo-table-mock-enabled.ts
Normal file
10
src/domain/work-orders/mocks/is-wo-table-mock-enabled.ts
Normal 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-");
|
||||
}
|
||||
10
src/domain/work-orders/mocks/is-wo-wizard-mock-enabled.ts
Normal file
10
src/domain/work-orders/mocks/is-wo-wizard-mock-enabled.ts
Normal 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-");
|
||||
}
|
||||
521
src/domain/work-orders/mocks/wo-table-mock-data.ts
Normal file
521
src/domain/work-orders/mocks/wo-table-mock-data.ts
Normal 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);
|
||||
}
|
||||
158
src/domain/work-orders/mocks/wo-wizard-mock-data.ts
Normal file
158
src/domain/work-orders/mocks/wo-wizard-mock-data.ts
Normal 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: [],
|
||||
};
|
||||
}
|
||||
42
src/domain/work-orders/types/completion-doc.ts
Normal file
42
src/domain/work-orders/types/completion-doc.ts
Normal 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, "">;
|
||||
80
src/domain/work-orders/types/dispatch.ts
Normal file
80
src/domain/work-orders/types/dispatch.ts
Normal 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;
|
||||
}
|
||||
74
src/domain/work-orders/types/work-order-advanced-filters.ts
Normal file
74
src/domain/work-orders/types/work-order-advanced-filters.ts
Normal 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";
|
||||
48
src/domain/work-orders/types/work-order-common.ts
Normal file
48
src/domain/work-orders/types/work-order-common.ts
Normal 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 }[];
|
||||
}
|
||||
97
src/domain/work-orders/types/work-order-detail.ts
Normal file
97
src/domain/work-orders/types/work-order-detail.ts
Normal 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: "",
|
||||
};
|
||||
}
|
||||
9
src/domain/work-orders/types/work-order-list-filters.ts
Normal file
9
src/domain/work-orders/types/work-order-list-filters.ts
Normal 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;
|
||||
}
|
||||
61
src/domain/work-orders/types/work-order-list.ts
Normal file
61
src/domain/work-orders/types/work-order-list.ts
Normal 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;
|
||||
24
src/domain/work-orders/types/work-order-media.ts
Normal file
24
src/domain/work-orders/types/work-order-media.ts
Normal 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[] = [];
|
||||
99
src/domain/work-orders/types/work-order-table-row.ts
Normal file
99
src/domain/work-orders/types/work-order-table-row.ts
Normal 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;
|
||||
149
src/domain/work-orders/types/work-order-wizard.ts
Normal file
149
src/domain/work-orders/types/work-order-wizard.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
240
src/domain/work-orders/utils/advanced-filters-utils.ts
Normal file
240
src/domain/work-orders/utils/advanced-filters-utils.ts
Normal 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";
|
||||
}
|
||||
68
src/domain/work-orders/utils/list-week-utils.ts
Normal file
68
src/domain/work-orders/utils/list-week-utils.ts
Normal 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;
|
||||
}
|
||||
46
src/domain/work-orders/utils/table-day-utils.ts
Normal file
46
src/domain/work-orders/utils/table-day-utils.ts
Normal 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;
|
||||
}
|
||||
166
src/domain/work-orders/utils/table-filter-utils.ts
Normal file
166
src/domain/work-orders/utils/table-filter-utils.ts
Normal 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;
|
||||
}
|
||||
148
src/domain/work-orders/utils/wizard-date-utils.ts
Normal file
148
src/domain/work-orders/utils/wizard-date-utils.ts
Normal 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)}`;
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
type WorkOrdersListParams = Record<string, unknown>;
|
||||
import type { WorkOrdersListParams } from "@/domain/work-orders/types/work-order-list";
|
||||
|
||||
export const queryKeys = {
|
||||
auth: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue