feat(work-orders): add SH-244 advanced filter fields and AAP indicator styles

This commit is contained in:
Arthur Bassi 2026-08-20 18:34:29 -03:00
parent 19a54e38c6
commit 93ab14abf3
8 changed files with 411 additions and 41 deletions

View file

@ -0,0 +1,135 @@
import { expect, test, type Page, type Route } from "@playwright/test";
const FROZEN_NOW = "2026-08-19T15:00:00.000Z";
const TEST_USER = {
token: "wo-adv-filters-e2e-token",
expiration: "2030-01-01T00:00:00.000Z",
email: "admin@seahavenind.com",
userRoles: "Admin",
phoneNumber: "5551234567",
fullname: "Work Order E2E Admin",
id: "1",
};
async function fulfillJson(route: Route, body: unknown, status = 200) {
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
}
function boardRow(id: number, woNumber: string, scheduledDate: string | null) {
return {
id,
woNumber,
workOrderType: 2,
siteCode: woNumber,
lifecycleStatusLabel: "Scheduled",
scheduledDate,
dispatcherId: "1",
dispatcherName: "Pat Dispatcher",
pm: "HVAC",
};
}
async function mockWorkOrderApis(page: Page, searchUrls: string[]) {
await page.route("**/api/**", (route) => {
const pathname = new URL(route.request().url()).pathname;
if (!pathname.startsWith("/api/")) {
return route.fallback();
}
if (pathname.includes("/workorders/board") || pathname.includes("DropdownOptions")) {
return route.fallback();
}
return fulfillJson(route, { data: [] });
});
await page.route("**/api/**/DropdownOptions/ByCategory/**", (route) => {
const pathname = new URL(route.request().url()).pathname;
if (pathname.toLowerCase().includes("problem")) {
return fulfillJson(route, { data: [{ id: 1, value: "HVAC", category: "Problem" }] });
}
return fulfillJson(route, { data: [] });
});
await page.route("**/api/workorders/board*", (route) => {
if (route.request().url().includes("board/search")) {
return route.fallback();
}
return fulfillJson(route, {
weekStart: "2026-08-17",
weekEnd: "2026-08-21",
counts: { returned: 1, total: 1 },
unscheduled: [],
scheduled: [boardRow(1, "ADV-WO", "2026-08-19")],
});
});
await page.route("**/api/workorders/board/search*", (route) => {
searchUrls.push(route.request().url());
return fulfillJson(route, { items: [], totalCount: 0, page: 0, pageSize: 50 });
});
}
async function signInAndOpenWorkOrders(page: Page) {
await page.clock.install({ time: new Date(FROZEN_NOW) });
await page.clock.resume();
await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER);
}
async function applyAdvancedFilters(page: Page) {
await page.getByRole("button", { name: "Advanced Filters" }).click();
const sheet = page.getByRole("dialog", { name: "Advanced Filters" });
await expect(sheet).toBeVisible();
return sheet;
}
test.describe("Work Orders advanced filters (SH-244)", () => {
test("sends carriedOver on board/search and shows empty copy", async ({ page }) => {
const searchUrls: string[] = [];
await signInAndOpenWorkOrders(page);
await mockWorkOrderApis(page, searchUrls);
await page.goto("/workorders");
await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible({
timeout: 15_000,
});
const sheet = await applyAdvancedFilters(page);
await sheet.getByRole("button", { name: /Indicators/i }).click();
await sheet.getByRole("button", { name: /Carried Over ≥ 2/i }).click();
await sheet.getByRole("button", { name: /apply filters/i }).click();
await expect.poll(() => searchUrls.some((url) => url.includes("carriedOver=true"))).toBe(true);
await expect(page.getByText("No work orders match these filters")).toBeVisible();
});
test("sends combined SH-244 facets on the same board/search request", async ({ page }) => {
const searchUrls: string[] = [];
await signInAndOpenWorkOrders(page);
await mockWorkOrderApis(page, searchUrls);
await page.goto("/workorders");
await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible({
timeout: 15_000,
});
const sheet = await applyAdvancedFilters(page);
await sheet.getByRole("button", { name: /Severity/i }).click();
await sheet.getByRole("button", { name: /SEV 1/i }).click();
await sheet.getByRole("button", { name: /Indicators/i }).click();
await sheet.getByRole("button", { name: /^Add-On$/i }).click();
await sheet.getByRole("button", { name: /^Service/i }).click();
await sheet.getByText("HVAC").click();
await sheet.getByRole("button", { name: /Completion doc/i }).click();
await sheet.getByRole("button", { name: /^Pending$/i }).click();
await sheet.getByRole("button", { name: /apply filters/i }).click();
await expect
.poll(() => {
const url = searchUrls[searchUrls.length - 1] ?? "";
return (
url.includes("severities=1") &&
url.includes("addOn=true") &&
url.includes("pmTypes=HVAC") &&
(url.includes("docStatuses=2") || url.includes("DocStatuses=2"))
);
})
.toBe(true);
});
});

