Merge pull request #259 from Sea-Haven-Industries/fix/ab/sh-391-adv-filter-date-range

fix(workorders): make the Advanced Filters date range narrow results (SH-391)
This commit is contained in:
Alexandre Brandizzi 2026-09-25 05:53:39 +00:00 • committed by GitHub
commit 52cbb87126
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 232 additions and 24 deletions

View file

@ -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,
}))
}
/>
))}
<AdvFilterPill

View file

@ -48,7 +48,11 @@ import {
type WorkOrdersListParams,
type WorkOrdersListResult,
} from "@/domain/work-orders/types/work-order-list";
import { buildBoardSearchParamsRecord } from "@/domain/work-orders/utils/board-query-params";
import {
ADVANCED_SEARCH_ALL_WEEKS_FROM,
ADVANCED_SEARCH_ALL_WEEKS_TO,
buildBoardSearchParamsRecord,
} from "@/domain/work-orders/utils/board-query-params";
function buildSearchParams(params: WorkOrdersListParams): Record<string, string | number> {
const searchParams: Record<string, string | number> = {
@ -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.
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,
});

View file

@ -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. */
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,

View file

@ -318,6 +318,11 @@ export interface WorkOrderBoardSearchQueryParams {
* legacy wire behavior.
*/
unscheduledOnly?: boolean;
/**
* `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;

View file

@ -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: "",
}),

View file

@ -2,18 +2,44 @@ 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 {
ADVANCED_SEARCH_ALL_WEEKS_FROM,
ADVANCED_SEARCH_ALL_WEEKS_TO,
} from "@/domain/work-orders/utils/board-query-params";
import { isTerminalCarryOverStatus } from "@/domain/work-orders/utils/carry-over-display";
import { addDaysIso } from "@/domain/work-orders/utils/wizard-date-utils";
type AdvancedFilterPredicate = (
row: WorkOrderTableRow,
filters: WorkOrderAdvancedFilters,
) => boolean;
const isOpenUndated = (row: WorkOrderTableRow): boolean =>
!row.scheduledOn && !isTerminalCarryOverStatus(row.status);
// Mirrors the board search (ApplyDateRangeFilter and ApplyUnscheduledOnlyFilter)
// so the page and /board/search keep the same rows: a row is in a range
// when its date is, or when it is week-only and its target week overlaps. With
// no range, every week is searched and open undated rows are added. Closed
// undated rows are never unscheduled.
const matchesDateRange: AdvancedFilterPredicate = (row, filters) => {
if (filters.unscheduledOnly) {
return !row.scheduledOn;
return isOpenUndated(row);
}
const [from, to] = dateRangeFor(filters);
return !row.scheduledOn || (row.scheduledOn >= from && row.scheduledOn <= to);
const [from, to] = dateRangeFor(filters) ?? [
ADVANCED_SEARCH_ALL_WEEKS_FROM,
ADVANCED_SEARCH_ALL_WEEKS_TO,
];
if (row.scheduledOn && row.scheduledOn >= from && row.scheduledOn <= to) return true;
if (
row.scheduleWeekOnly &&
Boolean(row.targetWeek) &&
row.targetWeek <= to &&
addDaysIso(row.targetWeek, 6) >= from
) {
return true;
}
return filters.dateRange === null && isOpenUndated(row);
};
const matchesSite: AdvancedFilterPredicate = (row, filters) =>

View file

@ -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;
}

View file

@ -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) {
// 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,
};
}

View file

@ -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 <AdvDateRangeSection draft={draft} setDraft={setDraft} />;
}
function openSection() {
onDraft.mockClear();
renderWithProviders(<Harness />);
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", () => {
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();
});
});

View file

@ -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();

View file

@ -420,6 +420,14 @@ describe("workOrdersApi.searchBoard", () => {
});
});
/** A WO# lookup spans every week and undated rows. */
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",
});

View file

@ -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);
});

View file

@ -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", () => {
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", () => {
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", () => {
expect(toUnassignedQueueQueryParams().includeDateless).toBe(true);
});
it("sends unscheduledOnly=true with no faked Custom date window (SH-338)", () => {

View file

@ -454,6 +454,56 @@ describe("matchesAdvancedFilters", () => {
expect(matchesAdvancedFilters(weekOnly, filters)).toBe(true);
expect(matchesAdvancedFilters(undatedAssigned, filters)).toBe(true);
});
it("matches a range the same way the board search does", () => {
const filters = {
...EMPTY_ADVANCED_FILTERS,
dateRange: "custom" as const,
customFrom: "2026-09-23",
customTo: "2026-09-25",
};
// Week-only: a target week overlapping the range matches, whatever date the
// row still carries.
const weekOnlyStaleDate = makeRow({
scheduledOn: "2026-10-02",
targetWeek: "2026-09-21",
scheduleWeekOnly: true,
});
const weekOnlyPreviousWeek = makeRow({
scheduledOn: "",
targetWeek: "2026-09-14",
scheduleWeekOnly: true,
});
const weekOnlyOutside = makeRow({
scheduledOn: "",
targetWeek: "2026-09-28",
scheduleWeekOnly: true,
});
const datedOutsideWithWeek = makeRow({ scheduledOn: "2026-10-02", targetWeek: "2026-09-21" });
const undated = makeRow({ scheduledOn: "", targetWeek: "" });
expect(matchesAdvancedFilters(weekOnlyStaleDate, filters)).toBe(true);
expect(matchesAdvancedFilters(weekOnlyPreviousWeek, filters)).toBe(false);
expect(matchesAdvancedFilters(weekOnlyOutside, filters)).toBe(false);
expect(matchesAdvancedFilters(datedOutsideWithWeek, filters)).toBe(false);
expect(matchesAdvancedFilters(undated, filters)).toBe(false);
// No range: every week, plus open undated rows. Closed undated rows are
// never unscheduled, with or without the Unscheduled filter.
const noRange = { ...filters, dateRange: null };
const unscheduledOnly = { ...filters, unscheduledOnly: true };
const completedUndated = makeRow({ scheduledOn: "", targetWeek: "", status: "Completed" });
const canceledUndated = makeRow({ scheduledOn: "", targetWeek: "", status: "Canceled" });
expect(matchesAdvancedFilters(undated, noRange)).toBe(true);
expect(matchesAdvancedFilters(datedOutsideWithWeek, noRange)).toBe(true);
expect(matchesAdvancedFilters(weekOnlyOutside, noRange)).toBe(true);
expect(matchesAdvancedFilters(completedUndated, noRange)).toBe(false);
expect(matchesAdvancedFilters(canceledUndated, noRange)).toBe(false);
expect(matchesAdvancedFilters(undated, unscheduledOnly)).toBe(true);
expect(matchesAdvancedFilters(completedUndated, unscheduledOnly)).toBe(false);
expect(matchesAdvancedFilters(canceledUndated, unscheduledOnly)).toBe(false);
});
});
describe("computeIsPastDue", () => {