From 0d6af0da592b04f7a88ca91aa58460a6c6bac0fd Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 24 Sep 2026 20:55:59 -0300 Subject: [PATCH] fix(workorders): make the Advanced Filters date range narrow results (SH-391) The Advanced Filters default was This week, and that value doubled as no range, so an explicit This week searched every week. Other ranges passed every undated WO through on the server. No range is now its own state (nothing selected, like the prototype; clicking a selected range clears it). It searches every week with undated rows, via includeDateless. Any selected range narrows strictly. The pinned Unassigned queue and the WO# duplicate lookup keep spanning every week. --- .../adv-date-range-section.tsx | 7 ++- src/domain/work-orders/api/work-orders-api.ts | 11 +++- .../types/work-order-advanced-filters.ts | 5 +- .../work-orders/types/work-order-board.ts | 5 ++ .../utils/advanced-filter-chip-builders.ts | 4 +- .../utils/advanced-filter-predicates.ts | 16 ++++- .../utils/advanced-filters-utils.ts | 10 +-- .../work-orders/utils/board-query-params.ts | 13 ++-- .../adv-date-range-section.test.tsx | 63 +++++++++++++++++++ ...ork-orders-advanced-search-wiring.test.tsx | 2 +- .../work-orders/api/work-orders-api.test.ts | 10 +++ .../utils/advanced-filters-utils.test.ts | 4 +- .../utils/board-query-params.test.ts | 40 +++++++++++- 13 files changed, 167 insertions(+), 23 deletions(-) create mode 100644 src/test/app/(protected)/workorders/adv-date-range-section.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 ee9f1b25..284c3707 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 @@ -27,7 +27,12 @@ export function AdvDateRangeSection({ draft, setDraft }: AdvDateRangeSectionProp key={option.key} label={option.label} selected={draft.dateRange === option.key} - onClick={() => setDraft((prev) => ({ ...prev, dateRange: option.key }))} + onClick={() => + setDraft((prev) => ({ + ...prev, + dateRange: prev.dateRange === option.key ? null : option.key, + })) + } /> ))} { const searchParams: Record = { @@ -223,8 +227,13 @@ export const workOrdersApi = { "Unable to verify the work order number: search result set exceeded the safety page limit.", ); } + // Every week plus undated rows: a WO# must be unique regardless of schedule (SH-391). const result = await workOrdersApi.searchBoard({ search: normalized, + datePreset: "Custom", + dateFrom: ADVANCED_SEARCH_ALL_WEEKS_FROM, + dateTo: ADVANCED_SEARCH_ALL_WEEKS_TO, + includeDateless: true, page, pageSize, }); 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 c187aee8..62719c8d 100644 --- a/src/domain/work-orders/types/work-order-advanced-filters.ts +++ b/src/domain/work-orders/types/work-order-advanced-filters.ts @@ -23,7 +23,8 @@ export type DateRangeKey = export type CompletionDocFilter = "No" | "Yes" | "NN"; export interface WorkOrderAdvancedFilters { - dateRange: DateRangeKey; + /** `null` = no date range selected: search every week (SH-391). */ + dateRange: DateRangeKey | null; customFrom: string; customTo: string; /** invariant: independent of DateRangeKey; never sent as datePreset=Unscheduled. */ @@ -48,7 +49,7 @@ export interface WorkOrderAdvancedFilters { } export const EMPTY_ADVANCED_FILTERS: WorkOrderAdvancedFilters = { - dateRange: "this-week", + dateRange: null, customFrom: "", customTo: "", unscheduledOnly: false, diff --git a/src/domain/work-orders/types/work-order-board.ts b/src/domain/work-orders/types/work-order-board.ts index 3b06e4fb..803495ba 100644 --- a/src/domain/work-orders/types/work-order-board.ts +++ b/src/domain/work-orders/types/work-order-board.ts @@ -318,6 +318,11 @@ export interface WorkOrderBoardSearchQueryParams { * legacy wire behavior. */ unscheduledOnly?: boolean; + /** + * SH-391: `true` adds open rows with no date to the range. Sent only when no + * date range is selected, so an explicit range strictly narrows the results. + */ + includeDateless?: 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 edc707a0..c7222b82 100644 --- a/src/domain/work-orders/utils/advanced-filter-chip-builders.ts +++ b/src/domain/work-orders/utils/advanced-filter-chip-builders.ts @@ -60,7 +60,7 @@ function severityLabel(level: WorkOrderSeverity): string { } export function appendDateChip(chips: AdvancedFilterChip[], filters: WorkOrderAdvancedFilters) { - if (filters.dateRange !== "this-week") { + if (filters.dateRange !== null) { const value = filters.dateRange === "custom" ? `${fmtChipDate(filters.customFrom) || "Any"} – ${fmtChipDate(filters.customTo) || "Any"}` @@ -71,7 +71,7 @@ export function appendDateChip(chips: AdvancedFilterChip[], filters: WorkOrderAd value, clear: (current) => ({ ...current, - dateRange: "this-week", + dateRange: null, customFrom: "", customTo: "", }), diff --git a/src/domain/work-orders/utils/advanced-filter-predicates.ts b/src/domain/work-orders/utils/advanced-filter-predicates.ts index 5217261b..75a6a40f 100644 --- a/src/domain/work-orders/utils/advanced-filter-predicates.ts +++ b/src/domain/work-orders/utils/advanced-filter-predicates.ts @@ -2,6 +2,7 @@ import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-o import { ASSIGNEE_FILTER_UNASSIGNED } from "@/domain/work-orders/types/work-order-list"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import { dateRangeFor } from "@/domain/work-orders/utils/advanced-filters-utils"; +import { addDaysIso } from "@/domain/work-orders/utils/wizard-date-utils"; type AdvancedFilterPredicate = ( row: WorkOrderTableRow, @@ -12,8 +13,19 @@ 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); + const range = dateRangeFor(filters); + if (!range) return true; + const [from, to] = range; + if (row.scheduledOn) { + return row.scheduledOn >= from && row.scheduledOn <= to; + } + // Week-only rows match when their target week overlaps the range (SH-391). + return ( + row.scheduleWeekOnly && + Boolean(row.targetWeek) && + row.targetWeek <= to && + addDaysIso(row.targetWeek, 6) >= from + ); }; const matchesSite: AdvancedFilterPredicate = (row, filters) => diff --git a/src/domain/work-orders/utils/advanced-filters-utils.ts b/src/domain/work-orders/utils/advanced-filters-utils.ts index 3f8f5ce5..871bd0cd 100644 --- a/src/domain/work-orders/utils/advanced-filters-utils.ts +++ b/src/domain/work-orders/utils/advanced-filters-utils.ts @@ -38,7 +38,7 @@ function cloneFilters(filters: WorkOrderAdvancedFilters): WorkOrderAdvancedFilte } const ADVANCED_FILTER_ACTIVE_SIGNALS = (filters: WorkOrderAdvancedFilters): boolean[] => [ - filters.dateRange !== "this-week", + filters.dateRange !== null, Boolean(filters.sites?.size), Boolean(filters.types?.size), Boolean(filters.severities?.size), @@ -70,7 +70,9 @@ 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] { +/** Inclusive [from, to] for the selected range; `null` when no range is selected. */ +export function dateRangeFor(filters: WorkOrderAdvancedFilters): [string, string] | null { + if (filters.dateRange === null) return null; const today = todayIso(); const ty = Number(today.slice(0, 4)); const tm = Number(today.slice(5, 7)) - 1; @@ -144,6 +146,6 @@ export function cloneAdvancedFilters(filters: WorkOrderAdvancedFilters): WorkOrd return cloneFilters(filters); } -export function isDefaultDateRange(dateRange: DateRangeKey): boolean { - return dateRange === "this-week"; +export function isDefaultDateRange(dateRange: DateRangeKey | null): boolean { + return dateRange === null; } diff --git a/src/domain/work-orders/utils/board-query-params.ts b/src/domain/work-orders/utils/board-query-params.ts index c10519b8..c5c21260 100644 --- a/src/domain/work-orders/utils/board-query-params.ts +++ b/src/domain/work-orders/utils/board-query-params.ts @@ -1,8 +1,5 @@ import { ASSIGNEE_FILTER_UNASSIGNED } from "@/domain/work-orders/types/work-order-list"; -import { - EMPTY_ADVANCED_FILTERS, - type WorkOrderAdvancedFilters, -} from "@/domain/work-orders/types/work-order-advanced-filters"; +import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters"; import type { WorkOrderBarFilters } from "@/domain/work-orders/types/work-order-list-filters"; import { BOARD_DISPATCHER_UNASSIGNED, @@ -158,17 +155,21 @@ function resolveAdvancedSearchDate(filters: WorkOrderAdvancedFilters): { datePreset?: WorkOrderBoardSearchQueryParams["datePreset"]; dateFrom?: string; dateTo?: string; + includeDateless?: boolean; } { // 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) { + // SH-391: no range selected searches every week, undated rows included. Any + // selected range (This week included) strictly narrows the results. + if (filters.dateRange === null) { return { datePreset: "Custom", dateFrom: ADVANCED_SEARCH_ALL_WEEKS_FROM, dateTo: ADVANCED_SEARCH_ALL_WEEKS_TO, + includeDateless: true, }; } @@ -213,6 +214,7 @@ export function toBoardSearchQueryParams(input: { datePreset: date.datePreset, dateFrom: date.dateFrom, dateTo: date.dateTo, + includeDateless: date.includeDateless, sites: filters.sites.size > 0 ? [...filters.sites] : undefined, types: typeFilters.types, overdue: typeFilters.overdue, @@ -267,5 +269,6 @@ export function toUnassignedQueueQueryParams( datePreset: "Custom", dateFrom: UNASSIGNED_QUEUE_DATE_FROM, dateTo: UNASSIGNED_QUEUE_DATE_TO, + includeDateless: true, }; } diff --git a/src/test/app/(protected)/workorders/adv-date-range-section.test.tsx b/src/test/app/(protected)/workorders/adv-date-range-section.test.tsx new file mode 100644 index 00000000..8ffe1f60 --- /dev/null +++ b/src/test/app/(protected)/workorders/adv-date-range-section.test.tsx @@ -0,0 +1,63 @@ +import { useEffect, useState } from "react"; +import { fireEvent, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { AdvDateRangeSection } from "@/app/(protected)/workorders/_components/list/advanced-search/adv-date-range-section"; +import { + EMPTY_ADVANCED_FILTERS, + type WorkOrderAdvancedFilters, +} from "@/domain/work-orders/types/work-order-advanced-filters"; +import { cloneAdvancedFilters } from "@/domain/work-orders/utils/advanced-filters-utils"; +import { toBoardSearchQueryParams } from "@/domain/work-orders/utils/board-query-params"; +import { renderWithProviders } from "@/test/test-utils"; + +const onDraft = vi.fn<(draft: WorkOrderAdvancedFilters) => void>(); + +function latestDraft(): WorkOrderAdvancedFilters { + return onDraft.mock.calls[onDraft.mock.calls.length - 1][0]; +} + +function Harness() { + const [draft, setDraft] = useState(() => cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS)); + useEffect(() => { + onDraft(draft); + }, [draft]); + return ; +} + +function openSection() { + onDraft.mockClear(); + renderWithProviders(); + const header = screen.queryByRole("button", { name: /Date range/i }); + if (header && !screen.queryByRole("button", { name: /^This week$/i })) { + fireEvent.click(header); + } +} + +describe("Advanced Filters date range (SH-391)", () => { + it("starts with no range selected, so the search spans every week", () => { + openSection(); + + for (const label of ["This week", "Last week", "Last 3 months", "Custom"]) { + expect(screen.getByRole("button", { name: new RegExp(`^${label}$`, "i") })).toHaveAttribute( + "aria-pressed", + "false", + ); + } + expect(toBoardSearchQueryParams({ filters: latestDraft() }).includeDateless).toBe(true); + }); + + it("narrows to This week once picked, and clicking it again clears the range", () => { + openSection(); + const thisWeek = screen.getByRole("button", { name: /^This week$/i }); + + fireEvent.click(thisWeek); + expect(thisWeek).toHaveAttribute("aria-pressed", "true"); + const narrowed = toBoardSearchQueryParams({ filters: latestDraft() }); + expect(narrowed.datePreset).toBe("ThisWeek"); + expect(narrowed.includeDateless).toBeUndefined(); + + fireEvent.click(thisWeek); + expect(thisWeek).toHaveAttribute("aria-pressed", "false"); + expect(latestDraft().dateRange).toBeNull(); + }); +}); 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 6e64219f..3927bfaf 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 @@ -367,7 +367,7 @@ describe("WorkOrders Advanced Search page wiring (SH-121)", () => { expect(unscheduled).toHaveAttribute("aria-pressed", "true"); expect(within(sheet).getByRole("button", { name: /^This week$/i })).toHaveAttribute( "aria-pressed", - "true", + "false", ); expect(within(sheet).queryByLabelText(/^From$/i)).not.toBeInTheDocument(); expect(within(sheet).queryByLabelText(/^To$/i)).not.toBeInTheDocument(); 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 27ce9129..7ea19716 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 @@ -420,6 +420,14 @@ describe("workOrdersApi.searchBoard", () => { }); }); +/** A WO# lookup spans every week and undated rows (SH-391). */ +const ALL_WEEKS_SEARCH = { + datePreset: "Custom", + dateFrom: "2000-01-01", + dateTo: "2099-12-31", + includeDateless: "true", +}; + describe("workOrdersApi.findByWoNumber", () => { beforeEach(() => { apiGet.mockReset(); @@ -450,6 +458,7 @@ describe("workOrdersApi.findByWoNumber", () => { const searchParams = apiGet.mock.calls[0]?.[1]?.searchParams as URLSearchParams; expect(Object.fromEntries(searchParams)).toEqual({ search: "10000001235", + ...ALL_WEEKS_SEARCH, page: "0", pageSize: "100", }); @@ -519,6 +528,7 @@ describe("workOrdersApi.findByWoNumber", () => { const searchParams = apiGet.mock.calls[0]?.[1]?.searchParams as URLSearchParams; expect(Object.fromEntries(searchParams)).toEqual({ search: "9", + ...ALL_WEEKS_SEARCH, page: "0", pageSize: "100", }); 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 e26229d7..f065e4e0 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 @@ -40,12 +40,12 @@ describe("countAdvancedFilters", () => { expect(countAdvancedFilters(filters)).toBe(3); }); - it("counts Unscheduled +1 with the this-week default still in place", () => { + it("counts Unscheduled +1 with no date range selected", () => { const filters = { ...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS), unscheduledOnly: true, }; - expect(filters.dateRange).toBe("this-week"); + expect(filters.dateRange).toBeNull(); expect(countAdvancedFilters(filters)).toBe(1); }); 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 486cd60e..3daaf798 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 @@ -130,9 +130,43 @@ describe("toBoardSearchQueryParams", () => { customTo: "2026-06-30", }, }); - expect(thisWeek.datePreset).toBe("Custom"); - expect(thisWeek.dateFrom).toBe("2000-01-01"); - expect(thisWeek.dateTo).toBe("2099-12-31"); + expect(thisWeek.datePreset).toBe("ThisWeek"); + expect(thisWeek.dateFrom).toBeUndefined(); + expect(thisWeek.dateTo).toBeUndefined(); + }); + + it("narrows strictly for every selected range, This week included (SH-391)", () => { + for (const dateRange of ["this-week", "last-week", "last-3-months", "custom"] as const) { + const params = toBoardSearchQueryParams({ + filters: { + ...EMPTY_ADVANCED_FILTERS, + dateRange, + customFrom: "2026-09-21", + customTo: "2026-09-25", + dispatchers: new Set(["__unassigned__"]), + }, + }); + expect(params.includeDateless).toBeUndefined(); + expect(params.dateFrom).not.toBe("2000-01-01"); + } + }); + + it("searches every week, undated rows included, when no range is selected (SH-391)", () => { + const params = toBoardSearchQueryParams({ + filters: { ...EMPTY_ADVANCED_FILTERS, dispatchers: new Set(["__unassigned__"]) }, + }); + expect(EMPTY_ADVANCED_FILTERS.dateRange).toBeNull(); + expect(params).toMatchObject({ + datePreset: "Custom", + dateFrom: "2000-01-01", + dateTo: "2099-12-31", + includeDateless: true, + dispatchers: ["__unassigned__"], + }); + }); + + it("keeps undated rows in the pinned Unassigned queue (SH-391)", () => { + expect(toUnassignedQueueQueryParams().includeDateless).toBe(true); }); it("sends unscheduledOnly=true with no faked Custom date window (SH-338)", () => {