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: {