feat(work-orders): send Overdue as its own work order type

Overdue maps to work order type 8 on create, inline correction, board
reads and the type filter, instead of the Other enum and the past-due
flag. It lists the PM service catalog and never sends a severity. Past
Due moves to its own toggle in the advanced filter Status section and
narrows the other filters.
This commit is contained in:
Alexandre Brandizzi 2026-09-25 11:06:21 -03:00
parent 358ec3e890
commit 8a1a12bfa1
17 changed files with 263 additions and 73 deletions

View file

@ -109,7 +109,11 @@ export function AdvancedSearchFilterSections({
/> />
</AdvSection> </AdvSection>
<AdvSection icon={CheckCircle2} label="Status" count={draft.statuses.size}> <AdvSection
icon={CheckCircle2}
label="Status"
count={draft.statuses.size + (draft.pastDue ? 1 : 0)}
>
{selectAllRow("statuses", [...ALL_WIZARD_STATUSES], draft.statuses.size)} {selectAllRow("statuses", [...ALL_WIZARD_STATUSES], draft.statuses.size)}
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{ALL_WIZARD_STATUSES.map((status) => ( {ALL_WIZARD_STATUSES.map((status) => (
@ -122,6 +126,14 @@ export function AdvancedSearchFilterSections({
onClick={() => toggleIn("statuses", status)} onClick={() => toggleIn("statuses", status)}
/> />
))} ))}
<AdvStatusPill
label="Past Due"
dotColor="var(--status-pastdue-text)"
selected={draft.pastDue}
selectedBg="var(--status-pastdue-bg)"
selectedText="var(--status-pastdue-text)"
onClick={() => setDraft((prev) => ({ ...prev, pastDue: !prev.pastDue }))}
/>
</div> </div>
</AdvSection> </AdvSection>

View file

@ -20,10 +20,13 @@ const SERVICE_WORK_ORDER_TYPE_API = {
Reactive: 6, Reactive: 6,
} as const; } as const;
const TYPE_TO_API: Record<Exclude<WOType, "Overdue">, number> = { // The registry is keyed on PM, Reactive and Emergency; Overdue is PM work the
// client never confirmed, so it lists the PM catalog.
const TYPE_TO_API: Record<WOType, number> = {
PM: WORK_ORDER_TYPE_API.PM, PM: WORK_ORDER_TYPE_API.PM,
Reactive: WORK_ORDER_TYPE_API.Reactive, Reactive: WORK_ORDER_TYPE_API.Reactive,
Emergency: WORK_ORDER_TYPE_API.Emergency, Emergency: WORK_ORDER_TYPE_API.Emergency,
Overdue: WORK_ORDER_TYPE_API.PM,
}; };
function toApiInput(input: ServiceInput) { function toApiInput(input: ServiceInput) {
@ -51,7 +54,6 @@ export const servicesApi = {
}, },
getActiveForWorkOrderType: async (workOrderType: WOType): Promise<Service[]> => { getActiveForWorkOrderType: async (workOrderType: WOType): Promise<Service[]> => {
if (workOrderType === "Overdue") return [];
const data = await apiGet<unknown>(API_PATHS.services.list, { const data = await apiGet<unknown>(API_PATHS.services.list, {
searchParams: { isActive: true, workOrderType: TYPE_TO_API[workOrderType] }, searchParams: { isActive: true, workOrderType: TYPE_TO_API[workOrderType] },
}); });

View file

@ -11,7 +11,6 @@ export function useServicesForWorkOrderType(
return useQuery({ return useQuery({
queryKey: queryKeys.services.byWorkOrderType(effectiveType), queryKey: queryKeys.services.byWorkOrderType(effectiveType),
queryFn: () => servicesApi.getActiveForWorkOrderType(effectiveType), queryFn: () => servicesApi.getActiveForWorkOrderType(effectiveType),
enabled: effectiveType !== "Overdue",
staleTime: 60 * 1000, staleTime: 60 * 1000,
}); });
} }

View file

@ -1,4 +1,5 @@
import type { WorkOrderBoardCreatePayload } from "@/domain/work-orders/types/work-order-board"; import type { WorkOrderBoardCreatePayload } from "@/domain/work-orders/types/work-order-board";
import { typeNeedsSeverity } from "@/domain/work-orders/types/work-order-severity";
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard"; import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
import { normalizeAdditionalContacts } from "@/domain/work-orders/mappers/additional-contacts-mapper"; import { normalizeAdditionalContacts } from "@/domain/work-orders/mappers/additional-contacts-mapper";
@ -64,7 +65,7 @@ export function assignWizardServicePocFields(
payload.vendorNotes = vendorNotes; payload.vendorNotes = vendorNotes;
} }
if (draft.severity != null) { if (draft.severity != null && typeNeedsSeverity(draft.type)) {
payload.severity = draft.severity; payload.severity = draft.severity;
} }

View file

@ -28,7 +28,7 @@ const WO_TYPE_TO_API: Record<WOType, WorkOrderTypeApi> = {
Reactive: WORK_ORDER_TYPE_API.Reactive, Reactive: WORK_ORDER_TYPE_API.Reactive,
Overdue: WORK_ORDER_TYPE_API.Other, Overdue: WORK_ORDER_TYPE_API.Overdue,
}; };
/** Leading token of a location label, e.g. `BK5` from `BK5 · Dallas` or `CHI-1042` from `CHI-1042 — Chicago`. */ /** Leading token of a location label, e.g. `BK5` from `BK5 · Dallas` or `CHI-1042` from `CHI-1042 — Chicago`. */

View file

@ -35,6 +35,8 @@ export interface WorkOrderAdvancedFilters {
dispatchers: Set<string>; dispatchers: Set<string>;
regions: Set<string>; regions: Set<string>;
statuses: Set<WizardWOStatus>; statuses: Set<WizardWOStatus>;
/** Past Due overlay (any type); narrows the other filters, independent of the Overdue type. */
pastDue: boolean;
rescheduled: boolean; rescheduled: boolean;
carriedOver: boolean; carriedOver: boolean;
addOn: boolean; addOn: boolean;
@ -59,6 +61,7 @@ export const EMPTY_ADVANCED_FILTERS: WorkOrderAdvancedFilters = {
dispatchers: new Set(), dispatchers: new Set(),
regions: new Set(), regions: new Set(),
statuses: new Set(), statuses: new Set(),
pastDue: false,
rescheduled: false, rescheduled: false,
carriedOver: false, carriedOver: false,
addOn: false, addOn: false,

View file

@ -17,6 +17,7 @@ export const WORK_ORDER_TYPE_API = {
Inspection: 5, Inspection: 5,
Reactive: 6, Reactive: 6,
AddOn: 7, AddOn: 7,
Overdue: 8,
Other: 99, Other: 99,
} as const; } as const;
@ -64,6 +65,7 @@ export const WORK_ORDER_TYPE_API_TO_FE: Record<number, WOType | ""> = {
[WORK_ORDER_TYPE_API.PM]: "PM", [WORK_ORDER_TYPE_API.PM]: "PM",
[WORK_ORDER_TYPE_API.Reactive]: "Reactive", [WORK_ORDER_TYPE_API.Reactive]: "Reactive",
[WORK_ORDER_TYPE_API.Emergency]: "Emergency", [WORK_ORDER_TYPE_API.Emergency]: "Emergency",
[WORK_ORDER_TYPE_API.Overdue]: "Overdue",
}; };
export const LIFECYCLE_STATUS_API_TO_FE: Record<number, WizardWOStatus | "Canceled"> = { export const LIFECYCLE_STATUS_API_TO_FE: Record<number, WizardWOStatus | "Canceled"> = {

View file

@ -192,6 +192,15 @@ export function appendStatusPmVendorDocChips(
clear: (current) => ({ ...current, statuses: new Set() }), clear: (current) => ({ ...current, statuses: new Set() }),
}); });
if (filters.pastDue) {
chips.push({
key: "pastdue",
group: "Status",
value: "Past Due",
clear: (current) => ({ ...current, pastDue: false }),
});
}
pushSetChip({ pushSetChip({
chips, chips,
key: "pm", key: "pm",

View file

@ -45,12 +45,10 @@ const matchesDateRange: AdvancedFilterPredicate = (row, filters) => {
const matchesSite: AdvancedFilterPredicate = (row, filters) => const matchesSite: AdvancedFilterPredicate = (row, filters) =>
!filters.sites.size || filters.sites.has(row.site); !filters.sites.size || filters.sites.has(row.site);
const matchesType: AdvancedFilterPredicate = (row, filters) => { const matchesType: AdvancedFilterPredicate = (row, filters) =>
if (!filters.types.size) return true; !filters.types.size || filters.types.has(row.type as never);
const overdueMatch = filters.types.has("Overdue") && row.isPastDue;
const typeMatch = filters.types.has(row.type as never); const matchesPastDue: AdvancedFilterPredicate = (row, filters) => !filters.pastDue || row.isPastDue;
return overdueMatch || typeMatch;
};
const matchesDispatcher: AdvancedFilterPredicate = (row, filters) => { const matchesDispatcher: AdvancedFilterPredicate = (row, filters) => {
if (!filters.dispatchers.size) return true; if (!filters.dispatchers.size) return true;
@ -95,6 +93,7 @@ export const ADVANCED_FILTER_PREDICATES: AdvancedFilterPredicate[] = [
matchesType, matchesType,
matchesDispatcher, matchesDispatcher,
matchesStatus, matchesStatus,
matchesPastDue,
matchesPmType, matchesPmType,
matchesVendorTech, matchesVendorTech,
matchesDoc, matchesDoc,

View file

@ -34,6 +34,7 @@ function cloneFilters(filters: WorkOrderAdvancedFilters): WorkOrderAdvancedFilte
flagColors: new Set(filters.flagColors ?? []), flagColors: new Set(filters.flagColors ?? []),
upliftStatuses: new Set(filters.upliftStatuses ?? []), upliftStatuses: new Set(filters.upliftStatuses ?? []),
unscheduledOnly: Boolean(filters.unscheduledOnly), unscheduledOnly: Boolean(filters.unscheduledOnly),
pastDue: Boolean(filters.pastDue),
}; };
} }
@ -45,6 +46,7 @@ const ADVANCED_FILTER_ACTIVE_SIGNALS = (filters: WorkOrderAdvancedFilters): bool
Boolean(filters.dispatchers?.size), Boolean(filters.dispatchers?.size),
Boolean(filters.regions?.size), Boolean(filters.regions?.size),
Boolean(filters.statuses?.size), Boolean(filters.statuses?.size),
Boolean(filters.pastDue),
filters.rescheduled, filters.rescheduled,
filters.carriedOver, filters.carriedOver,
filters.addOn, filters.addOn,

View file

@ -41,30 +41,19 @@ function mapDispatchers(ids: Iterable<string>): string[] | undefined {
return list.length > 0 ? list : undefined; return list.length > 0 ? list : undefined;
} }
/** Maps UI type filters to API `types` + separate `overdue` flag. Overdue is never sent as types=99. */ /** Maps UI type filters to API `types`. The Overdue type is sent as its own enum; past due uses `overdue`. */
function mapWoTypeFilters(types: Iterable<WOType | string>): { function mapWoTypeFilters(types: Iterable<WOType | string>): number[] | undefined {
types?: number[];
overdue?: boolean;
} {
const list: number[] = []; const list: number[] = [];
let overdue = false;
for (const type of types) { for (const type of types) {
if (type === "All") { if (type === "All") {
continue; continue;
} }
if (type === "Overdue") {
overdue = true;
continue;
}
const api = mapWizardTypeToApi(type as WOType); const api = mapWizardTypeToApi(type as WOType);
if (api != null) { if (api != null) {
list.push(api); list.push(api);
} }
} }
return { return list.length > 0 ? list : undefined;
types: list.length > 0 ? list : undefined,
overdue: overdue || undefined,
};
} }
function mapStatuses(statuses: Iterable<WizardWOStatus | string>): number[] | undefined { function mapStatuses(statuses: Iterable<WizardWOStatus | string>): number[] | undefined {
@ -139,14 +128,11 @@ export function toBoardQueryParams(
search: string, search: string,
): WorkOrderBoardQueryParams { ): WorkOrderBoardQueryParams {
const [, weekTo] = weekRange(bar.weekMonday); const [, weekTo] = weekRange(bar.weekMonday);
const typeFilters = bar.types.size > 0 ? mapWoTypeFilters(bar.types) : {};
return { return {
weekStart: bar.weekMonday, weekStart: bar.weekMonday,
weekEnd: weekTo, weekEnd: weekTo,
dispatchers: mapDispatchers(bar.dispatcherIds), dispatchers: mapDispatchers(bar.dispatcherIds),
types: typeFilters.types, types: mapWoTypeFilters(bar.types),
overdue: typeFilters.overdue,
search: search.trim().length >= 2 ? search.trim() : undefined, search: search.trim().length >= 2 ? search.trim() : undefined,
}; };
} }
@ -203,7 +189,6 @@ export function toBoardSearchQueryParams(input: {
BOARD_SEARCH_MAX_PAGE_SIZE, BOARD_SEARCH_MAX_PAGE_SIZE,
); );
const typeFilters = mapWoTypeFilters(filters.types);
const trimmedSearch = input.search?.trim(); const trimmedSearch = input.search?.trim();
const search = trimmedSearch && trimmedSearch.length >= 2 ? trimmedSearch : undefined; const search = trimmedSearch && trimmedSearch.length >= 2 ? trimmedSearch : undefined;
@ -216,8 +201,8 @@ export function toBoardSearchQueryParams(input: {
dateTo: date.dateTo, dateTo: date.dateTo,
includeDateless: date.includeDateless, includeDateless: date.includeDateless,
sites: filters.sites.size > 0 ? [...filters.sites] : undefined, sites: filters.sites.size > 0 ? [...filters.sites] : undefined,
types: typeFilters.types, types: mapWoTypeFilters(filters.types),
overdue: typeFilters.overdue, overdue: filters.pastDue || undefined,
dispatchers: mapDispatchers(filters.dispatchers), dispatchers: mapDispatchers(filters.dispatchers),
regions: filters.regions.size > 0 ? [...filters.regions] : undefined, regions: filters.regions.size > 0 ? [...filters.regions] : undefined,
statuses: mapStatuses(filters.statuses), statuses: mapStatuses(filters.statuses),
@ -252,10 +237,6 @@ export function buildBoardSearchParamsRecord(
return out; return out;
} }
export function woTypeFilterIsOverdue(type: string): boolean {
return type === "Overdue";
}
/** Week-independent GET /board/search params for the pinned Unassigned queue. */ /** Week-independent GET /board/search params for the pinned Unassigned queue. */
export function toUnassignedQueueQueryParams( export function toUnassignedQueueQueryParams(
page = 0, page = 0,

View file

@ -65,12 +65,7 @@ function rowMatchesSearch(row: WorkOrderTableRow, query: string): boolean {
} }
function rowMatchesBarTypes(row: WorkOrderTableRow, types: Set<WOType>): boolean { function rowMatchesBarTypes(row: WorkOrderTableRow, types: Set<WOType>): boolean {
if (types.size === 0) { return types.size === 0 || types.has(row.type as WOType);
return true;
}
const overdueMatch = types.has("Overdue") && row.isPastDue;
const typeMatch = types.has(row.type as WOType);
return overdueMatch || typeMatch;
} }
export function filterByBarAndSearch( export function filterByBarAndSearch(

View file

@ -0,0 +1,154 @@
import { fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { SlideOverCompletionTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab";
import { AdvancedSearchFilterSections } from "@/app/(protected)/workorders/_components/list/advanced-search/adv-filter-sections";
import { TypeCell } from "@/app/(protected)/workorders/_components/list/table/cells/type-cell";
import { WizardStepTypeSchedule } from "@/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule";
import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-table-row-mapper";
import { mapWizardDraftToBoardCreatePayload } from "@/domain/work-orders/mappers/work-order-board-mapper";
import {
EMPTY_ADVANCED_FILTERS,
type WorkOrderAdvancedFilters,
} from "@/domain/work-orders/types/work-order-advanced-filters";
import { WORK_ORDER_TYPE_API } from "@/domain/work-orders/types/work-order-board";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import {
canContinueWizardStep1,
EMPTY_WIZARD_DRAFT,
type WorkOrderWizardDraft,
} from "@/domain/work-orders/types/work-order-wizard";
import { buildAdvancedFilterChips } from "@/domain/work-orders/utils/advanced-filters-utils";
import { getClosabilityGaps } from "@/domain/work-orders/utils/wo-closability";
const USERS = [{ id: "sarah", name: "Sarah Chen" }];
describe("Overdue work order type", () => {
it("offers Overdue in the Create WO wizard and never asks for a severity", () => {
const onPatch = vi.fn();
const { rerender } = render(
<WizardStepTypeSchedule
draft={{ ...EMPTY_WIZARD_DRAFT, type: "PM" }}
users={USERS}
onUpdate={vi.fn()}
onPatch={onPatch}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /No client response — due date passed/ }));
expect(onPatch).toHaveBeenCalledWith(
expect.objectContaining({ type: "Overdue", severity: null }),
);
const overdueDraft: WorkOrderWizardDraft = { ...EMPTY_WIZARD_DRAFT, type: "Overdue" };
rerender(
<WizardStepTypeSchedule
draft={overdueDraft}
users={USERS}
onUpdate={vi.fn()}
onPatch={onPatch}
/>,
);
expect(screen.queryByText(/Severity \(from APM\)/)).not.toBeInTheDocument();
expect(canContinueWizardStep1(overdueDraft)).toBe(true);
});
it("creates the work order with the Overdue enum and no severity", () => {
const payload = mapWizardDraftToBoardCreatePayload({
...EMPTY_WIZARD_DRAFT,
type: "Overdue",
severity: 2,
locationId: "1",
siteCode: "BK5",
});
expect(payload.workOrderType).toBe(WORK_ORDER_TYPE_API.Overdue);
expect(payload.severity ?? null).toBeNull();
});
it("corrects a type inline to Overdue without opening the severity dialog", async () => {
const onSave = vi.fn();
const user = userEvent.setup();
render(<TypeCell value="Reactive" severity={3} onSave={onSave} />);
await user.click(screen.getByRole("button", { name: "Type of WO, Reactive" }));
await user.click(screen.getByRole("button", { name: /^Overdue$/ }));
expect(onSave).toHaveBeenCalledWith("Overdue", null);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("reads the Overdue enum back from the board as the Overdue type", () => {
const row = mapWorkOrderTableRow({ id: 1, workOrderType: WORK_ORDER_TYPE_API.Overdue });
expect(row.type).toBe("Overdue");
});
it("closes with the completion document, not photos", () => {
const base = {
id: "1",
dispatcherId: "sarah",
dispatcherName: "Sarah Chen",
company: "Gateway Plumbing",
vendorId: "",
tech: "",
completedDate: "2026-09-24",
woNumber: "24971",
pm: "Backflow Preventers",
mediaCount: 0,
};
expect(
getClosabilityGaps({ ...base, type: "Overdue" as const, docStatus: "No" as const }),
).toEqual(["Completion document"]);
expect(
getClosabilityGaps({ ...base, type: "Overdue" as const, docStatus: "Yes" as const }),
).toEqual([]);
const row = {
id: "1",
type: "Overdue",
pm: "Backflow Preventers",
docStatus: "No",
rowVersion: "1",
};
render(
<SlideOverCompletionTab
row={row as WorkOrderTableRow}
isReadOnly={false}
media={[]}
onOpenCompletionDoc={vi.fn()}
/>,
);
expect(screen.getByText("Completion Document")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /open completion document/i })).toBeEnabled();
});
it("keeps a separate Past Due status toggle in the advanced filters", () => {
let draft: WorkOrderAdvancedFilters = { ...EMPTY_ADVANCED_FILTERS };
const setDraft = vi.fn(
(update: (prev: WorkOrderAdvancedFilters) => WorkOrderAdvancedFilters) => {
draft = update(draft);
},
);
render(
<AdvancedSearchFilterSections
draft={draft}
setDraft={setDraft as never}
toggleIn={vi.fn()}
setMany={vi.fn()}
sites={[]}
users={[]}
pmTypes={[]}
vendorTechs={[]}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /^Status/ }));
fireEvent.click(screen.getByRole("button", { name: /Past Due/ }));
expect(draft.pastDue).toBe(true);
expect(draft.types.size).toBe(0);
expect(buildAdvancedFilterChips(draft)).toContainEqual(
expect.objectContaining({ group: "Status", value: "Past Due" }),
);
});
});

View file

@ -78,11 +78,14 @@ describe("servicesApi", () => {
}); });
}); });
it("does not request services for the unsupported Overdue type", async () => { it("lists the PM catalog for the Overdue type", async () => {
const services = await servicesApi.getActiveForWorkOrderType("Overdue"); vi.mocked(apiGet).mockResolvedValue([]);
expect(apiGet).not.toHaveBeenCalled(); await servicesApi.getActiveForWorkOrderType("Overdue");
expect(services).toEqual([]);
expect(apiGet).toHaveBeenCalledWith(API_PATHS.services.list, {
searchParams: { isActive: true, workOrderType: 2 },
});
}); });
it("creates, updates, and deactivates through the registry routes", async () => { it("creates, updates, and deactivates through the registry routes", async () => {

View file

@ -36,7 +36,9 @@ describe("mapWizardTypeToApi", () => {
expect(mapWizardTypeToApi("Reactive")).toBe(WORK_ORDER_TYPE_API.Reactive); expect(mapWizardTypeToApi("Reactive")).toBe(WORK_ORDER_TYPE_API.Reactive);
expect(mapWizardTypeToApi("Overdue")).toBe(WORK_ORDER_TYPE_API.Other); expect(mapWizardTypeToApi("Overdue")).toBe(WORK_ORDER_TYPE_API.Overdue);
expect(WORK_ORDER_TYPE_API.Overdue).toBe(8);
expect(mapWizardTypeToApi("")).toBeNull(); expect(mapWizardTypeToApi("")).toBeNull();
}); });

View file

@ -60,16 +60,16 @@ describe("toBoardQueryParams", () => {
expect(params.overdue).toBeUndefined(); expect(params.overdue).toBeUndefined();
}); });
it("maps Overdue bar filter to overdue=true without types", () => { it("maps the Overdue bar filter to the Overdue type, not the past-due flag", () => {
const params = toBoardQueryParams({ ...baseBar, types: new Set(["Overdue"]) }, ""); const params = toBoardQueryParams({ ...baseBar, types: new Set(["Overdue"]) }, "");
expect(params.overdue).toBe(true); expect(params.types).toEqual([WORK_ORDER_TYPE_API.Overdue]);
expect(params.types).toBeUndefined(); expect(params.overdue).toBeUndefined();
}); });
it("maps Overdue with real types as OR (types + overdue)", () => { it("maps Overdue with other types as a plain type union", () => {
const params = toBoardQueryParams({ ...baseBar, types: new Set(["PM", "Overdue"]) }, ""); const params = toBoardQueryParams({ ...baseBar, types: new Set(["PM", "Overdue"]) }, "");
expect(params.types).toEqual([WORK_ORDER_TYPE_API.PM]); expect(params.types).toEqual([WORK_ORDER_TYPE_API.PM, WORK_ORDER_TYPE_API.Overdue]);
expect(params.overdue).toBe(true); expect(params.overdue).toBeUndefined();
}); });
it("omits types when the set is empty", () => { it("omits types when the set is empty", () => {
@ -222,22 +222,23 @@ describe("toBoardSearchQueryParams", () => {
expect(buildBoardSearchParamsRecord(params)).not.toHaveProperty("unscheduledOnly"); expect(buildBoardSearchParamsRecord(params)).not.toHaveProperty("unscheduledOnly");
}); });
it("maps Overdue-only advanced filter to overdue=true without types", () => { it("maps the Overdue type advanced filter to types without the past-due flag", () => {
const params = toBoardSearchQueryParams({ const params = toBoardSearchQueryParams({
filters: { filters: {
...EMPTY_ADVANCED_FILTERS, ...EMPTY_ADVANCED_FILTERS,
types: new Set(["Overdue"]), types: new Set(["Overdue"]),
}, },
}); });
expect(params.overdue).toBe(true); expect(params.types).toEqual([WORK_ORDER_TYPE_API.Overdue]);
expect(params.types).toBeUndefined(); expect(params.overdue).toBeUndefined();
}); });
it("maps Overdue with real types as OR (types + overdue)", () => { it("maps the Past Due status toggle to overdue=true alongside types", () => {
const params = toBoardSearchQueryParams({ const params = toBoardSearchQueryParams({
filters: { filters: {
...EMPTY_ADVANCED_FILTERS, ...EMPTY_ADVANCED_FILTERS,
types: new Set(["PM", "Overdue"]), types: new Set(["PM"]),
pastDue: true,
}, },
}); });
expect(params.types).toEqual([WORK_ORDER_TYPE_API.PM]); expect(params.types).toEqual([WORK_ORDER_TYPE_API.PM]);

View file

@ -172,26 +172,26 @@ describe("filterByBarAndSearch", () => {
expect(ids).toEqual(["em", "rx"]); expect(ids).toEqual(["em", "rx"]);
}); });
it("matches Overdue via isPastDue even when row.type is not Overdue", () => { it("matches the Overdue type only, never a past-due row of another type", () => {
const pastDuePm = makeRow({ const pastDuePm = makeRow({
id: "pd", id: "pd",
type: "PM", type: "PM",
isPastDue: true, isPastDue: true,
scheduledOn: "2026-06-03", scheduledOn: "2026-06-03",
}); });
const currentPm = makeRow({ const overdue = makeRow({
id: "now", id: "ov",
type: "PM", type: "Overdue",
isPastDue: false, isPastDue: false,
scheduledOn: "2026-06-03", scheduledOn: "2026-06-03",
}); });
const ids = filterByBarAndSearch( const ids = filterByBarAndSearch(
[pastDuePm, currentPm], [pastDuePm, overdue],
bar(weekMonday, { types: new Set(["Overdue"]) }), bar(weekMonday, { types: new Set(["Overdue"]) }),
"", "",
).map((row) => row.id); ).map((row) => row.id);
expect(ids).toEqual(["pd"]); expect(ids).toEqual(["ov"]);
}); });
it("hides a row after type correction when the bar filter no longer matches", () => { it("hides a row after type correction when the bar filter no longer matches", () => {
@ -395,13 +395,8 @@ describe("matchesAdvancedFilters", () => {
expect(matchesAdvancedFilters(row, noMatch)).toBe(false); expect(matchesAdvancedFilters(row, noMatch)).toBe(false);
}); });
it("matches Overdue when isPastDue is true", () => { it("matches the Overdue type filter by type, independent of isPastDue", () => {
const weekMonday = mondayOf(todayIso()); const weekMonday = mondayOf(todayIso());
const pastDue = makeRow({
scheduledOn: weekMonday,
...deriveDayFields(weekMonday),
isPastDue: true,
});
const filters = { const filters = {
...EMPTY_ADVANCED_FILTERS, ...EMPTY_ADVANCED_FILTERS,
dateRange: "custom" as const, dateRange: "custom" as const,
@ -409,9 +404,39 @@ describe("matchesAdvancedFilters", () => {
customTo: "9999-12-31", customTo: "9999-12-31",
types: new Set(["Overdue" as const]), types: new Set(["Overdue" as const]),
}; };
expect(matchesAdvancedFilters(pastDue, filters)).toBe(true); const overdue = makeRow({ type: "Overdue", isPastDue: false, scheduledOn: weekMonday });
const pastDuePm = makeRow({ type: "PM", isPastDue: true, scheduledOn: weekMonday });
expect(matchesAdvancedFilters(overdue, filters)).toBe(true);
expect(matchesAdvancedFilters(pastDuePm, filters)).toBe(false);
});
it("narrows to past-due rows with the Past Due status toggle", () => {
const weekMonday = mondayOf(todayIso());
const filters = {
...EMPTY_ADVANCED_FILTERS,
dateRange: "custom" as const,
customFrom: "0000-01-01",
customTo: "9999-12-31",
types: new Set(["PM" as const]),
pastDue: true,
};
expect( expect(
matchesAdvancedFilters(makeRow({ isPastDue: false, scheduledOn: weekMonday }), filters), matchesAdvancedFilters(
makeRow({ type: "PM", isPastDue: true, scheduledOn: weekMonday }),
filters,
),
).toBe(true);
expect(
matchesAdvancedFilters(
makeRow({ type: "PM", isPastDue: false, scheduledOn: weekMonday }),
filters,
),
).toBe(false);
expect(
matchesAdvancedFilters(
makeRow({ type: "Overdue", isPastDue: true, scheduledOn: weekMonday }),
filters,
),
).toBe(false); ).toBe(false);
}); });