From 5aae933e1bbd330c13b1b4d3b709a243d48acba7 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Fri, 11 Sep 2026 14:30:51 -0300 Subject: [PATCH] fix(work-orders): add Unscheduled option to Advanced Filters Date range --- .../adv-date-range-section.tsx | 15 ++++-- .../_hooks/use-work-orders-table-data.ts | 14 ++++-- .../types/work-order-advanced-filters.ts | 3 ++ .../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 | 7 +++ .../work-orders/utils/table-filter-utils.ts | 19 ++++++++ ...ers-advanced-filters-banner-count.test.tsx | 20 ++++++++ ...ork-orders-advanced-search-wiring.test.tsx | 47 ++++++++++++++++++ .../utils/advanced-filters-utils.test.ts | 48 +++++++++++++++++++ .../utils/board-query-params.test.ts | 22 +++++++++ .../utils/table-filter-utils.test.ts | 42 ++++++++++++++++ 13 files changed, 255 insertions(+), 21 deletions(-) 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..4b9bd266 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 @@ -7,7 +7,11 @@ import { useWorkOrdersTableDayView } from "@/app/(protected)/workorders/_hooks/u import { useWorkOrdersTableLocalState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-local-state"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import { pickBoardPager, type DayPageSize } from "@/domain/work-orders/utils/day-pagination"; -import { filterByBarAndSearch } from "@/domain/work-orders/utils/table-filter-utils"; +import { + filterByBarAndSearch, + resolveAdvSearchDisplayRows, + resolveAdvSearchScopeCount, +} from "@/domain/work-orders/utils/table-filter-utils"; export type { DragVizState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-local-state"; @@ -32,7 +36,11 @@ export function useWorkOrdersTableData(filters: FiltersReturn) { }); const { displayRows, ...localUi } = local; - const advResults = useMemo(() => (advActive ? displayRows : []), [advActive, displayRows]); + const unscheduledOnly = Boolean(filters.advApplied?.unscheduledOnly); + const advResults = useMemo( + () => resolveAdvSearchDisplayRows(displayRows, advActive, unscheduledOnly), + [advActive, displayRows, unscheduledOnly], + ); const filtered = useMemo(() => { if (advActive) { @@ -58,7 +66,7 @@ export function useWorkOrdersTableData(filters: FiltersReturn) { ); const scopeTotalCount = advActive - ? (data?.totalCount ?? advResults.length) + ? resolveAdvSearchScopeCount(unscheduledOnly, data?.totalCount, advResults.length) : dayView.dayTotalCount; useSyncOutOfRangeSearchPage({ enabled: 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/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..4ca5611f 100644 --- a/src/domain/work-orders/utils/board-query-params.ts +++ b/src/domain/work-orders/utils/board-query-params.ts @@ -159,6 +159,13 @@ function resolveAdvancedSearchDate(filters: WorkOrderAdvancedFilters): { dateFrom?: string; dateTo?: string; } { + if (filters.unscheduledOnly) { + return { + datePreset: "Custom", + dateFrom: ADVANCED_SEARCH_ALL_WEEKS_FROM, + dateTo: ADVANCED_SEARCH_ALL_WEEKS_TO, + }; + } if (filters.dateRange === EMPTY_ADVANCED_FILTERS.dateRange) { return { datePreset: "Custom", diff --git a/src/domain/work-orders/utils/table-filter-utils.ts b/src/domain/work-orders/utils/table-filter-utils.ts index 4f2a045b..33066f3e 100644 --- a/src/domain/work-orders/utils/table-filter-utils.ts +++ b/src/domain/work-orders/utils/table-filter-utils.ts @@ -37,6 +37,25 @@ export function matchesAdvancedFilters( return ADVANCED_FILTER_PREDICATES.every((predicate) => predicate(row, filters)); } +export function resolveAdvSearchDisplayRows( + rows: WorkOrderTableRow[], + advActive: boolean, + unscheduledOnly: boolean, +): WorkOrderTableRow[] { + if (!advActive) return []; + if (!unscheduledOnly) return rows; + return rows.filter((row) => !row.scheduledOn); +} + +export function resolveAdvSearchScopeCount( + unscheduledOnly: boolean, + backendTotal: number | undefined, + filteredLength: number, +): number { + if (unscheduledOnly) return filteredLength; + return backendTotal ?? filteredLength; +} + function rowMatchesSearch(row: WorkOrderTableRow, query: string): boolean { const q = query.trim().toLowerCase(); if (!q) { 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 5b1e50c8..019dbc41 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 @@ -341,6 +341,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 without opening From/To or sending Unscheduled", () => { + 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.datePreset).toBe("Custom"); + expect(params.dateFrom).toBe("2000-01-01"); + expect(params.dateTo).toBe("2099-12-31"); + expect(String(params.datePreset)).not.toBe("Unscheduled"); + }); + it("applies Aveta required only through Advanced Filters", () => { const view = renderWithProviders(, { route: "/workorders", 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..5ede0021 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 @@ -133,6 +133,28 @@ describe("toBoardSearchQueryParams", () => { expect(thisWeek.dateTo).toBe("2099-12-31"); }); + it("uses wide Custom dates when Unscheduled is on and never sends Unscheduled", () => { + const withDefaultRange = toBoardSearchQueryParams({ + filters: { ...EMPTY_ADVANCED_FILTERS, unscheduledOnly: true }, + }); + expect(withDefaultRange.datePreset).toBe("Custom"); + expect(withDefaultRange.dateFrom).toBe("2000-01-01"); + expect(withDefaultRange.dateTo).toBe("2099-12-31"); + expect(String(withDefaultRange.datePreset)).not.toBe("Unscheduled"); + + const overridingLastWeek = toBoardSearchQueryParams({ + filters: { + ...EMPTY_ADVANCED_FILTERS, + dateRange: "last-week", + unscheduledOnly: true, + }, + }); + expect(overridingLastWeek.datePreset).toBe("Custom"); + expect(overridingLastWeek.dateFrom).toBe("2000-01-01"); + expect(overridingLastWeek.dateTo).toBe("2099-12-31"); + expect(String(overridingLastWeek.datePreset)).not.toBe("Unscheduled"); + }); + it("maps Overdue-only advanced filter to overdue=true without types", () => { const params = toBoardSearchQueryParams({ filters: { 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..39e8468c 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 @@ -14,6 +14,8 @@ import { matchesAdvancedFilters, mergeWeeklyAndUnassignedQueue, partitionBoardRows, + resolveAdvSearchDisplayRows, + resolveAdvSearchScopeCount, splitScheduledUnscheduled, } from "@/domain/work-orders/utils/table-filter-utils"; import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils"; @@ -431,6 +433,46 @@ 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("resolveAdvSearchDisplayRows", () => { + it("filters advActive rows to !scheduledOn when the Unscheduled toggle is on", () => { + const dated = makeRow({ id: "dated", scheduledOn: "2026-06-23" }); + const undated = makeRow({ id: "undated", scheduledOn: "", targetWeek: "2026-07-06" }); + + expect(resolveAdvSearchDisplayRows([dated, undated], false, true)).toEqual([]); + expect(resolveAdvSearchDisplayRows([dated, undated], true, false)).toEqual([dated, undated]); + expect(resolveAdvSearchDisplayRows([dated, undated], true, true)).toEqual([undated]); + }); + + it("uses the filtered list length as banner count when Unscheduled is on", () => { + expect(resolveAdvSearchScopeCount(true, 150, 3)).toBe(3); + expect(resolveAdvSearchScopeCount(false, 150, 3)).toBe(150); + expect(resolveAdvSearchScopeCount(false, undefined, 3)).toBe(3); + }); }); describe("computeIsPastDue", () => {