mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 06:53:12 +00:00
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.
This commit is contained in:
parent
6367dc2a7a
commit
0d6af0da59
13 changed files with 167 additions and 23 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 (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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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: "",
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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)", () => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue