fix(work-orders): add Unscheduled option to Advanced Filters Date range

This commit is contained in:
Arthur Bassi 2026-09-11 14:30:51 -03:00
parent b24e6f3b9a
commit 5aae933e1b
13 changed files with 255 additions and 21 deletions

View file

@ -14,13 +14,13 @@ type AdvDateRangeSectionProps = {
setDraft: Dispatch<SetStateAction<WorkOrderAdvancedFilters>>;
};
function dateRangeSectionCount(draft: WorkOrderAdvancedFilters): number {
return (isDefaultDateRange(draft.dateRange) ? 0 : 1) + (draft.unscheduledOnly ? 1 : 0);
}
export function AdvDateRangeSection({ draft, setDraft }: AdvDateRangeSectionProps) {
return (
<AdvSection
icon={CalendarDays}
label="Date range"
count={isDefaultDateRange(draft.dateRange) ? 0 : 1}
>
<AdvSection icon={CalendarDays} label="Date range" count={dateRangeSectionCount(draft)}>
<div className="flex flex-wrap gap-1.5">
{DATE_RANGE_OPTIONS.map((option) => (
<AdvFilterPill
@ -30,6 +30,11 @@ export function AdvDateRangeSection({ draft, setDraft }: AdvDateRangeSectionProp
onClick={() => setDraft((prev) => ({ ...prev, dateRange: option.key }))}
/>
))}
<AdvFilterPill
label="Unscheduled"
selected={draft.unscheduledOnly}
onClick={() => setDraft((prev) => ({ ...prev, unscheduledOnly: !prev.unscheduledOnly }))}
/>
</div>
{draft.dateRange === "custom" && (
<div className="mt-2.5 flex items-center gap-2">

View file

@ -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,

View file

@ -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<string>;
types: Set<WOType>;
severities: Set<WorkOrderSeverity>;
@ -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(),

View file

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

View file

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

View file

@ -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 {

View file

@ -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",

View file

@ -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) {

View file

@ -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(
<WorkOrdersAdvancedFiltersBanner
applied={{
...EMPTY_ADVANCED_FILTERS,
unscheduledOnly: true,
}}
totalCount={3}
sites={[]}
users={[]}
onClearChip={() => undefined}
onClearAll={() => undefined}
/>,
);
expect(screen.getByText("Date")).toBeInTheDocument();
expect(screen.getByText("Unscheduled")).toBeInTheDocument();
expect(screen.getByText("3 work orders")).toBeInTheDocument();
});
});

View file

@ -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(<WorkOrdersListPage />, {
route: "/workorders",
withAuth: false,
});
fireEvent.click(screen.getByRole("button", { name: "Advanced Filters" }));
advOpen = true;
view.rerender(<WorkOrdersListPage />);
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(<WorkOrdersListPage />, {
route: "/workorders",

View file

@ -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),

View file

@ -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: {

View file

@ -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", () => {