From 8a1a12bfa11abe8f43e51324e590eccd8bdfd913 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 11:06:21 -0300 Subject: [PATCH 1/2] feat(work-orders): send Overdue as its own work order type Overdue maps to work order type 8 on create, inline correction, board reads and the type filter, instead of the Other enum and the past-due flag. It lists the PM service catalog and never sends a severity. Past Due moves to its own toggle in the advanced filter Status section and narrows the other filters. --- .../advanced-search/adv-filter-sections.tsx | 14 +- src/domain/services/api/services-api.ts | 6 +- .../use-services-for-work-order-type.ts | 1 - .../assign-wizard-service-poc-fields.ts | 3 +- .../mappers/work-order-board-mapper.ts | 2 +- .../types/work-order-advanced-filters.ts | 3 + .../work-orders/types/work-order-board.ts | 2 + .../utils/advanced-filter-chip-builders.ts | 9 + .../utils/advanced-filter-predicates.ts | 11 +- .../utils/advanced-filters-utils.ts | 2 + .../work-orders/utils/board-query-params.ts | 31 +--- .../work-orders/utils/table-filter-utils.ts | 7 +- .../overdue-work-order-type.test.tsx | 154 ++++++++++++++++++ .../domain/services/api/services-api.test.ts | 11 +- .../mappers/work-order-board-mapper.test.ts | 4 +- .../utils/board-query-params.test.ts | 23 +-- .../utils/table-filter-utils.test.ts | 53 ++++-- 17 files changed, 263 insertions(+), 73 deletions(-) create mode 100644 src/test/app/(protected)/workorders/overdue-work-order-type.test.tsx diff --git a/src/app/(protected)/workorders/_components/list/advanced-search/adv-filter-sections.tsx b/src/app/(protected)/workorders/_components/list/advanced-search/adv-filter-sections.tsx index b21120b7..148aeee9 100644 --- a/src/app/(protected)/workorders/_components/list/advanced-search/adv-filter-sections.tsx +++ b/src/app/(protected)/workorders/_components/list/advanced-search/adv-filter-sections.tsx @@ -109,7 +109,11 @@ export function AdvancedSearchFilterSections({ /> - + {selectAllRow("statuses", [...ALL_WIZARD_STATUSES], draft.statuses.size)}
{ALL_WIZARD_STATUSES.map((status) => ( @@ -122,6 +126,14 @@ export function AdvancedSearchFilterSections({ onClick={() => toggleIn("statuses", status)} /> ))} + setDraft((prev) => ({ ...prev, pastDue: !prev.pastDue }))} + />
diff --git a/src/domain/services/api/services-api.ts b/src/domain/services/api/services-api.ts index d725d828..df781742 100644 --- a/src/domain/services/api/services-api.ts +++ b/src/domain/services/api/services-api.ts @@ -20,10 +20,13 @@ const SERVICE_WORK_ORDER_TYPE_API = { Reactive: 6, } as const; -const TYPE_TO_API: Record, number> = { +// The registry is keyed on PM, Reactive and Emergency; Overdue is PM work the +// client never confirmed, so it lists the PM catalog. +const TYPE_TO_API: Record = { PM: WORK_ORDER_TYPE_API.PM, Reactive: WORK_ORDER_TYPE_API.Reactive, Emergency: WORK_ORDER_TYPE_API.Emergency, + Overdue: WORK_ORDER_TYPE_API.PM, }; function toApiInput(input: ServiceInput) { @@ -51,7 +54,6 @@ export const servicesApi = { }, getActiveForWorkOrderType: async (workOrderType: WOType): Promise => { - if (workOrderType === "Overdue") return []; const data = await apiGet(API_PATHS.services.list, { searchParams: { isActive: true, workOrderType: TYPE_TO_API[workOrderType] }, }); diff --git a/src/domain/services/use-cases/use-services-for-work-order-type.ts b/src/domain/services/use-cases/use-services-for-work-order-type.ts index 6dd8d4ac..f712051e 100644 --- a/src/domain/services/use-cases/use-services-for-work-order-type.ts +++ b/src/domain/services/use-cases/use-services-for-work-order-type.ts @@ -11,7 +11,6 @@ export function useServicesForWorkOrderType( return useQuery({ queryKey: queryKeys.services.byWorkOrderType(effectiveType), queryFn: () => servicesApi.getActiveForWorkOrderType(effectiveType), - enabled: effectiveType !== "Overdue", staleTime: 60 * 1000, }); } diff --git a/src/domain/work-orders/mappers/assign-wizard-service-poc-fields.ts b/src/domain/work-orders/mappers/assign-wizard-service-poc-fields.ts index e45381e7..b4940692 100644 --- a/src/domain/work-orders/mappers/assign-wizard-service-poc-fields.ts +++ b/src/domain/work-orders/mappers/assign-wizard-service-poc-fields.ts @@ -1,4 +1,5 @@ import type { WorkOrderBoardCreatePayload } from "@/domain/work-orders/types/work-order-board"; +import { typeNeedsSeverity } from "@/domain/work-orders/types/work-order-severity"; import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard"; import { normalizeAdditionalContacts } from "@/domain/work-orders/mappers/additional-contacts-mapper"; @@ -64,7 +65,7 @@ export function assignWizardServicePocFields( payload.vendorNotes = vendorNotes; } - if (draft.severity != null) { + if (draft.severity != null && typeNeedsSeverity(draft.type)) { payload.severity = draft.severity; } diff --git a/src/domain/work-orders/mappers/work-order-board-mapper.ts b/src/domain/work-orders/mappers/work-order-board-mapper.ts index af00a55d..f1f046f9 100644 --- a/src/domain/work-orders/mappers/work-order-board-mapper.ts +++ b/src/domain/work-orders/mappers/work-order-board-mapper.ts @@ -28,7 +28,7 @@ const WO_TYPE_TO_API: Record = { Reactive: WORK_ORDER_TYPE_API.Reactive, - Overdue: WORK_ORDER_TYPE_API.Other, + Overdue: WORK_ORDER_TYPE_API.Overdue, }; /** Leading token of a location label, e.g. `BK5` from `BK5 · Dallas` or `CHI-1042` from `CHI-1042 — Chicago`. */ diff --git a/src/domain/work-orders/types/work-order-advanced-filters.ts b/src/domain/work-orders/types/work-order-advanced-filters.ts index 66f831dc..79f65b98 100644 --- a/src/domain/work-orders/types/work-order-advanced-filters.ts +++ b/src/domain/work-orders/types/work-order-advanced-filters.ts @@ -35,6 +35,8 @@ export interface WorkOrderAdvancedFilters { dispatchers: Set; regions: Set; statuses: Set; + /** Past Due overlay (any type); narrows the other filters, independent of the Overdue type. */ + pastDue: boolean; rescheduled: boolean; carriedOver: boolean; addOn: boolean; @@ -59,6 +61,7 @@ export const EMPTY_ADVANCED_FILTERS: WorkOrderAdvancedFilters = { dispatchers: new Set(), regions: new Set(), statuses: new Set(), + pastDue: false, rescheduled: false, carriedOver: false, addOn: false, diff --git a/src/domain/work-orders/types/work-order-board.ts b/src/domain/work-orders/types/work-order-board.ts index 317e7cad..88addefd 100644 --- a/src/domain/work-orders/types/work-order-board.ts +++ b/src/domain/work-orders/types/work-order-board.ts @@ -17,6 +17,7 @@ export const WORK_ORDER_TYPE_API = { Inspection: 5, Reactive: 6, AddOn: 7, + Overdue: 8, Other: 99, } as const; @@ -64,6 +65,7 @@ export const WORK_ORDER_TYPE_API_TO_FE: Record = { [WORK_ORDER_TYPE_API.PM]: "PM", [WORK_ORDER_TYPE_API.Reactive]: "Reactive", [WORK_ORDER_TYPE_API.Emergency]: "Emergency", + [WORK_ORDER_TYPE_API.Overdue]: "Overdue", }; export const LIFECYCLE_STATUS_API_TO_FE: Record = { diff --git a/src/domain/work-orders/utils/advanced-filter-chip-builders.ts b/src/domain/work-orders/utils/advanced-filter-chip-builders.ts index c7222b82..47703c1f 100644 --- a/src/domain/work-orders/utils/advanced-filter-chip-builders.ts +++ b/src/domain/work-orders/utils/advanced-filter-chip-builders.ts @@ -192,6 +192,15 @@ export function appendStatusPmVendorDocChips( clear: (current) => ({ ...current, statuses: new Set() }), }); + if (filters.pastDue) { + chips.push({ + key: "pastdue", + group: "Status", + value: "Past Due", + clear: (current) => ({ ...current, pastDue: false }), + }); + } + pushSetChip({ chips, key: "pm", diff --git a/src/domain/work-orders/utils/advanced-filter-predicates.ts b/src/domain/work-orders/utils/advanced-filter-predicates.ts index 45fb611e..530bbd70 100644 --- a/src/domain/work-orders/utils/advanced-filter-predicates.ts +++ b/src/domain/work-orders/utils/advanced-filter-predicates.ts @@ -45,12 +45,10 @@ const matchesDateRange: AdvancedFilterPredicate = (row, filters) => { const matchesSite: AdvancedFilterPredicate = (row, filters) => !filters.sites.size || filters.sites.has(row.site); -const matchesType: AdvancedFilterPredicate = (row, filters) => { - if (!filters.types.size) return true; - const overdueMatch = filters.types.has("Overdue") && row.isPastDue; - const typeMatch = filters.types.has(row.type as never); - return overdueMatch || typeMatch; -}; +const matchesType: AdvancedFilterPredicate = (row, filters) => + !filters.types.size || filters.types.has(row.type as never); + +const matchesPastDue: AdvancedFilterPredicate = (row, filters) => !filters.pastDue || row.isPastDue; const matchesDispatcher: AdvancedFilterPredicate = (row, filters) => { if (!filters.dispatchers.size) return true; @@ -95,6 +93,7 @@ export const ADVANCED_FILTER_PREDICATES: AdvancedFilterPredicate[] = [ matchesType, matchesDispatcher, matchesStatus, + matchesPastDue, matchesPmType, matchesVendorTech, matchesDoc, diff --git a/src/domain/work-orders/utils/advanced-filters-utils.ts b/src/domain/work-orders/utils/advanced-filters-utils.ts index 871bd0cd..9e0ccb90 100644 --- a/src/domain/work-orders/utils/advanced-filters-utils.ts +++ b/src/domain/work-orders/utils/advanced-filters-utils.ts @@ -34,6 +34,7 @@ function cloneFilters(filters: WorkOrderAdvancedFilters): WorkOrderAdvancedFilte flagColors: new Set(filters.flagColors ?? []), upliftStatuses: new Set(filters.upliftStatuses ?? []), unscheduledOnly: Boolean(filters.unscheduledOnly), + pastDue: Boolean(filters.pastDue), }; } @@ -45,6 +46,7 @@ const ADVANCED_FILTER_ACTIVE_SIGNALS = (filters: WorkOrderAdvancedFilters): bool Boolean(filters.dispatchers?.size), Boolean(filters.regions?.size), Boolean(filters.statuses?.size), + Boolean(filters.pastDue), filters.rescheduled, filters.carriedOver, filters.addOn, diff --git a/src/domain/work-orders/utils/board-query-params.ts b/src/domain/work-orders/utils/board-query-params.ts index 998d0605..df90e2fc 100644 --- a/src/domain/work-orders/utils/board-query-params.ts +++ b/src/domain/work-orders/utils/board-query-params.ts @@ -41,30 +41,19 @@ function mapDispatchers(ids: Iterable): string[] | undefined { return list.length > 0 ? list : undefined; } -/** Maps UI type filters to API `types` + separate `overdue` flag. Overdue is never sent as types=99. */ -function mapWoTypeFilters(types: Iterable): { - types?: number[]; - overdue?: boolean; -} { +/** Maps UI type filters to API `types`. The Overdue type is sent as its own enum; past due uses `overdue`. */ +function mapWoTypeFilters(types: Iterable): number[] | undefined { const list: number[] = []; - let overdue = false; for (const type of types) { if (type === "All") { continue; } - if (type === "Overdue") { - overdue = true; - continue; - } const api = mapWizardTypeToApi(type as WOType); if (api != null) { list.push(api); } } - return { - types: list.length > 0 ? list : undefined, - overdue: overdue || undefined, - }; + return list.length > 0 ? list : undefined; } function mapStatuses(statuses: Iterable): number[] | undefined { @@ -139,14 +128,11 @@ export function toBoardQueryParams( search: string, ): WorkOrderBoardQueryParams { const [, weekTo] = weekRange(bar.weekMonday); - const typeFilters = bar.types.size > 0 ? mapWoTypeFilters(bar.types) : {}; - return { weekStart: bar.weekMonday, weekEnd: weekTo, dispatchers: mapDispatchers(bar.dispatcherIds), - types: typeFilters.types, - overdue: typeFilters.overdue, + types: mapWoTypeFilters(bar.types), search: search.trim().length >= 2 ? search.trim() : undefined, }; } @@ -203,7 +189,6 @@ export function toBoardSearchQueryParams(input: { BOARD_SEARCH_MAX_PAGE_SIZE, ); - const typeFilters = mapWoTypeFilters(filters.types); const trimmedSearch = input.search?.trim(); const search = trimmedSearch && trimmedSearch.length >= 2 ? trimmedSearch : undefined; @@ -216,8 +201,8 @@ export function toBoardSearchQueryParams(input: { dateTo: date.dateTo, includeDateless: date.includeDateless, sites: filters.sites.size > 0 ? [...filters.sites] : undefined, - types: typeFilters.types, - overdue: typeFilters.overdue, + types: mapWoTypeFilters(filters.types), + overdue: filters.pastDue || undefined, dispatchers: mapDispatchers(filters.dispatchers), regions: filters.regions.size > 0 ? [...filters.regions] : undefined, statuses: mapStatuses(filters.statuses), @@ -252,10 +237,6 @@ export function buildBoardSearchParamsRecord( return out; } -export function woTypeFilterIsOverdue(type: string): boolean { - return type === "Overdue"; -} - /** Week-independent GET /board/search params for the pinned Unassigned queue. */ export function toUnassignedQueueQueryParams( page = 0, diff --git a/src/domain/work-orders/utils/table-filter-utils.ts b/src/domain/work-orders/utils/table-filter-utils.ts index 3d8eb132..2300335f 100644 --- a/src/domain/work-orders/utils/table-filter-utils.ts +++ b/src/domain/work-orders/utils/table-filter-utils.ts @@ -65,12 +65,7 @@ function rowMatchesSearch(row: WorkOrderTableRow, query: string): boolean { } function rowMatchesBarTypes(row: WorkOrderTableRow, types: Set): boolean { - if (types.size === 0) { - return true; - } - const overdueMatch = types.has("Overdue") && row.isPastDue; - const typeMatch = types.has(row.type as WOType); - return overdueMatch || typeMatch; + return types.size === 0 || types.has(row.type as WOType); } export function filterByBarAndSearch( diff --git a/src/test/app/(protected)/workorders/overdue-work-order-type.test.tsx b/src/test/app/(protected)/workorders/overdue-work-order-type.test.tsx new file mode 100644 index 00000000..5c429789 --- /dev/null +++ b/src/test/app/(protected)/workorders/overdue-work-order-type.test.tsx @@ -0,0 +1,154 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { SlideOverCompletionTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab"; +import { AdvancedSearchFilterSections } from "@/app/(protected)/workorders/_components/list/advanced-search/adv-filter-sections"; +import { TypeCell } from "@/app/(protected)/workorders/_components/list/table/cells/type-cell"; +import { WizardStepTypeSchedule } from "@/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule"; +import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-table-row-mapper"; +import { mapWizardDraftToBoardCreatePayload } from "@/domain/work-orders/mappers/work-order-board-mapper"; +import { + EMPTY_ADVANCED_FILTERS, + type WorkOrderAdvancedFilters, +} from "@/domain/work-orders/types/work-order-advanced-filters"; +import { WORK_ORDER_TYPE_API } from "@/domain/work-orders/types/work-order-board"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { + canContinueWizardStep1, + EMPTY_WIZARD_DRAFT, + type WorkOrderWizardDraft, +} from "@/domain/work-orders/types/work-order-wizard"; +import { buildAdvancedFilterChips } from "@/domain/work-orders/utils/advanced-filters-utils"; +import { getClosabilityGaps } from "@/domain/work-orders/utils/wo-closability"; + +const USERS = [{ id: "sarah", name: "Sarah Chen" }]; + +describe("Overdue work order type", () => { + it("offers Overdue in the Create WO wizard and never asks for a severity", () => { + const onPatch = vi.fn(); + const { rerender } = render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /No client response — due date passed/ })); + expect(onPatch).toHaveBeenCalledWith( + expect.objectContaining({ type: "Overdue", severity: null }), + ); + + const overdueDraft: WorkOrderWizardDraft = { ...EMPTY_WIZARD_DRAFT, type: "Overdue" }; + rerender( + , + ); + expect(screen.queryByText(/Severity \(from APM\)/)).not.toBeInTheDocument(); + expect(canContinueWizardStep1(overdueDraft)).toBe(true); + }); + + it("creates the work order with the Overdue enum and no severity", () => { + const payload = mapWizardDraftToBoardCreatePayload({ + ...EMPTY_WIZARD_DRAFT, + type: "Overdue", + severity: 2, + locationId: "1", + siteCode: "BK5", + }); + + expect(payload.workOrderType).toBe(WORK_ORDER_TYPE_API.Overdue); + expect(payload.severity ?? null).toBeNull(); + }); + + it("corrects a type inline to Overdue without opening the severity dialog", async () => { + const onSave = vi.fn(); + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "Type of WO, Reactive" })); + await user.click(screen.getByRole("button", { name: /^Overdue$/ })); + + expect(onSave).toHaveBeenCalledWith("Overdue", null); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("reads the Overdue enum back from the board as the Overdue type", () => { + const row = mapWorkOrderTableRow({ id: 1, workOrderType: WORK_ORDER_TYPE_API.Overdue }); + expect(row.type).toBe("Overdue"); + }); + + it("closes with the completion document, not photos", () => { + const base = { + id: "1", + dispatcherId: "sarah", + dispatcherName: "Sarah Chen", + company: "Gateway Plumbing", + vendorId: "", + tech: "", + completedDate: "2026-09-24", + woNumber: "24971", + pm: "Backflow Preventers", + mediaCount: 0, + }; + expect( + getClosabilityGaps({ ...base, type: "Overdue" as const, docStatus: "No" as const }), + ).toEqual(["Completion document"]); + expect( + getClosabilityGaps({ ...base, type: "Overdue" as const, docStatus: "Yes" as const }), + ).toEqual([]); + + const row = { + id: "1", + type: "Overdue", + pm: "Backflow Preventers", + docStatus: "No", + rowVersion: "1", + }; + render( + , + ); + expect(screen.getByText("Completion Document")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /open completion document/i })).toBeEnabled(); + }); + + it("keeps a separate Past Due status toggle in the advanced filters", () => { + let draft: WorkOrderAdvancedFilters = { ...EMPTY_ADVANCED_FILTERS }; + const setDraft = vi.fn( + (update: (prev: WorkOrderAdvancedFilters) => WorkOrderAdvancedFilters) => { + draft = update(draft); + }, + ); + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /^Status/ })); + fireEvent.click(screen.getByRole("button", { name: /Past Due/ })); + + expect(draft.pastDue).toBe(true); + expect(draft.types.size).toBe(0); + expect(buildAdvancedFilterChips(draft)).toContainEqual( + expect.objectContaining({ group: "Status", value: "Past Due" }), + ); + }); +}); diff --git a/src/test/domain/services/api/services-api.test.ts b/src/test/domain/services/api/services-api.test.ts index be46e93b..7dc0b1b0 100644 --- a/src/test/domain/services/api/services-api.test.ts +++ b/src/test/domain/services/api/services-api.test.ts @@ -78,11 +78,14 @@ describe("servicesApi", () => { }); }); - it("does not request services for the unsupported Overdue type", async () => { - const services = await servicesApi.getActiveForWorkOrderType("Overdue"); + it("lists the PM catalog for the Overdue type", async () => { + vi.mocked(apiGet).mockResolvedValue([]); - expect(apiGet).not.toHaveBeenCalled(); - expect(services).toEqual([]); + await servicesApi.getActiveForWorkOrderType("Overdue"); + + expect(apiGet).toHaveBeenCalledWith(API_PATHS.services.list, { + searchParams: { isActive: true, workOrderType: 2 }, + }); }); it("creates, updates, and deactivates through the registry routes", async () => { diff --git a/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts index e5d0cd0e..b82d970c 100644 --- a/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts +++ b/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts @@ -36,7 +36,9 @@ describe("mapWizardTypeToApi", () => { expect(mapWizardTypeToApi("Reactive")).toBe(WORK_ORDER_TYPE_API.Reactive); - expect(mapWizardTypeToApi("Overdue")).toBe(WORK_ORDER_TYPE_API.Other); + expect(mapWizardTypeToApi("Overdue")).toBe(WORK_ORDER_TYPE_API.Overdue); + + expect(WORK_ORDER_TYPE_API.Overdue).toBe(8); expect(mapWizardTypeToApi("")).toBeNull(); }); diff --git a/src/test/domain/work-orders/utils/board-query-params.test.ts b/src/test/domain/work-orders/utils/board-query-params.test.ts index 866de6f6..39fa0275 100644 --- a/src/test/domain/work-orders/utils/board-query-params.test.ts +++ b/src/test/domain/work-orders/utils/board-query-params.test.ts @@ -60,16 +60,16 @@ describe("toBoardQueryParams", () => { expect(params.overdue).toBeUndefined(); }); - it("maps Overdue bar filter to overdue=true without types", () => { + it("maps the Overdue bar filter to the Overdue type, not the past-due flag", () => { const params = toBoardQueryParams({ ...baseBar, types: new Set(["Overdue"]) }, ""); - expect(params.overdue).toBe(true); - expect(params.types).toBeUndefined(); + expect(params.types).toEqual([WORK_ORDER_TYPE_API.Overdue]); + expect(params.overdue).toBeUndefined(); }); - it("maps Overdue with real types as OR (types + overdue)", () => { + it("maps Overdue with other types as a plain type union", () => { const params = toBoardQueryParams({ ...baseBar, types: new Set(["PM", "Overdue"]) }, ""); - expect(params.types).toEqual([WORK_ORDER_TYPE_API.PM]); - expect(params.overdue).toBe(true); + expect(params.types).toEqual([WORK_ORDER_TYPE_API.PM, WORK_ORDER_TYPE_API.Overdue]); + expect(params.overdue).toBeUndefined(); }); it("omits types when the set is empty", () => { @@ -222,22 +222,23 @@ describe("toBoardSearchQueryParams", () => { expect(buildBoardSearchParamsRecord(params)).not.toHaveProperty("unscheduledOnly"); }); - it("maps Overdue-only advanced filter to overdue=true without types", () => { + it("maps the Overdue type advanced filter to types without the past-due flag", () => { const params = toBoardSearchQueryParams({ filters: { ...EMPTY_ADVANCED_FILTERS, types: new Set(["Overdue"]), }, }); - expect(params.overdue).toBe(true); - expect(params.types).toBeUndefined(); + expect(params.types).toEqual([WORK_ORDER_TYPE_API.Overdue]); + expect(params.overdue).toBeUndefined(); }); - it("maps Overdue with real types as OR (types + overdue)", () => { + it("maps the Past Due status toggle to overdue=true alongside types", () => { const params = toBoardSearchQueryParams({ filters: { ...EMPTY_ADVANCED_FILTERS, - types: new Set(["PM", "Overdue"]), + types: new Set(["PM"]), + pastDue: true, }, }); expect(params.types).toEqual([WORK_ORDER_TYPE_API.PM]); diff --git a/src/test/domain/work-orders/utils/table-filter-utils.test.ts b/src/test/domain/work-orders/utils/table-filter-utils.test.ts index cb7b10e5..9798afb8 100644 --- a/src/test/domain/work-orders/utils/table-filter-utils.test.ts +++ b/src/test/domain/work-orders/utils/table-filter-utils.test.ts @@ -172,26 +172,26 @@ describe("filterByBarAndSearch", () => { expect(ids).toEqual(["em", "rx"]); }); - it("matches Overdue via isPastDue even when row.type is not Overdue", () => { + it("matches the Overdue type only, never a past-due row of another type", () => { const pastDuePm = makeRow({ id: "pd", type: "PM", isPastDue: true, scheduledOn: "2026-06-03", }); - const currentPm = makeRow({ - id: "now", - type: "PM", + const overdue = makeRow({ + id: "ov", + type: "Overdue", isPastDue: false, scheduledOn: "2026-06-03", }); const ids = filterByBarAndSearch( - [pastDuePm, currentPm], + [pastDuePm, overdue], bar(weekMonday, { types: new Set(["Overdue"]) }), "", ).map((row) => row.id); - expect(ids).toEqual(["pd"]); + expect(ids).toEqual(["ov"]); }); it("hides a row after type correction when the bar filter no longer matches", () => { @@ -395,13 +395,8 @@ describe("matchesAdvancedFilters", () => { expect(matchesAdvancedFilters(row, noMatch)).toBe(false); }); - it("matches Overdue when isPastDue is true", () => { + it("matches the Overdue type filter by type, independent of isPastDue", () => { const weekMonday = mondayOf(todayIso()); - const pastDue = makeRow({ - scheduledOn: weekMonday, - ...deriveDayFields(weekMonday), - isPastDue: true, - }); const filters = { ...EMPTY_ADVANCED_FILTERS, dateRange: "custom" as const, @@ -409,9 +404,39 @@ describe("matchesAdvancedFilters", () => { customTo: "9999-12-31", types: new Set(["Overdue" as const]), }; - expect(matchesAdvancedFilters(pastDue, filters)).toBe(true); + const overdue = makeRow({ type: "Overdue", isPastDue: false, scheduledOn: weekMonday }); + const pastDuePm = makeRow({ type: "PM", isPastDue: true, scheduledOn: weekMonday }); + expect(matchesAdvancedFilters(overdue, filters)).toBe(true); + expect(matchesAdvancedFilters(pastDuePm, filters)).toBe(false); + }); + + it("narrows to past-due rows with the Past Due status toggle", () => { + const weekMonday = mondayOf(todayIso()); + const filters = { + ...EMPTY_ADVANCED_FILTERS, + dateRange: "custom" as const, + customFrom: "0000-01-01", + customTo: "9999-12-31", + types: new Set(["PM" as const]), + pastDue: true, + }; expect( - matchesAdvancedFilters(makeRow({ isPastDue: false, scheduledOn: weekMonday }), filters), + matchesAdvancedFilters( + makeRow({ type: "PM", isPastDue: true, scheduledOn: weekMonday }), + filters, + ), + ).toBe(true); + expect( + matchesAdvancedFilters( + makeRow({ type: "PM", isPastDue: false, scheduledOn: weekMonday }), + filters, + ), + ).toBe(false); + expect( + matchesAdvancedFilters( + makeRow({ type: "Overdue", isPastDue: true, scheduledOn: weekMonday }), + filters, + ), ).toBe(false); }); From 35714a917d6d77d3eee9366bd6205f1ea780130f Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 17:01:27 -0300 Subject: [PATCH 2/2] fix(workorders): Site dialog re-syncs a site after switching back and never syncs cached data Switching to another site and back now loads the site record again, so its extra contacts are not dropped on Save. The dialog also waits for the site request to settle before syncing, so a record cached before an earlier save is never shown or written back. Half-filled extra contacts stay off the site record, as they already stay off the work order. The save path moves into its own hook to keep the dialog state under the complexity limit. --- .../list/table/cells/use-site-dialog-save.ts | 82 ++++++++ .../list/table/cells/use-site-dialog-state.ts | 119 ++++------- .../list/table/cells/use-site-record-sync.ts | 61 +++--- .../site-dialog-site-record-resync.test.tsx | 190 ++++++++++++++++++ 4 files changed, 352 insertions(+), 100 deletions(-) create mode 100644 src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-save.ts create mode 100644 src/test/app/(protected)/workorders/site-dialog-site-record-resync.test.tsx diff --git a/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-save.ts b/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-save.ts new file mode 100644 index 00000000..cf2fdfc9 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-save.ts @@ -0,0 +1,82 @@ +import { buildSiteDialogPatch } from "@/app/(protected)/workorders/_components/list/table/cells/build-site-dialog-patch"; +import type { SitePatch } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-types"; +import { resolveLocationId } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-helpers"; +import type { useSiteDialogFormFields } from "@/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-form-fields"; +import { + workOrderPocAfterSiteSave, + type useSiteRecordSync, + type WorkOrderPoc, +} from "@/app/(protected)/workorders/_components/list/table/cells/use-site-record-sync"; +import { useUpdateSiteContactInfo } from "@/domain/locations/use-cases/use-update-site-contact-info"; +import type { LocationOption } from "@/domain/work-orders/types/work-order"; + +type UseSiteDialogSaveArgs = { + fields: ReturnType; + siteRecord: ReturnType; + /** The work order's values when the dialog opened. */ + original: WorkOrderPoc & { locationId: string | number; value: string; sites: LocationOption[] }; + createMode: boolean; + /** The first contact is required before Save (inline create, or editing the site record). */ + requiresPoc: boolean; + onSave: (patch: SitePatch) => void; + onOpenChange: (open: boolean) => void; +}; + +/** + * The Site dialog's Save: writes changed contacts and notes to the site record first when the + * dialog edits it, then patches the work order and closes. + */ +export function useSiteDialogSave({ + fields, + siteRecord, + original, + createMode, + requiresPoc, + onSave, + onOpenChange, +}: UseSiteDialogSaveArgs) { + const updateSite = useUpdateSiteContactInfo(); + const pocMissing = !fields.pn.trim() || !fields.pp.trim(); + const canConfirm = !fields.siteMissing && (!requiresPoc || !pocMissing); + + const saveWorkOrder = () => { + const poc = workOrderPocAfterSiteSave( + siteRecord, + original, + { pocName: fields.pn, pocPhone: fields.pp, pocNotes: fields.notes }, + fields.locId !== resolveLocationId(original.locationId, original.value, original.sites), + ); + onSave( + buildSiteDialogPatch({ + code: fields.code, + locId: fields.locId, + selected: fields.selected, + ...poc, + extraContacts: fields.extraContacts, + contactsDirty: fields.contactsDirty, + baselineHadContacts: fields.baselineHadContacts, + followsSiteRecord: siteRecord.synced, + }), + ); + onOpenChange(false); + }; + + const attemptSave = () => { + if (requiresPoc && !canConfirm) { + fields.setShowErrors(true); + return; + } + if (!siteRecord.siteChanged) { + saveWorkOrder(); + return; + } + updateSite.mutate({ id: fields.locId, ...siteRecord.request }, { onSuccess: saveWorkOrder }); + }; + + return { + attemptSave, + // Save is actionable only once something changed; inline create keeps its confirm step. + saveDisabled: updateSite.isPending || siteRecord.loading || (!createMode && !fields.dirty), + saving: updateSite.isPending, + }; +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-state.ts b/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-state.ts index f23b1b01..6d53ac87 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-state.ts +++ b/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-state.ts @@ -1,15 +1,11 @@ import { useEffect, useMemo } from "react"; -import { buildSiteDialogPatch } from "@/app/(protected)/workorders/_components/list/table/cells/build-site-dialog-patch"; import type { SitePatch } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-types"; import { useSiteDialogFormFields } from "@/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-form-fields"; -import { resolveLocationId } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-helpers"; -import { - useSiteRecordSync, - workOrderPocAfterSiteSave, -} from "@/app/(protected)/workorders/_components/list/table/cells/use-site-record-sync"; -import { useUpdateSiteContactInfo } from "@/domain/locations/use-cases/use-update-site-contact-info"; +import { useSiteDialogSave } from "@/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-save"; +import { useSiteRecordSync } from "@/app/(protected)/workorders/_components/list/table/cells/use-site-record-sync"; import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail"; import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper"; +import type { Location } from "@/domain/locations/types/location"; import type { WorkOrderAdditionalContact } from "@/domain/work-orders/types/work-order-additional-contact"; import type { LocationOption } from "@/domain/work-orders/types/work-order"; import type { WorkOrderFrozenSite } from "@/domain/work-orders/types/work-order-table-row"; @@ -32,6 +28,20 @@ type UseSiteDialogStateArgs = { onSave: (patch: SitePatch) => void; }; +/** A completed work order's frozen snapshot, shaped like the live site detail. */ +function frozenSiteDetail(frozenSite: WorkOrderFrozenSite): Location { + return { + name: frozenSite.label, + address: frozenSite.address, + city: frozenSite.city, + state: frozenSite.state, + zipCode: frozenSite.zip, + phone: frozenSite.phone, + contact: undefined, + contactEmail: frozenSite.email, + }; +} + export function useSiteDialogState({ open, onOpenChange, @@ -58,96 +68,51 @@ export function useSiteDialogState({ sites, createMode, }); - const { locId, pocFilledFor, pn, pp, setPn, setPp, setPocFilledFor, siteMissing } = fields; - const { - data: liveLocationDetail, - isLoading: liveLocationDetailLoading, - isError: liveLocationDetailError, - } = useLocationDetail(frozenSite == null && open && locId ? locId : undefined); + const { locId, pocFilledFor, setPn, setPp, setPocFilledFor } = fields; + const live = frozenSite == null; + const liveDetail = useLocationDetail(live && open && locId ? locId : undefined); const frozenLocationDetail = useMemo( - () => - frozenSite == null - ? undefined - : { - name: frozenSite.label, - address: frozenSite.address, - city: frozenSite.city, - state: frozenSite.state, - zipCode: frozenSite.zip, - phone: frozenSite.phone, - contact: undefined, - contactEmail: frozenSite.email, - }, + () => (frozenSite == null ? undefined : frozenSiteDetail(frozenSite)), [frozenSite], ); - const locationDetail = frozenLocationDetail ?? liveLocationDetail; - const locationDetailLoading = frozenSite == null && liveLocationDetailLoading; - const locationDetailError = frozenSite == null && liveLocationDetailError; + const locationDetail = frozenLocationDetail ?? liveDetail.data; + const locationDetailLoading = live && liveDetail.isLoading; + const locationDetailError = live && liveDetail.isError; const addressPreview = locationDetail ? formatLocationAddressPreview(locationDetail) : ""; // An existing, editable work order edits the site record itself (contacts and notes). - const editsSiteRecord = !createMode && !viewOnly && frozenSite == null; + const editsSiteRecord = !createMode && !viewOnly && live; const siteRecord = useSiteRecordSync({ enabled: editsSiteRecord, open, locId, - locationDetail: liveLocationDetail, - locationDetailError: liveLocationDetailError, + locationDetail: liveDetail.data, + locationDetailFetching: liveDetail.isFetching, + locationDetailError: liveDetail.isError, fields, }); // The site failed to load (or the user typed before it did), so Save writes this work order only. const siteRecordUnavailable = editsSiteRecord && Boolean(locId) && !siteRecord.synced && !siteRecord.loading; - const updateSite = useUpdateSiteContactInfo(); - const pocMissing = !pn.trim() || !pp.trim(); const requiresPoc = createMode || editsSiteRecord; - const canConfirm = !siteMissing && (!requiresPoc || !pocMissing); + const save = useSiteDialogSave({ + fields, + siteRecord, + original: { pocName, pocPhone, pocNotes, locationId, value, sites }, + createMode, + requiresPoc, + onSave, + onOpenChange, + }); useEffect(() => { - if (frozenSite != null || !open || !locId || !locationDetail || pocFilledFor === locId) { + if (!live || !open || !locId || !locationDetail || pocFilledFor === locId) { return; } setPn((prev) => (prev.trim() ? prev : (locationDetail.contact ?? ""))); setPp((prev) => (prev.trim() ? prev : (locationDetail.phone ?? ""))); setPocFilledFor(locId); - }, [open, locId, locationDetail, pocFilledFor, setPn, setPp, setPocFilledFor, frozenSite]); - - const saveWorkOrder = () => { - const poc = workOrderPocAfterSiteSave( - siteRecord, - { pocName, pocPhone, pocNotes }, - { pocName: fields.pn, pocPhone: fields.pp, pocNotes: fields.notes }, - fields.locId !== resolveLocationId(locationId, value, sites), - ); - onSave( - buildSiteDialogPatch({ - code: fields.code, - locId: fields.locId, - selected: fields.selected, - ...poc, - extraContacts: fields.extraContacts, - contactsDirty: fields.contactsDirty, - baselineHadContacts: fields.baselineHadContacts, - followsSiteRecord: siteRecord.synced, - }), - ); - onOpenChange(false); - }; - - const attemptSave = () => { - if (requiresPoc && !canConfirm) { - fields.setShowErrors(true); - return; - } - if (!siteRecord.siteChanged) { - saveWorkOrder(); - return; - } - updateSite.mutate({ id: locId, ...siteRecord.request }, { onSuccess: saveWorkOrder }); - }; - - // Save is actionable only once something changed; inline create keeps its confirm step. - const saveDisabled = updateSite.isPending || siteRecord.loading || (!createMode && !fields.dirty); + }, [open, locId, locationDetail, pocFilledFor, setPn, setPp, setPocFilledFor, live]); return { locId: fields.locId, @@ -164,9 +129,9 @@ export function useSiteDialogState({ showErrors: fields.showErrors, siteMissing: fields.siteMissing, handlePick: fields.handlePick, - attemptSave, - saveDisabled, - saving: updateSite.isPending, + attemptSave: save.attemptSave, + saveDisabled: save.saveDisabled, + saving: save.saving, siteRecordLoading: siteRecord.loading, editsSiteRecord, siteRecordUnavailable, diff --git a/src/app/(protected)/workorders/_components/list/table/cells/use-site-record-sync.ts b/src/app/(protected)/workorders/_components/list/table/cells/use-site-record-sync.ts index 33b09eb0..cdb45f3e 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/use-site-record-sync.ts +++ b/src/app/(protected)/workorders/_components/list/table/cells/use-site-record-sync.ts @@ -22,6 +22,8 @@ type UseSiteRecordSyncArgs = { open: boolean; locId: string; locationDetail: Location | undefined; + /** A request for the site is in flight; cached data may predate the latest save. */ + locationDetailFetching: boolean; locationDetailError: boolean; fields: SiteRecordFields; }; @@ -30,6 +32,10 @@ export type WorkOrderPoc = { pocName: string; pocPhone: string; pocNotes: string type SiteRecordEdits = { primary: boolean; notes: boolean }; +function isCompleteContact(contact: WorkOrderAdditionalContact): boolean { + return contact.name.trim() !== "" && contact.phone.trim() !== ""; +} + function toRequest( primaryId: number | undefined, fields: Pick, @@ -37,7 +43,8 @@ function toRequest( return { contacts: [ { ...(primaryId === undefined ? {} : { id: primaryId }), name: fields.pn, phone: fields.pp }, - ...fields.extraContacts.map((contact) => ({ + // A half-filled extra is dropped here, the same as the work order's own copy drops it. + ...fields.extraContacts.filter(isCompleteContact).map((contact) => ({ ...(contact.siteContactId === undefined ? {} : { id: contact.siteContactId }), name: contact.name, phone: contact.phone, @@ -92,6 +99,22 @@ export function workOrderPocAfterSiteSave( }; } +/** The site record's contacts and notes, in the dialog's field shape. */ +function siteRecordValues(location: Location) { + const [main, ...others] = location.contacts ?? []; + return { + primaryId: main?.id, + pn: main?.name ?? "", + pp: main?.phone ?? "", + notes: location.notes ?? "", + extraContacts: others.map((contact) => ({ + name: contact.name, + phone: contact.phone, + ...(contact.id === undefined ? {} : { siteContactId: contact.id }), + })), + }; +} + /** * Loads the selected site's contacts and notes into the Site dialog and reports what the user * changed, so Save can write them back to the site record. @@ -101,6 +124,7 @@ export function useSiteRecordSync({ open, locId, locationDetail, + locationDetailFetching, locationDetailError, fields, }: UseSiteRecordSyncArgs) { @@ -109,43 +133,34 @@ export function useSiteRecordSync({ const [baseline, setBaseline] = useState(null); const { userEdited, resetVersion, setPn, setPp, setNotes, setExtraContacts } = fields; + // Picking another site clears its contacts, so coming back to a site must load it again. useEffect(() => { setSyncedFor(""); - }, [open, resetVersion]); + }, [open, resetVersion, locId]); useEffect(() => { const detailMatches = locationDetail !== undefined && String(locationDetail.id) === locId; + // Cached site data can predate a save made since, so only a settled request is synced. + const detailCurrent = detailMatches && !locationDetailFetching; // Input typed while the site was unavailable (a failed load) is never replaced by a late // response: the dialog stays on the work order's values and saves them to the work order only. - if (!enabled || !open || !locId || !detailMatches || syncedFor === locId || userEdited) { + if (!enabled || !open || !locId || !detailCurrent || syncedFor === locId || userEdited) { return; } - const [main, ...others] = locationDetail.contacts ?? []; - const extras = others.map((contact) => ({ - name: contact.name, - phone: contact.phone, - ...(contact.id === undefined ? {} : { siteContactId: contact.id }), - })); - const notes = locationDetail.notes ?? ""; - setPn(main?.name ?? ""); - setPp(main?.phone ?? ""); - setExtraContacts(extras); - setNotes(notes); - setPrimaryId(main?.id); - setBaseline( - toRequest(main?.id, { - pn: main?.name ?? "", - pp: main?.phone ?? "", - notes, - extraContacts: extras, - }), - ); + const site = siteRecordValues(locationDetail); + setPn(site.pn); + setPp(site.pp); + setExtraContacts(site.extraContacts); + setNotes(site.notes); + setPrimaryId(site.primaryId); + setBaseline(toRequest(site.primaryId, site)); setSyncedFor(locId); }, [ enabled, open, locId, locationDetail, + locationDetailFetching, syncedFor, userEdited, setPn, diff --git a/src/test/app/(protected)/workorders/site-dialog-site-record-resync.test.tsx b/src/test/app/(protected)/workorders/site-dialog-site-record-resync.test.tsx new file mode 100644 index 00000000..12be55f2 --- /dev/null +++ b/src/test/app/(protected)/workorders/site-dialog-site-record-resync.test.tsx @@ -0,0 +1,190 @@ +import { QueryClient } from "@tanstack/react-query"; +import { fireEvent, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SiteDialog } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog"; +import type { Location } from "@/domain/locations/types/location"; +import { queryKeys } from "@/infra/query-key/query-key"; +import { renderWithProviders } from "@/test/test-utils"; + +const getById = vi.fn(); +const updateContactInfo = vi.fn(); + +vi.mock("@/domain/locations/api/locations-api", () => ({ + locationsApi: { + getById: (...args: unknown[]) => getById(...args), + updateContactInfo: (...args: unknown[]) => updateContactInfo(...args), + }, +})); + +const dallas: Location = { + id: 1, + name: "DAL1", + address: "3811 Distribution Dr", + city: "Dallas", + state: "TX", + zipCode: "75201", + sitePhone: "(214) 555-0100", + notes: "Gate code 1234", + contacts: [ + { id: 31, name: "Jane", phone: "(421) 433-0032" }, + { id: 32, name: "Bob", phone: "(421) 433-0033" }, + ], +}; + +const DALLAS_CONTACTS = [ + { id: 31, name: "Jane", phone: "(421) 433-0032" }, + { id: 32, name: "Bob", phone: "(421) 433-0033" }, +]; + +const SITES = [ + { id: "1", name: "DAL1" }, + { id: "2", name: "CHI2" }, +]; + +const SITE_RECORD_COPY = + "Contacts and notes are saved to the site record and apply to all its work orders."; +const SITE_UNAVAILABLE_COPY = "Site record unavailable — changes apply to this work order only."; + +function renderDialog(queryClient?: QueryClient) { + const onSave = vi.fn(); + renderWithProviders( + , + { withAuth: false, ...(queryClient === undefined ? {} : { queryClient }) }, + ); + return { onSave }; +} + +/** The main contact's field; additional contact rows reuse the same placeholders after it. */ +function primaryField(placeholder: string): HTMLElement { + return screen.getAllByPlaceholderText(placeholder)[0]; +} + +/** Opens the Site picker and chooses a site by its label. */ +function pickSite(label: string) { + const trigger = document.querySelector('[aria-haspopup="listbox"]'); + if (trigger === null) throw new Error("expected the Site picker"); + fireEvent.click(trigger); + fireEvent.click(screen.getByRole("button", { name: label })); +} + +function editNotesAndSave(notes: string) { + fireEvent.change(primaryField("Notes…"), { target: { value: notes } }); + fireEvent.click(screen.getByRole("button", { name: /^save$/i })); +} + +describe("Work order Site dialog keeps the site record in sync", () => { + beforeEach(() => { + getById.mockReset(); + updateContactInfo.mockReset(); + updateContactInfo.mockResolvedValue(undefined); + }); + + it("re-syncs a site's extra contacts after switching away and back while the other site loads", async () => { + getById.mockImplementation((id: string) => + String(id) === "1" ? Promise.resolve(dallas) : new Promise(() => {}), + ); + renderDialog(); + expect(await screen.findByDisplayValue("Bob")).toBeInTheDocument(); + + pickSite("CHI2"); + await waitFor(() => expect(getById).toHaveBeenCalledWith("2")); + expect(screen.queryByDisplayValue("Bob")).not.toBeInTheDocument(); + pickSite("DAL1"); + + expect(await screen.findByDisplayValue("Bob")).toBeEnabled(); + editNotesAndSave("Gate code 9999"); + + await waitFor(() => expect(updateContactInfo).toHaveBeenCalledTimes(1)); + expect(updateContactInfo).toHaveBeenCalledWith("1", { + contacts: DALLAS_CONTACTS, + notes: "Gate code 9999", + }); + }); + + it("re-syncs a site's extra contacts after switching away and back when the other site failed to load", async () => { + getById.mockImplementation((id: string) => + String(id) === "1" ? Promise.resolve(dallas) : Promise.reject(new Error("Network error")), + ); + renderDialog(); + expect(await screen.findByDisplayValue("Bob")).toBeInTheDocument(); + + pickSite("CHI2"); + expect(await screen.findByText(SITE_UNAVAILABLE_COPY)).toBeInTheDocument(); + pickSite("DAL1"); + + expect(await screen.findByDisplayValue("Bob")).toBeEnabled(); + expect(screen.getByText(SITE_RECORD_COPY)).toBeInTheDocument(); + editNotesAndSave("Gate code 9999"); + + await waitFor(() => expect(updateContactInfo).toHaveBeenCalledTimes(1)); + expect(updateContactInfo).toHaveBeenCalledWith("1", { + contacts: DALLAS_CONTACTS, + notes: "Gate code 9999", + }); + }); + + it("waits for the refetch instead of syncing a cached site record, so a save never reverts it", async () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + // What the cache still holds from before an earlier Save changed the site. + queryClient.setQueryData(queryKeys.locations.detail("1"), dallas); + const saved: Location = { + ...dallas, + notes: "Saved gate code", + contacts: [ + { id: 31, name: "Jane", phone: "(421) 433-9999" }, + { id: 32, name: "Bob", phone: "(421) 433-0033" }, + ], + }; + let resolveSite: (site: Location) => void = () => {}; + getById.mockReturnValue( + new Promise((resolve) => { + resolveSite = resolve; + }), + ); + renderDialog(queryClient); + + await waitFor(() => expect(getById).toHaveBeenCalled()); + expect(primaryField("POC phone")).toBeDisabled(); + expect(screen.queryByDisplayValue("(421) 433-0032")).not.toBeInTheDocument(); + resolveSite(saved); + + expect(await screen.findByDisplayValue("(421) 433-9999")).toBeEnabled(); + expect(primaryField("Notes…")).toHaveValue("Saved gate code"); + editNotesAndSave("Gate code 9999"); + + await waitFor(() => expect(updateContactInfo).toHaveBeenCalledTimes(1)); + expect(updateContactInfo).toHaveBeenCalledWith("1", { + contacts: saved.contacts, + notes: "Gate code 9999", + }); + }); + + it("leaves a half-filled extra contact off the site record, as the work order does", async () => { + getById.mockResolvedValue(dallas); + const { onSave } = renderDialog(); + await screen.findByDisplayValue("Bob"); + + fireEvent.click(screen.getByRole("button", { name: /Add point of contact/ })); + const names = screen.getAllByPlaceholderText("POC name"); + fireEvent.change(names[names.length - 1], { target: { value: "Name Only" } }); + editNotesAndSave("Gate code 9999"); + + await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); + expect(updateContactInfo).toHaveBeenCalledWith("1", { + contacts: DALLAS_CONTACTS, + notes: "Gate code 9999", + }); + }); +});