diff --git a/e2e/work-orders/advanced-filters-sh244.spec.ts b/e2e/work-orders/advanced-filters-sh244.spec.ts new file mode 100644 index 00000000..fe71ffec --- /dev/null +++ b/e2e/work-orders/advanced-filters-sh244.spec.ts @@ -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); + }); +}); diff --git a/src/app/(protected)/workorders/_components/list/advanced-search/adv-filter-sections.tsx b/src/app/(protected)/workorders/_components/list/advanced-search/adv-filter-sections.tsx index 88ba6ca3..b21120b7 100644 --- a/src/app/(protected)/workorders/_components/list/advanced-search/adv-filter-sections.tsx +++ b/src/app/(protected)/workorders/_components/list/advanced-search/adv-filter-sections.tsx @@ -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({ - + - + ({ 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) => ( + + )} /> @@ -164,6 +163,13 @@ export function AdvancedSearchFilterSections({ ))} + + ); } diff --git a/src/app/(protected)/workorders/_components/list/advanced-search/adv-parity-filter-sections.tsx b/src/app/(protected)/workorders/_components/list/advanced-search/adv-parity-filter-sections.tsx index 559004c8..199cfcb8 100644 --- a/src/app/(protected)/workorders/_components/list/advanced-search/adv-parity-filter-sections.tsx +++ b/src/app/(protected)/workorders/_components/list/advanced-search/adv-parity-filter-sections.tsx @@ -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>; +}; + +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 = { + 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 ( + + ); +} + +export function AdvIndicatorsSection({ draft, setDraft }: DraftProps) { + return ( + +
+ setDraft((prev) => ({ ...prev, rescheduled: !prev.rescheduled }))} + > + + Rescheduled ≥ 2 + + setDraft((prev) => ({ ...prev, carriedOver: !prev.carriedOver }))} + > + + Carried Over ≥ 2 + + setDraft((prev) => ({ ...prev, addOn: !prev.addOn }))} + > + + Add-On + +
+
+ ); +} + +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 ( <> - -
- setDraft((prev) => ({ ...prev, rescheduled: !prev.rescheduled }))} - className="inline-flex items-center gap-1.5" - /> - setDraft((prev) => ({ ...prev, carriedOver: !prev.carriedOver }))} - className="inline-flex items-center gap-1.5" - /> - setDraft((prev) => ({ ...prev, addOn: !prev.addOn }))} - className="inline-flex items-center gap-1.5" - /> -
-
- ({ value: color.value, label: color.name }))} 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 8150c9d2..c43d5d55 100644 --- a/src/domain/work-orders/utils/advanced-filter-chip-builders.ts +++ b/src/domain/work-orders/utils/advanced-filter-chip-builders.ts @@ -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() }), diff --git a/src/test/app/(protected)/workorders/wo-table-search-empty.test.tsx b/src/test/app/(protected)/workorders/wo-table-search-empty.test.tsx index db7e5685..23dbc11b 100644 --- a/src/test/app/(protected)/workorders/wo-table-search-empty.test.tsx +++ b/src/test/app/(protected)/workorders/wo-table-search-empty.test.tsx @@ -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( + , + { withAuth: false }, + ); + + expect(getByText("No work orders match these filters")).toBeInTheDocument(); + getByRole("button", { name: "Adjust filters" }).click(); + expect(onOpenAdvancedFilters).toHaveBeenCalled(); + }); }); 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 a8e0c15e..e1339afd 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 @@ -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(, { + 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: /^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(, { 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 })); 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 c4f351fa..0e5126fd 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 @@ -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), 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 5d80c1b4..2167cfcb 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 @@ -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: {