From 4beefba71937df8fb1ce5bcfb386e132da66df65 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 21 Jul 2026 09:47:43 -0300 Subject: [PATCH] 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)}`; +}