From fc3e7591dc8eb095fdf93f1403ca8992f6b34b0a Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 15 Sep 2026 19:11:35 -0300 Subject: [PATCH] fix(work-orders): add Unscheduled option to Advanced Filters Date range (#181) * fix(work-orders): add Unscheduled option to Advanced Filters Date range * fix(work-orders): page unscheduled results server-side --------- Co-authored-by: Codex Review Integration Co-authored-by: Alexandre Brandizzi --- .../adv-date-range-section.tsx | 15 +- .../_hooks/use-work-orders-table-data.ts | 6 +- .../types/work-order-advanced-filters.ts | 3 + .../work-orders/types/work-order-board.ts | 6 + .../utils/advanced-filter-chip-builders.ts | 34 ++- .../utils/advanced-filter-predicates.ts | 3 + .../utils/advanced-filters-utils.ts | 2 + .../work-orders/utils/board-query-params.ts | 8 +- ...wo-table-unscheduled-server-owned.test.tsx | 233 ++++++++++++++++++ ...ers-advanced-filters-banner-count.test.tsx | 20 ++ ...ork-orders-advanced-search-wiring.test.tsx | 47 ++++ .../work-orders/api/work-orders-api.test.ts | 36 +++ .../utils/advanced-filters-utils.test.ts | 48 ++++ .../utils/board-query-params.test.ts | 83 +++++++ .../utils/table-filter-utils.test.ts | 23 ++ 15 files changed, 547 insertions(+), 20 deletions(-) create mode 100644 src/test/app/(protected)/workorders/wo-table-unscheduled-server-owned.test.tsx diff --git a/src/app/(protected)/workorders/_components/list/advanced-search/adv-date-range-section.tsx b/src/app/(protected)/workorders/_components/list/advanced-search/adv-date-range-section.tsx index 06e2104e..ee9f1b25 100644 --- a/src/app/(protected)/workorders/_components/list/advanced-search/adv-date-range-section.tsx +++ b/src/app/(protected)/workorders/_components/list/advanced-search/adv-date-range-section.tsx @@ -14,13 +14,13 @@ type AdvDateRangeSectionProps = { setDraft: Dispatch>; }; +function dateRangeSectionCount(draft: WorkOrderAdvancedFilters): number { + return (isDefaultDateRange(draft.dateRange) ? 0 : 1) + (draft.unscheduledOnly ? 1 : 0); +} + export function AdvDateRangeSection({ draft, setDraft }: AdvDateRangeSectionProps) { return ( - +
{DATE_RANGE_OPTIONS.map((option) => ( setDraft((prev) => ({ ...prev, dateRange: option.key }))} /> ))} + setDraft((prev) => ({ ...prev, unscheduledOnly: !prev.unscheduledOnly }))} + />
{draft.dateRange === "custom" && (
diff --git a/src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts index 2163bcfb..0e6c0b98 100644 --- a/src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts +++ b/src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts @@ -32,7 +32,11 @@ export function useWorkOrdersTableData(filters: FiltersReturn) { }); const { displayRows, ...localUi } = local; - const advResults = useMemo(() => (advActive ? displayRows : []), [advActive, displayRows]); + // SH-338: the server owns rows/totals for advanced search (incl. Unscheduled). + const advResults = useMemo( + () => (advActive ? displayRows : EMPTY_WEEKLY_ITEMS), + [advActive, displayRows], + ); const filtered = useMemo(() => { if (advActive) { 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 3157bca1..e9ad34f9 100644 --- a/src/domain/work-orders/types/work-order-advanced-filters.ts +++ b/src/domain/work-orders/types/work-order-advanced-filters.ts @@ -26,6 +26,8 @@ export interface WorkOrderAdvancedFilters { dateRange: DateRangeKey; customFrom: string; customTo: string; + /** invariant: independent of DateRangeKey; never sent as datePreset=Unscheduled. */ + unscheduledOnly: boolean; sites: Set; types: Set; severities: Set; @@ -48,6 +50,7 @@ export const EMPTY_ADVANCED_FILTERS: WorkOrderAdvancedFilters = { dateRange: "this-week", customFrom: "", customTo: "", + unscheduledOnly: false, sites: new Set(), types: new Set(), severities: new Set(), diff --git a/src/domain/work-orders/types/work-order-board.ts b/src/domain/work-orders/types/work-order-board.ts index b8bdcaa0..4c5606b4 100644 --- a/src/domain/work-orders/types/work-order-board.ts +++ b/src/domain/work-orders/types/work-order-board.ts @@ -299,6 +299,12 @@ export interface WorkOrderBoardSearchQueryParams { avetaOnly?: boolean; flagColors?: string[]; internalOnly?: boolean; + /** + * SH-338: server-side Unscheduled facet. `true` makes the server ignore the + * date range only; every other facet still applies. Absent/false keeps the + * legacy wire behavior. + */ + unscheduledOnly?: boolean; hasUplift?: boolean; upliftStatuses?: string[]; myWorkOrders?: boolean; 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 4960e6dd..57bdab98 100644 --- a/src/domain/work-orders/utils/advanced-filter-chip-builders.ts +++ b/src/domain/work-orders/utils/advanced-filter-chip-builders.ts @@ -60,21 +60,29 @@ function severityLabel(level: WorkOrderSeverity): string { } export function appendDateChip(chips: AdvancedFilterChip[], filters: WorkOrderAdvancedFilters) { - if (filters.dateRange === "this-week") return; - const value = - filters.dateRange === "custom" - ? `${fmtChipDate(filters.customFrom) || "Any"} – ${fmtChipDate(filters.customTo) || "Any"}` - : (DATE_RANGE_OPTIONS.find((o) => o.key === filters.dateRange)?.label ?? "Custom"); + 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: "", + }), + }); + } + if (!filters.unscheduledOnly) return; chips.push({ - key: "date", + key: "unscheduled", group: "Date", - value, - clear: (current) => ({ - ...current, - dateRange: "this-week", - customFrom: "", - customTo: "", - }), + value: "Unscheduled", + clear: (current) => ({ ...current, unscheduledOnly: false }), }); } diff --git a/src/domain/work-orders/utils/advanced-filter-predicates.ts b/src/domain/work-orders/utils/advanced-filter-predicates.ts index c4f165f8..5217261b 100644 --- a/src/domain/work-orders/utils/advanced-filter-predicates.ts +++ b/src/domain/work-orders/utils/advanced-filter-predicates.ts @@ -9,6 +9,9 @@ type AdvancedFilterPredicate = ( ) => boolean; const matchesDateRange: AdvancedFilterPredicate = (row, filters) => { + if (filters.unscheduledOnly) { + return !row.scheduledOn; + } const [from, to] = dateRangeFor(filters); return !row.scheduledOn || (row.scheduledOn >= from && row.scheduledOn <= to); }; diff --git a/src/domain/work-orders/utils/advanced-filters-utils.ts b/src/domain/work-orders/utils/advanced-filters-utils.ts index b98d2ef5..97e503f2 100644 --- a/src/domain/work-orders/utils/advanced-filters-utils.ts +++ b/src/domain/work-orders/utils/advanced-filters-utils.ts @@ -32,6 +32,7 @@ function cloneFilters(filters: WorkOrderAdvancedFilters): WorkOrderAdvancedFilte docs: new Set(filters.docs), flagColors: new Set(filters.flagColors ?? []), upliftStatuses: new Set(filters.upliftStatuses ?? []), + unscheduledOnly: Boolean(filters.unscheduledOnly), }; } @@ -52,6 +53,7 @@ const ADVANCED_FILTER_ACTIVE_SIGNALS = (filters: WorkOrderAdvancedFilters): bool Boolean(filters.flagColors?.size), filters.internalOnly, filters.hasUplift, + filters.unscheduledOnly, ]; export function countAdvancedFilters(filters: WorkOrderAdvancedFilters): number { diff --git a/src/domain/work-orders/utils/board-query-params.ts b/src/domain/work-orders/utils/board-query-params.ts index b16881eb..29780d1a 100644 --- a/src/domain/work-orders/utils/board-query-params.ts +++ b/src/domain/work-orders/utils/board-query-params.ts @@ -155,10 +155,15 @@ export function toBoardQueryParams( } function resolveAdvancedSearchDate(filters: WorkOrderAdvancedFilters): { - datePreset: WorkOrderBoardSearchQueryParams["datePreset"]; + datePreset?: WorkOrderBoardSearchQueryParams["datePreset"]; dateFrom?: string; dateTo?: string; } { + // SH-338: server-side unscheduledOnly owns the date-range exemption; the + // client must not fake a Custom window and sends no date params. + if (filters.unscheduledOnly) { + return {}; + } if (filters.dateRange === EMPTY_ADVANCED_FILTERS.dateRange) { return { datePreset: "Custom", @@ -216,6 +221,7 @@ export function toBoardSearchQueryParams(input: { pmTypes: filters.pmTypes.size > 0 ? [...filters.pmTypes] : undefined, vendorIds: resolveVendorIds(filters.vendorTechs, input.vendorIdsByCompanyName), docStatuses: mapDocStatuses(filters.docs), + unscheduledOnly: filters.unscheduledOnly || undefined, ...mapSearchFacetParams(filters), sortBy: input.sortBy || "scheduledDate", sortDir: input.sortDir === "desc" ? "desc" : "asc", diff --git a/src/test/app/(protected)/workorders/wo-table-unscheduled-server-owned.test.tsx b/src/test/app/(protected)/workorders/wo-table-unscheduled-server-owned.test.tsx new file mode 100644 index 00000000..ecd9f64f --- /dev/null +++ b/src/test/app/(protected)/workorders/wo-table-unscheduled-server-owned.test.tsx @@ -0,0 +1,233 @@ +import { fireEvent, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { WorkOrderBoardSearchQueryParams } from "@/domain/work-orders/types/work-order-board"; +import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { renderWithProviders } from "@/test/test-utils"; + +const { searchBoard, getBoard, emptyList } = vi.hoisted(() => ({ + searchBoard: vi.fn(), + getBoard: vi.fn(), + emptyList: [] as const, +})); + +vi.mock("@/domain/work-orders/api/work-orders-api", () => ({ + workOrdersApi: { + searchBoard: (...args: unknown[]) => searchBoard(...args), + getBoard: (...args: unknown[]) => getBoard(...args), + }, +})); + +vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({ + useWorkOrderLocations: () => ({ data: emptyList }), + useWorkOrderVendors: () => ({ data: emptyList }), +})); + +vi.mock("@/domain/locations/use-cases/use-location-sites", () => ({ + useLocationSites: () => ({ data: emptyList }), +})); + +vi.mock("@/app/(protected)/workorders/_components/list/table/use-wo-table-inline-create", () => ({ + useWoTableInlineCreate: () => ({ handleInlineSave: vi.fn(), saving: false }), +})); + +vi.mock("react-toastify", () => ({ toast: { error: vi.fn(), success: vi.fn() } })); + +import { WoTable } from "@/app/(protected)/workorders/_components/list/table/wo-table"; +import { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters"; +import { useWorkOrdersTableData } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-data"; + +const PAGE_SIZE = 25; + +function makeRow(overrides: Partial = {}): WorkOrderTableRow { + return { + id: "1", + woNumber: "WO-1", + site: "CLV2", + type: "PM", + severity: null, + dispatcherId: "", + dispatcherName: "", + scheduledOn: "", + completedDate: "", + targetWeek: "", + dayGroup: "", + dayLabel: "", + location: "Cleveland, OH", + locationId: "", + pm: "Backflow", + serviceNotes: "", + extraServices: [], + company: "", + tech: "", + techPhone: "", + apptStart: "", + apptEnd: "", + apptTime: "—", + status: "Incomplete", + docStatus: "No", + pocName: "", + pocPhone: "", + pocNotes: "", + dueDate: "", + rescheduleCount: 0, + carriedOver: 0, + isAddOn: false, + originalWeek: "", + originalDate: "", + isPastDue: false, + scheduleWeekOnly: false, + mediaCount: 0, + title: "", + priority: "", + vendorId: "", + primaryDispatchId: null, + rowVersion: "v1", + dispatchRowVersion: "", + flagColor: null, + additionalContacts: [], + ...overrides, + }; +} + +function emptyBoardResult() { + return { + items: [] as WorkOrderTableRow[], + scheduled: [] as WorkOrderTableRow[], + unscheduled: [] as WorkOrderTableRow[], + totalCount: 0, + returnedCount: 0, + page: 0, + pageSize: PAGE_SIZE, + }; +} + +function pagedResult(rows: WorkOrderTableRow[], page: number, pageSize: number) { + const items = rows.slice(page * pageSize, page * pageSize + pageSize); + return { + items, + scheduled: items, + unscheduled: [] as WorkOrderTableRow[], + totalCount: rows.length, + returnedCount: items.length, + page, + pageSize, + }; +} + +function lastSearchParams(): WorkOrderBoardSearchQueryParams { + const calls = searchBoard.mock.calls; + return calls[calls.length - 1]?.[0] as WorkOrderBoardSearchQueryParams; +} + +function UnscheduledServerOwnedHarness() { + const filters = useWorkOrdersListFilters(); + const tableData = useWorkOrdersTableData(filters); + + return ( +
+
+ {`adv=${String(tableData.advActive)} rows=${tableData.advResults.length} total=${tableData.scopeTotalCount}`} +
+ + undefined} + onOpenMedia={() => undefined} + onPatch={() => undefined} + /> +
+ ); +} + +describe("unscheduled advanced search is server-owned (SH-338)", () => { + beforeEach(() => { + searchBoard.mockReset(); + getBoard.mockReset(); + getBoard.mockResolvedValue(emptyBoardResult()); + }); + + it("sends unscheduledOnly=true with no date window and pages server rows/totals", async () => { + // Server owns totals: page 0 returns 3 rows while totalCount spans 2 pages. + const pageZero = [ + makeRow({ id: "wo-0", woNumber: "WO-0", scheduledOn: "" }), + // Server owns row filtering: a dated row on the page must still render. + makeRow({ id: "wo-1", woNumber: "WO-1", scheduledOn: "2026-08-17" }), + makeRow({ id: "wo-2", woNumber: "WO-2", scheduledOn: "" }), + ]; + const pageOne = [ + makeRow({ id: "wo-25", woNumber: "WO-25", scheduledOn: "" }), + makeRow({ id: "wo-26", woNumber: "WO-26", scheduledOn: "" }), + ]; + const serverTotal = PAGE_SIZE + 2; + searchBoard.mockImplementation(async (params: WorkOrderBoardSearchQueryParams) => ({ + items: (params.page ?? 0) === 0 ? pageZero : pageOne, + scheduled: (params.page ?? 0) === 0 ? pageZero : pageOne, + unscheduled: [] as WorkOrderTableRow[], + totalCount: serverTotal, + returnedCount: ((params.page ?? 0) === 0 ? pageZero : pageOne).length, + page: params.page ?? 0, + pageSize: params.pageSize ?? PAGE_SIZE, + })); + + renderWithProviders(, { withAuth: false }); + + fireEvent.click(screen.getByRole("button", { name: "Apply unscheduled" })); + + await waitFor(() => { + expect(screen.getByTestId("harness-state")).toHaveTextContent("adv=true rows=3"); + }); + const params = lastSearchParams(); + expect(params.unscheduledOnly).toBe(true); + expect(params.datePreset).toBeUndefined(); + expect(params.dateFrom).toBeUndefined(); + expect(params.dateTo).toBeUndefined(); + expect(params.page).toBe(0); + expect(params.pageSize).toBe(PAGE_SIZE); + + expect(screen.getByText("WO-0")).toBeInTheDocument(); + expect(screen.getByText("WO-1")).toBeInTheDocument(); + expect(screen.getByText("WO-2")).toBeInTheDocument(); + expect(screen.queryByText("WO-25")).not.toBeInTheDocument(); + expect(screen.getByText(`RESULTS · ${serverTotal} items`)).toBeInTheDocument(); + expect(screen.getByText("Page 1 of 2")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Next page" })); + await waitFor(() => { + expect(screen.getByText("WO-25")).toBeInTheDocument(); + expect(screen.getByText("WO-26")).toBeInTheDocument(); + expect(screen.getByText("Page 2 of 2")).toBeInTheDocument(); + }); + expect(lastSearchParams().page).toBe(1); + expect(screen.getByTestId("harness-state")).toHaveTextContent("rows=2"); + expect(screen.getByText(`RESULTS · ${serverTotal} items`)).toBeInTheDocument(); + }, 15000); + + it("shows the adv empty state when the server returns zero unscheduled results", async () => { + searchBoard.mockResolvedValue(pagedResult([], 0, PAGE_SIZE)); + + renderWithProviders(, { withAuth: false }); + + fireEvent.click(screen.getByRole("button", { name: "Apply unscheduled" })); + + await waitFor(() => { + expect(screen.getByText("No work orders match these filters")).toBeInTheDocument(); + }); + expect(screen.getByTestId("harness-state")).toHaveTextContent("adv=true rows=0 total=0"); + expect(screen.queryByText(/Page \d+ of \d+/)).not.toBeInTheDocument(); + }, 15000); +}); diff --git a/src/test/app/(protected)/workorders/work-orders-advanced-filters-banner-count.test.tsx b/src/test/app/(protected)/workorders/work-orders-advanced-filters-banner-count.test.tsx index 8f9fc28d..1043eb54 100644 --- a/src/test/app/(protected)/workorders/work-orders-advanced-filters-banner-count.test.tsx +++ b/src/test/app/(protected)/workorders/work-orders-advanced-filters-banner-count.test.tsx @@ -22,4 +22,24 @@ describe("WorkOrdersAdvancedFiltersBanner count semantics", () => { expect(screen.getByText("150 work orders")).toBeInTheDocument(); }); + + it("shows Date / Unscheduled and the filtered list count when the toggle is on", () => { + render( + undefined} + onClearAll={() => undefined} + />, + ); + + expect(screen.getByText("Date")).toBeInTheDocument(); + expect(screen.getByText("Unscheduled")).toBeInTheDocument(); + expect(screen.getByText("3 work orders")).toBeInTheDocument(); + }); }); diff --git a/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx b/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx index 2bcf1cf6..6e64219f 100644 --- a/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx +++ b/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx @@ -343,6 +343,53 @@ describe("WorkOrders Advanced Search page wiring (SH-121)", () => { expect(screen.getByRole("button", { name: /work order types, all types/i })).toBeEnabled(); }); + it("applies Unscheduled from Date range and sends unscheduledOnly without a faked window", () => { + const view = renderWithProviders(, { + route: "/workorders", + withAuth: false, + }); + + fireEvent.click(screen.getByRole("button", { name: "Advanced Filters" })); + advOpen = true; + view.rerender(); + + const sheet = screen.getByRole("dialog", { name: "Advanced Filters" }); + fireEvent.click(within(sheet).getByRole("button", { name: /Date range/i })); + + const custom = within(sheet).getByRole("button", { name: /^Custom$/i }); + const unscheduled = within(sheet).getByRole("button", { name: /^Unscheduled$/i }); + expect(custom.compareDocumentPosition(unscheduled) & Node.DOCUMENT_POSITION_FOLLOWING).toBe( + Node.DOCUMENT_POSITION_FOLLOWING, + ); + + expect(unscheduled).toHaveAttribute("aria-pressed", "false"); + fireEvent.click(unscheduled); + expect(unscheduled).toHaveAttribute("aria-pressed", "true"); + expect(within(sheet).getByRole("button", { name: /^This week$/i })).toHaveAttribute( + "aria-pressed", + "true", + ); + expect(within(sheet).queryByLabelText(/^From$/i)).not.toBeInTheDocument(); + expect(within(sheet).queryByLabelText(/^To$/i)).not.toBeInTheDocument(); + + fireEvent.click(custom); + expect(within(sheet).getByLabelText(/^From$/i)).toBeInTheDocument(); + expect(unscheduled).toHaveAttribute("aria-pressed", "true"); + + fireEvent.click(within(sheet).getByRole("button", { name: /apply filters/i })); + + const appliedCalls = applyAdvancedFilters.mock.calls; + const applied = appliedCalls[appliedCalls.length - 1]?.[0] as typeof EMPTY_ADVANCED_FILTERS; + expect(applied.unscheduledOnly).toBe(true); + expect(applied.dateRange).toBe("custom"); + + const params = toBoardSearchQueryParams({ filters: applied }); + expect(params.unscheduledOnly).toBe(true); + expect(params.datePreset).toBeUndefined(); + expect(params.dateFrom).toBeUndefined(); + expect(params.dateTo).toBeUndefined(); + }); + it("places Aveta Compliance immediately above Uplift", () => { const view = renderWithProviders(, { route: "/workorders", diff --git a/src/test/domain/work-orders/api/work-orders-api.test.ts b/src/test/domain/work-orders/api/work-orders-api.test.ts index ec323a94..75f1e499 100644 --- a/src/test/domain/work-orders/api/work-orders-api.test.ts +++ b/src/test/domain/work-orders/api/work-orders-api.test.ts @@ -363,6 +363,42 @@ describe("workOrdersApi.searchBoard", () => { expect(result.items[0]?.pm).toBe("PM Filter"); expect(result.items[0]?.scheduledOn).toBe("2026-07-15"); }); + + it("serializes unscheduledOnly=true with no date window on the exact wire shape (SH-338)", async () => { + await workOrdersApi.searchBoard({ + page: 0, + pageSize: 50, + unscheduledOnly: true, + statuses: [3], + sortBy: "scheduledDate", + sortDir: "asc", + }); + + const searchParams = apiGet.mock.calls[0]?.[1]?.searchParams as URLSearchParams; + expect(String(searchParams)).toBe( + "page=0&pageSize=50&unscheduledOnly=true&statuses=3&sortBy=scheduledDate&sortDir=asc", + ); + expect(searchParams.get("unscheduledOnly")).toBe("true"); + expect(searchParams.has("datePreset")).toBe(false); + expect(searchParams.has("dateFrom")).toBe(false); + expect(searchParams.has("dateTo")).toBe(false); + }); + + it("keeps the legacy wire shape (datePreset, no unscheduledOnly) when the toggle is off", async () => { + await workOrdersApi.searchBoard({ + datePreset: "Custom", + dateFrom: "2000-01-01", + dateTo: "2099-12-31", + page: 0, + pageSize: 50, + }); + + const searchParams = apiGet.mock.calls[0]?.[1]?.searchParams as URLSearchParams; + expect(searchParams.has("unscheduledOnly")).toBe(false); + expect(searchParams.get("datePreset")).toBe("Custom"); + expect(searchParams.get("dateFrom")).toBe("2000-01-01"); + expect(searchParams.get("dateTo")).toBe("2099-12-31"); + }); }); describe("workOrdersApi.findByWoNumber", () => { diff --git a/src/test/domain/work-orders/utils/advanced-filters-utils.test.ts b/src/test/domain/work-orders/utils/advanced-filters-utils.test.ts index ad69e440..e26229d7 100644 --- a/src/test/domain/work-orders/utils/advanced-filters-utils.test.ts +++ b/src/test/domain/work-orders/utils/advanced-filters-utils.test.ts @@ -39,6 +39,24 @@ describe("countAdvancedFilters", () => { }; expect(countAdvancedFilters(filters)).toBe(3); }); + + it("counts Unscheduled +1 with the this-week default still in place", () => { + const filters = { + ...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS), + unscheduledOnly: true, + }; + expect(filters.dateRange).toBe("this-week"); + expect(countAdvancedFilters(filters)).toBe(1); + }); + + it("counts Unscheduled independently of a non-default date range", () => { + const filters = { + ...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS), + dateRange: "last-week" as const, + unscheduledOnly: true, + }; + expect(countAdvancedFilters(filters)).toBe(2); + }); }); describe("buildAdvancedFilterChips", () => { @@ -66,6 +84,27 @@ describe("buildAdvancedFilterChips", () => { }); }); + it("builds a Date / Unscheduled chip whose clear only turns the toggle off", () => { + const filters = { + ...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS), + dateRange: "last-week" as const, + unscheduledOnly: true, + }; + + const chips = buildAdvancedFilterChips(filters); + const unscheduled = chips.find((chip) => chip.key === "unscheduled"); + expect(unscheduled).toMatchObject({ + key: "unscheduled", + group: "Date", + value: "Unscheduled", + }); + expect(chips.some((chip) => chip.key === "date" && chip.value === "Last week")).toBe(true); + + const cleared = unscheduled?.clear(filters); + expect(cleared?.unscheduledOnly).toBe(false); + expect(cleared?.dateRange).toBe("last-week"); + }); + it("creates a separate unassigned chip for dispatchers", () => { const filters = { ...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS), @@ -101,6 +140,15 @@ describe("clearAdvancedFilterGroup", () => { expect(next).toBeNull(); }); + it("clears only Unscheduled and returns null when it was the last chip", () => { + const filters = { + ...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS), + unscheduledOnly: true, + }; + const next = clearAdvancedFilterGroup(filters, "unscheduled"); + expect(next).toBeNull(); + }); + it("builds chips for severity, indicators, flags, internal WO#, and uplift", () => { const filters = { ...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS), 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 52ac92ae..486cd60e 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 @@ -7,6 +7,7 @@ import { WORK_ORDER_TYPE_API, } from "@/domain/work-orders/types/work-order-board"; import type { WOType } from "@/domain/work-orders/types/work-order-wizard"; +import { queryKeys } from "@/infra/query-key/query-key"; import { BOARD_SEARCH_MAX_PAGE_SIZE, buildBoardSearchParamsRecord, @@ -88,6 +89,7 @@ describe("toBoardSearchQueryParams", () => { expect(params.datePreset).toBe("Custom"); expect(params.dateFrom).toBe("2000-01-01"); expect(params.dateTo).toBe("2099-12-31"); + expect(params.unscheduledOnly).toBeUndefined(); }); it("forwards chrome pageSize 50 instead of the search max", () => { @@ -133,6 +135,59 @@ describe("toBoardSearchQueryParams", () => { expect(thisWeek.dateTo).toBe("2099-12-31"); }); + it("sends unscheduledOnly=true with no faked Custom date window (SH-338)", () => { + const withDefaultRange = toBoardSearchQueryParams({ + filters: { ...EMPTY_ADVANCED_FILTERS, unscheduledOnly: true }, + }); + expect(withDefaultRange.unscheduledOnly).toBe(true); + expect(withDefaultRange.datePreset).toBeUndefined(); + expect(withDefaultRange.dateFrom).toBeUndefined(); + expect(withDefaultRange.dateTo).toBeUndefined(); + + const overridingLastWeek = toBoardSearchQueryParams({ + filters: { + ...EMPTY_ADVANCED_FILTERS, + dateRange: "last-week", + unscheduledOnly: true, + }, + }); + expect(overridingLastWeek.unscheduledOnly).toBe(true); + expect(overridingLastWeek.datePreset).toBeUndefined(); + expect(overridingLastWeek.dateFrom).toBeUndefined(); + expect(overridingLastWeek.dateTo).toBeUndefined(); + + const record = buildBoardSearchParamsRecord(overridingLastWeek); + expect(record.unscheduledOnly).toBe(true); + expect(record).not.toHaveProperty("datePreset"); + expect(record).not.toHaveProperty("dateFrom"); + expect(record).not.toHaveProperty("dateTo"); + }); + + it("keeps applying every other facet alongside unscheduledOnly", () => { + const params = toBoardSearchQueryParams({ + filters: { + ...EMPTY_ADVANCED_FILTERS, + unscheduledOnly: true, + statuses: new Set(["Scheduled"]), + severities: new Set([4]), + pmTypes: new Set(["HVAC"]), + }, + }); + expect(params.unscheduledOnly).toBe(true); + expect(params.statuses).toEqual([LIFECYCLE_STATUS_API.Scheduled]); + expect(params.severities).toEqual([4]); + expect(params.pmTypes).toEqual(["HVAC"]); + }); + + it("omits unscheduledOnly and keeps the wide Custom default when the toggle is off", () => { + const params = toBoardSearchQueryParams({ + filters: { ...EMPTY_ADVANCED_FILTERS, dateRange: "last-week" }, + }); + expect(params.unscheduledOnly).toBeUndefined(); + expect(params.datePreset).toBe("LastWeek"); + expect(buildBoardSearchParamsRecord(params)).not.toHaveProperty("unscheduledOnly"); + }); + it("maps Overdue-only advanced filter to overdue=true without types", () => { const params = toBoardSearchQueryParams({ filters: { @@ -188,6 +243,7 @@ describe("toBoardSearchQueryParams", () => { expect(params.internalOnly).toBeUndefined(); expect(params.hasUplift).toBeUndefined(); expect(params.upliftStatuses).toBeUndefined(); + expect(params.unscheduledOnly).toBeUndefined(); }); it("maps service types and completion docs onto board search params", () => { @@ -350,3 +406,30 @@ describe("toUnassignedQueueQueryParams", () => { expect(record).not.toHaveProperty("weekEnd"); }); }); + +describe("boardSearch query-key isolation", () => { + it("separates unscheduledOnly=true from the same facets without it", () => { + const without = toBoardSearchQueryParams({ + filters: { ...EMPTY_ADVANCED_FILTERS, rescheduled: true }, + }); + const withUnscheduled = toBoardSearchQueryParams({ + filters: { ...EMPTY_ADVANCED_FILTERS, rescheduled: true, unscheduledOnly: true }, + }); + + expect(queryKeys.workOrders.boardSearch(without)).not.toEqual( + queryKeys.workOrders.boardSearch(withUnscheduled), + ); + }); + + it("separates pages under the same unscheduledOnly filter", () => { + const base = toBoardSearchQueryParams({ + filters: { ...EMPTY_ADVANCED_FILTERS, unscheduledOnly: true }, + page: 0, + }); + const pageOne = { ...base, page: 1 }; + + expect(queryKeys.workOrders.boardSearch(base)).not.toEqual( + queryKeys.workOrders.boardSearch(pageOne), + ); + }); +}); 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 b6a6c416..a2e1f2eb 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 @@ -431,6 +431,29 @@ describe("matchesAdvancedFilters", () => { matchesAdvancedFilters(makeRow({ scheduledOn: weekMonday, avetaRequired: false }), filters), ).toBe(false); }); + + it("keeps only undated rows when Unscheduled overrides any date range", () => { + const filters = { + ...EMPTY_ADVANCED_FILTERS, + dateRange: "last-week" as const, + unscheduledOnly: true, + }; + const dated = makeRow({ scheduledOn: "2026-06-23" }); + const weekOnly = makeRow({ + scheduledOn: "", + targetWeek: "2026-07-06", + scheduleWeekOnly: true, + }); + const undatedAssigned = makeRow({ + scheduledOn: "", + dispatcherId: "u1", + dispatcherName: "Alice", + }); + + expect(matchesAdvancedFilters(dated, filters)).toBe(false); + expect(matchesAdvancedFilters(weekOnly, filters)).toBe(true); + expect(matchesAdvancedFilters(undatedAssigned, filters)).toBe(true); + }); }); describe("computeIsPastDue", () => {