View file

@ -13,10 +13,11 @@ import {
import { AdvDocPill, AdvStatusPill } from "./adv-filter-pill";
import { AdvDateRangeSection } from "./adv-date-range-section";
import { AdvMultiSelect } from "./adv-multi-select";
import { AdvParityFilterSections } from "./adv-parity-filter-sections";
import { AdvFlagInternalUpliftSections, AdvIndicatorsSection } from "./adv-parity-filter-sections";
import { AdvSection } from "./adv-section";
import { AdvSeverityList } from "./adv-severity-list";
import { AdvTypeGrid } from "./adv-type-grid";
import { PmTypeIcon } from "@/app/(protected)/workorders/_components/list/table/pm-type-icons";
import { SelectAllAction } from "@/components/ui/select-all-action";
import {
ALL_WIZARD_STATUSES,
@ -124,20 +125,18 @@ export function AdvancedSearchFilterSections({
</div>
</AdvSection>
<AdvParityFilterSections
draft={draft}
setDraft={setDraft}
toggleIn={toggleIn}
setMany={setMany}
/>
<AdvIndicatorsSection draft={draft} setDraft={setDraft} />
<AdvSection icon={Flame} label="PM type" count={draft.pmTypes.size}>
<AdvSection icon={Flame} label="Service" count={draft.pmTypes.size}>
<AdvMultiSelect
options={pmTypes.map((pmType) => ({ value: pmType, label: pmType }))}
selected={draft.pmTypes}
onToggle={(value) => toggleIn("pmTypes", value)}
onBulkToggle={(values, select) => setMany("pmTypes", values, select)}
placeholder="Search pm type…"
placeholder="Search service…"
renderLeft={(pmType) => (
<PmTypeIcon pmType={pmType} size={14} className="shrink-0 text-muted-foreground" />
)}
/>
</AdvSection>
@ -164,6 +163,13 @@ export function AdvancedSearchFilterSections({
))}
</div>
</AdvSection>
<AdvFlagInternalUpliftSections
draft={draft}
setDraft={setDraft}
toggleIn={toggleIn}
setMany={setMany}
/>
</div>
);
}

View file

@ -1,7 +1,7 @@
import type { Dispatch, SetStateAction } from "react";
import { Check, DollarSign, Flag, Hash, RefreshCw } from "lucide-react";
import type { Dispatch, ReactNode, SetStateAction } from "react";
import { ArrowRight, Check, DollarSign, Flag, Hash, Plus, RefreshCw } from "lucide-react";
import { Checkbox } from "@mui/material";
import { AdvDocPill, AdvFilterPill } from "./adv-filter-pill";
import { AdvDocPill } from "./adv-filter-pill";
import { AdvMultiSelect } from "./adv-multi-select";
import { AdvSection } from "./adv-section";
import { FLAG_COLORS } from "@/domain/work-orders/flag-colors";
@ -15,9 +15,12 @@ import {
type UpliftFilterStatus,
} from "@/domain/work-orders/types/work-order-uplift-filter";
type AdvParityFilterSectionsProps = {
type DraftProps = {
draft: WorkOrderAdvancedFilters;
setDraft: Dispatch<SetStateAction<WorkOrderAdvancedFilters>>;
};
type AdvFlagInternalUpliftSectionsProps = DraftProps & {
toggleIn: (key: AdvancedFilterSetKey, value: string) => void;
setMany: (key: AdvancedFilterSetKey, values: string[], select: boolean) => void;
};
@ -35,12 +38,88 @@ function FlagColorDot({ value }: { value: string }) {
);
}
export function AdvParityFilterSections({
type IndicatorKind = "rescheduled" | "carriedOver" | "addOn";
const INDICATOR_SELECTED: Record<IndicatorKind, { background: string; color: string }> = {
rescheduled: { background: "#f5f3ff", color: "#7c3aed" },
carriedOver: { background: "var(--color-bg-muted)", color: "var(--color-text-primary)" },
addOn: { background: "var(--type-addon-bg)", color: "var(--type-addon-text)" },
};
function IndicatorPill({
selected,
onClick,
kind,
children,
}: {
selected: boolean;
onClick: () => void;
kind: IndicatorKind;
children: ReactNode;
}) {
const selectedLook = INDICATOR_SELECTED[kind];
return (
<button
type="button"
onClick={onClick}
data-indicator={kind}
data-selected={selected ? "true" : "false"}
className="inline-flex items-center gap-1.5"
style={{
height: 26,
padding: "0 9px",
borderRadius: 9999,
border: `1px solid ${selected ? "var(--color-primary)" : "var(--color-border)"}`,
background: selected ? selectedLook.background : "var(--color-bg-surface)",
color: selected ? selectedLook.color : "var(--color-text-secondary)",
fontSize: 12,
fontWeight: 500,
}}
>
{children}
</button>
);
}
export function AdvIndicatorsSection({ draft, setDraft }: DraftProps) {
return (
<AdvSection icon={RefreshCw} label="Indicators" count={indicatorCount(draft)}>
<div className="flex flex-wrap gap-1.5">
<IndicatorPill
kind="rescheduled"
selected={draft.rescheduled}
onClick={() => setDraft((prev) => ({ ...prev, rescheduled: !prev.rescheduled }))}
>
<RefreshCw size={12} className="shrink-0" style={{ color: "#7c3aed" }} />
Rescheduled ≥ 2
</IndicatorPill>
<IndicatorPill
kind="carriedOver"
selected={draft.carriedOver}
onClick={() => setDraft((prev) => ({ ...prev, carriedOver: !prev.carriedOver }))}
>
<ArrowRight size={12} className="shrink-0" style={{ color: "var(--color-text-muted)" }} />
Carried Over ≥ 2
</IndicatorPill>
<IndicatorPill
kind="addOn"
selected={draft.addOn}
onClick={() => setDraft((prev) => ({ ...prev, addOn: !prev.addOn }))}
>
<Plus size={12} className="shrink-0" style={{ color: "var(--type-addon-text)" }} />
Add-On
</IndicatorPill>
</div>
</AdvSection>
);
}
export function AdvFlagInternalUpliftSections({
draft,
setDraft,
toggleIn,
setMany,
}: AdvParityFilterSectionsProps) {
}: AdvFlagInternalUpliftSectionsProps) {
const toggleUpliftStatus = (status: UpliftFilterStatus) => {
setDraft((prev) => {
const next = new Set(prev.upliftStatuses);
@ -55,29 +134,6 @@ export function AdvParityFilterSections({
return (
<>
<AdvSection icon={RefreshCw} label="Indicators" count={indicatorCount(draft)}>
<div className="flex flex-wrap gap-1.5">
<AdvFilterPill
label="Rescheduled ≥ 2"
selected={draft.rescheduled}
onClick={() => setDraft((prev) => ({ ...prev, rescheduled: !prev.rescheduled }))}
className="inline-flex items-center gap-1.5"
/>
<AdvFilterPill
label="Carried Over ≥ 2"
selected={draft.carriedOver}
onClick={() => setDraft((prev) => ({ ...prev, carriedOver: !prev.carriedOver }))}
className="inline-flex items-center gap-1.5"
/>
<AdvFilterPill
label="Add-On"
selected={draft.addOn}
onClick={() => setDraft((prev) => ({ ...prev, addOn: !prev.addOn }))}
className="inline-flex items-center gap-1.5"
/>
</div>
</AdvSection>
<AdvSection icon={Flag} label="Flag" count={draft.flagColors.size}>
<AdvMultiSelect
options={FLAG_COLORS.map((color) => ({ value: color.value, label: color.name }))}

View file

@ -178,7 +178,7 @@ export function appendStatusPmVendorDocChips(
pushSetChip({
chips,
key: "pm",
group: "PM type",
group: "Service",
set: filters.pmTypes,
nameOf: (v) => v,
clear: (current) => ({ ...current, pmTypes: new Set() }),

View file

@ -120,4 +120,33 @@ describe("WoTable search empty vs Unassigned queue (SH-165/SH-241)", () => {
expect(queryByText("No work orders match your search")).not.toBeInTheDocument();
expect(getByText("queue unavailable")).toBeInTheDocument();
});
it("shows empty advanced-filter copy with Adjust filters", () => {
const onOpenAdvancedFilters = vi.fn();
const { getByText, getByRole } = renderWithProviders(
<WoTable
tableData={tableData({
advActive: true,
advResults: [],
searchActive: false,
noSearchResults: false,
})}
users={[]}
sites={[]}
vendors={[]}
pmTypes={[]}
weekMonday="2026-05-18"
onNavigateToDay={vi.fn()}
onOpenSlideOver={vi.fn()}
onOpenMedia={vi.fn()}
onOpenAdvancedFilters={onOpenAdvancedFilters}
onPatch={vi.fn()}
/>,
{ withAuth: false },
);
expect(getByText("No work orders match these filters")).toBeInTheDocument();
getByRole("button", { name: "Adjust filters" }).click();
expect(onOpenAdvancedFilters).toHaveBeenCalled();
});
});

View file

@ -140,7 +140,7 @@ describe("WorkOrders Advanced Search page wiring (SH-121)", () => {
expect(within(sheet).getByRole("button", { name: /apply filters/i })).toBeInTheDocument();
// Expand filter sections that start collapsed and assert option inputs are wired.
for (const label of ["Site", "Assigned to", "PM type", "Vendor / tech"]) {
for (const label of ["Site", "Assigned to", "Service", "Vendor / tech"]) {
fireEvent.click(within(sheet).getByRole("button", { name: new RegExp(label, "i") }));
}
expect(within(sheet).getByText("Seattle")).toBeInTheDocument();
@ -200,6 +200,40 @@ describe("WorkOrders Advanced Search page wiring (SH-121)", () => {
expect(params.dateTo).toBe("2099-12-31");
});
it("applies Service and Completion doc through the public Advanced Filters sheet", () => {
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: /^Service/i }));
fireEvent.click(within(sheet).getByText("HVAC"));
fireEvent.click(within(sheet).getByRole("button", { name: /Completion doc/i }));
fireEvent.click(within(sheet).getByRole("button", { name: /^Pending$/i }));
fireEvent.click(within(sheet).getByRole("button", { name: /^Uploaded$/i }));
fireEvent.click(within(sheet).getByRole("button", { name: /^N\/N$/i }));
fireEvent.click(within(sheet).getByRole("button", { name: /apply filters/i }));
expect(applyAdvancedFilters).toHaveBeenCalled();
const appliedCalls = applyAdvancedFilters.mock.calls;
const applied = appliedCalls[appliedCalls.length - 1]?.[0] as typeof EMPTY_ADVANCED_FILTERS;
expect(applied.pmTypes.has("HVAC")).toBe(true);
expect(applied.docs.has("No")).toBe(true);
expect(applied.docs.has("Yes")).toBe(true);
expect(applied.docs.has("NN")).toBe(true);
const params = toBoardSearchQueryParams({ filters: applied });
expect(params.pmTypes).toEqual(["HVAC"]);
expect(params.docStatuses).toEqual([2, 1, 3]);
});
it("applies Indicators, Flag, and Internal WO# through the public Advanced Filters sheet", () => {
const view = renderWithProviders(<WorkOrdersListPage />, {
route: "/workorders",
@ -216,6 +250,18 @@ describe("WorkOrders Advanced Search page wiring (SH-121)", () => {
fireEvent.click(within(sheet).getByRole("button", { name: /Carried Over ≥ 2/i }));
fireEvent.click(within(sheet).getByRole("button", { name: /^Add-On$/i }));
const rescheduled = within(sheet).getByRole("button", { name: /Rescheduled ≥ 2/i });
const carriedOver = within(sheet).getByRole("button", { name: /Carried Over ≥ 2/i });
const addOn = within(sheet).getByRole("button", { name: /^Add-On$/i });
expect(rescheduled).toHaveAttribute("data-selected", "true");
expect(carriedOver).toHaveAttribute("data-selected", "true");
expect(addOn).toHaveAttribute("data-selected", "true");
expect(rescheduled.style.background).toBe("rgb(245, 243, 255)");
expect(rescheduled.style.color).toBe("rgb(124, 58, 237)");
expect(carriedOver.style.background).toBe("var(--color-bg-muted)");
expect(addOn.style.background).toBe("var(--type-addon-bg)");
expect(addOn.style.color).toBe("var(--type-addon-text)");
fireEvent.click(within(sheet).getByRole("button", { name: /^Flag$/i }));
fireEvent.click(within(sheet).getByRole("button", { name: /^Red$/i }));

View file

@ -120,6 +120,24 @@ describe("clearAdvancedFilterGroup", () => {
expect(chips.some((c) => c.key === "uplift" && c.value === "Pending")).toBe(true);
});
it("builds chips for service, carried over, add-on, and completion doc", () => {
const filters = {
...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS),
pmTypes: new Set(["HVAC"]),
carriedOver: true,
addOn: true,
docs: new Set(["No" as const, "Yes" as const]),
};
const chips = buildAdvancedFilterChips(filters);
expect(chips.some((c) => c.key === "pm" && c.group === "Service" && c.value === "HVAC")).toBe(
true,
);
expect(chips.some((c) => c.key === "carriedover" && c.value === "Carried Over")).toBe(true);
expect(chips.some((c) => c.key === "addon" && c.value === "Add-On")).toBe(true);
expect(chips.some((c) => c.key === "doc" && c.group === "Comp doc")).toBe(true);
});
it("clears uplift chip and drops hasUplift with statuses", () => {
const filters = {
...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS),

View file

@ -1,7 +1,11 @@
import { describe, expect, it } from "vitest";
import { ASSIGNEE_FILTER_UNASSIGNED } from "@/domain/work-orders/types/work-order-list";
import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters";
import { WORK_ORDER_TYPE_API } from "@/domain/work-orders/types/work-order-board";
import {
DOC_STATUS_API,
LIFECYCLE_STATUS_API,
WORK_ORDER_TYPE_API,
} from "@/domain/work-orders/types/work-order-board";
import {
BOARD_SEARCH_MAX_PAGE_SIZE,
buildBoardSearchParamsRecord,
@ -163,6 +167,82 @@ describe("toBoardSearchQueryParams", () => {
expect(params.upliftStatuses).toBeUndefined();
});
it("maps service types and completion docs onto board search params", () => {
const params = toBoardSearchQueryParams({
filters: {
...EMPTY_ADVANCED_FILTERS,
pmTypes: new Set(["HVAC", "Electrical"]),
docs: new Set(["No", "Yes", "NN"]),
},
});
expect(params.pmTypes).toEqual(["HVAC", "Electrical"]);
expect(params.docStatuses).toEqual([
DOC_STATUS_API.No,
DOC_STATUS_API.Yes,
DOC_STATUS_API.NotNeeded,
]);
});
it("maps each SH-244 facet alone onto board search params (AND-ready query)", () => {
const severity = toBoardSearchQueryParams({
filters: { ...EMPTY_ADVANCED_FILTERS, severities: new Set([4]) },
});
expect(severity.severities).toEqual([4]);
expect(severity.carriedOver).toBeUndefined();
expect(severity.addOn).toBeUndefined();
expect(severity.pmTypes).toBeUndefined();
expect(severity.docStatuses).toBeUndefined();
const carried = toBoardSearchQueryParams({
filters: { ...EMPTY_ADVANCED_FILTERS, carriedOver: true },
});
expect(carried.carriedOver).toBe(true);
expect(carried.severities).toBeUndefined();
const addOn = toBoardSearchQueryParams({
filters: { ...EMPTY_ADVANCED_FILTERS, addOn: true },
});
expect(addOn.addOn).toBe(true);
expect(addOn.carriedOver).toBeUndefined();
const service = toBoardSearchQueryParams({
filters: { ...EMPTY_ADVANCED_FILTERS, pmTypes: new Set(["HVAC"]) },
});
expect(service.pmTypes).toEqual(["HVAC"]);
const docs = toBoardSearchQueryParams({
filters: { ...EMPTY_ADVANCED_FILTERS, docs: new Set(["NN"]) },
});
expect(docs.docStatuses).toEqual([DOC_STATUS_API.NotNeeded]);
});
it("combines SH-244 facets with a SH-121 status facet on the same request", () => {
const params = toBoardSearchQueryParams({
filters: {
...EMPTY_ADVANCED_FILTERS,
severities: new Set([1]),
carriedOver: true,
addOn: true,
pmTypes: new Set(["HVAC"]),
docs: new Set(["Yes"]),
statuses: new Set(["Scheduled"]),
},
});
expect(params.severities).toEqual([1]);
expect(params.carriedOver).toBe(true);
expect(params.addOn).toBe(true);
expect(params.pmTypes).toEqual(["HVAC"]);
expect(params.docStatuses).toEqual([DOC_STATUS_API.Yes]);
expect(params.statuses).toEqual([LIFECYCLE_STATUS_API.Scheduled]);
const record = buildBoardSearchParamsRecord(params);
expect(record.severities).toEqual([1]);
expect(record.carriedOver).toBe(true);
expect(record.addOn).toBe(true);
expect(record.pmTypes).toEqual(["HVAC"]);
expect(record.docStatuses).toEqual([DOC_STATUS_API.Yes]);
expect(record.statuses).toEqual([LIFECYCLE_STATUS_API.Scheduled]);
});
it("maps severity and uplift facets onto board search params", () => {
const params = toBoardSearchQueryParams({
filters: {