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