diff --git a/src/app/(protected)/workorders/_components/list/advanced-search/adv-filter-pill.tsx b/src/app/(protected)/workorders/_components/list/advanced-search/adv-filter-pill.tsx new file mode 100644 index 00000000..5b7c3394 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/advanced-search/adv-filter-pill.tsx @@ -0,0 +1,78 @@ +import { DotTogglePill } from "@/components/ui/dot-toggle-pill"; + +import { TogglePill } from "@/components/ui/toggle-pill"; + +import { ToggleSegment } from "@/components/ui/toggle-segment"; + +type AdvFilterPillProps = { + label: string; + + selected: boolean; + + onClick: () => void; + + className?: string; +}; + +export function AdvFilterPill({ label, selected, onClick, className }: AdvFilterPillProps) { + return ( + + {label} + + ); +} + +type AdvStatusPillProps = { + label: string; + + dotColor: string; + + selected: boolean; + + selectedBg?: string; + + selectedText?: string; + + onClick: () => void; +}; + +export function AdvStatusPill({ + label, + + dotColor, + + selected, + + selectedBg, + + selectedText, + + onClick, +}: AdvStatusPillProps) { + return ( + + ); +} + +type AdvDocPillProps = { + label: string; + + selected: boolean; + + onClick: () => void; +}; + +export function AdvDocPill({ label, selected, onClick }: AdvDocPillProps) { + return ( + + {label} + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/advanced-search/adv-multi-select.tsx b/src/app/(protected)/workorders/_components/list/advanced-search/adv-multi-select.tsx new file mode 100644 index 00000000..f9f877aa --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/advanced-search/adv-multi-select.tsx @@ -0,0 +1,100 @@ +import { useState, type ReactNode } from "react"; + +import { CheckboxListItem } from "@/components/ui/checkbox-list-item"; + +import { SearchInput } from "@/components/ui/search-input"; + +import { SelectAllAction } from "@/components/ui/select-all-action"; + +type AdvMultiSelectOption = { + value: string; + + label: string; +}; + +type AdvMultiSelectProps = { + options: AdvMultiSelectOption[]; + + selected: Set; + + onToggle: (value: string) => void; + + onBulkToggle?: (values: string[], select: boolean) => void; + + placeholder: string; + + renderLeft?: (value: string) => ReactNode; +}; + +export function AdvMultiSelect({ + options, + + selected, + + onToggle, + + onBulkToggle, + + placeholder, + + renderLeft, +}: AdvMultiSelectProps) { + const [query, setQuery] = useState(""); + + const list = query + ? options.filter((option) => option.label.toLowerCase().includes(query.toLowerCase())) + : options; + + const anySelected = selected.size > 0; + + return ( +
+ + +
+ + {selected.size} of {options.length} selected + + + + anySelected + ? onBulkToggle?.( + options.map((option) => option.value), + + false, + ) + : onBulkToggle?.( + list.map((option) => option.value), + + true, + ) + } + /> +
+ +
+ {list.map((option) => ( + onToggle(option.value)} + /> + ))} + + {list.length === 0 && ( +
No matches
+ )} +
+
+ ); +} diff --git a/src/app/(protected)/workorders/_components/list/advanced-search/adv-section.tsx b/src/app/(protected)/workorders/_components/list/advanced-search/adv-section.tsx new file mode 100644 index 00000000..f7195508 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/advanced-search/adv-section.tsx @@ -0,0 +1,19 @@ +import type { ElementType, ReactNode } from "react"; + +import { CollapsibleSection } from "@/components/ui/collapsible-section"; + +type AdvSectionProps = { + icon?: ElementType; + + label: string; + + count?: number; + + defaultOpen?: boolean; + + children: ReactNode; +}; + +export function AdvSection(props: AdvSectionProps) { + return ; +} diff --git a/src/app/(protected)/workorders/_components/list/advanced-search/adv-type-grid.tsx b/src/app/(protected)/workorders/_components/list/advanced-search/adv-type-grid.tsx new file mode 100644 index 00000000..b1762731 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/advanced-search/adv-type-grid.tsx @@ -0,0 +1,43 @@ +import { SelectAllAction } from "@/components/ui/select-all-action"; + +import { ToggleCard } from "@/components/ui/toggle-card"; + +import { + WO_TYPE_DESCRIPTIONS, + WO_TYPES, +} from "@/domain/work-orders/types/work-order-advanced-filters"; + +import type { WOType } from "@/domain/work-orders/types/work-order-wizard"; + +type AdvTypeGridProps = { + selected: Set; + + onToggle: (type: WOType) => void; + + onSelectAll: (types: WOType[], select: boolean) => void; +}; + +export function AdvTypeGrid({ selected, onToggle, onSelectAll }: AdvTypeGridProps) { + return ( + <> +
+ 0} + onClick={() => onSelectAll([...WO_TYPES], selected.size === 0)} + /> +
+ +
+ {WO_TYPES.map((type) => ( + onToggle(type)} + /> + ))} +
+ + ); +} diff --git a/src/app/(protected)/workorders/_components/list/advanced-search/work-orders-advanced-search-sheet.tsx b/src/app/(protected)/workorders/_components/list/advanced-search/work-orders-advanced-search-sheet.tsx new file mode 100644 index 00000000..06a3b283 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/advanced-search/work-orders-advanced-search-sheet.tsx @@ -0,0 +1,273 @@ +import { useEffect, useState } from "react"; +import { + CalendarDays, + CheckCircle2, + FileText, + Flame, + MapPin, + SlidersHorizontal, + Tag, + Truck, + UserX, + Users, +} from "lucide-react"; +import { AdvDocPill, AdvFilterPill, AdvStatusPill } from "./adv-filter-pill"; +import { AdvMultiSelect } from "./adv-multi-select"; +import { AdvSection } from "./adv-section"; +import { AdvTypeGrid } from "./adv-type-grid"; +import { Button } from "@/components/ui/button"; +import { DateField } from "@/components/ui/date-input"; +import { IconCloseButton } from "@/components/ui/icon-close-button"; +import { SelectAllAction } from "@/components/ui/select-all-action"; +import { SideSheet } from "@/components/ui/side-sheet"; +import { + ALL_WIZARD_STATUSES, + ASSIGNEE_FILTER_UNASSIGNED, + COMPLETION_DOC_OPTIONS, + DATE_RANGE_OPTIONS, + DOC_FILTER_LABELS, + EMPTY_ADVANCED_FILTERS, + type AdvancedFilterSetKey, + type WorkOrderAdvancedFilters, +} from "@/domain/work-orders/types/work-order-advanced-filters"; +import type { WorkOrderUser } from "@/domain/work-orders/types/work-order"; +import { WIZARD_STATUS_DOTS } from "@/domain/work-orders/types/work-order-wizard"; +import { + cloneAdvancedFilters, + countAdvancedFilters, + isDefaultDateRange, +} from "@/domain/work-orders/utils/advanced-filters-utils"; + +type WorkOrdersAdvancedSearchSheetProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + initial: WorkOrderAdvancedFilters; + onApply: (filters: WorkOrderAdvancedFilters) => void; + onClearAll: () => void; + sites: { id: string | number; name: string }[]; + users: WorkOrderUser[]; + pmTypes: string[]; + vendorTechs: string[]; +}; + +export function WorkOrdersAdvancedSearchSheet({ + open, + onOpenChange, + initial, + onApply, + onClearAll, + sites, + users, + pmTypes, + vendorTechs, +}: WorkOrdersAdvancedSearchSheetProps) { + const [draft, setDraft] = useState(initial); + + useEffect(() => { + if (open) { + setDraft(cloneAdvancedFilters(initial)); + } + }, [open, initial]); + + const activeCount = countAdvancedFilters(draft); + + const toggleIn = (key: AdvancedFilterSetKey, value: string) => { + setDraft((prev) => { + const next = new Set(prev[key] as Set); + if (next.has(value)) { + next.delete(value); + } else { + next.add(value); + } + return { ...prev, [key]: next }; + }); + }; + + const setMany = (key: AdvancedFilterSetKey, values: string[], select: boolean) => { + setDraft((prev) => { + const next = new Set(prev[key] as Set); + values.forEach((value) => (select ? next.add(value) : next.delete(value))); + return { ...prev, [key]: next }; + }); + }; + + const selectAllRow = (key: AdvancedFilterSetKey, all: string[], selectedSize: number) => ( +
+ 0} + onClick={() => setMany(key, all, selectedSize === 0)} + /> +
+ ); + + const handleClearAll = () => { + setDraft(cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS)); + onClearAll(); + }; + + const handleApply = () => { + onApply(cloneAdvancedFilters(draft)); + onOpenChange(false); + }; + + return ( + onOpenChange(false)} aria-label="Advanced Filters"> + Advanced Filters + +
+
+ + + + Advanced Filters + + + {activeCount > 0 && ( + · {activeCount} active + )} +
+ onOpenChange(false)} /> +
+ +
+ +
+ {DATE_RANGE_OPTIONS.map((option) => ( + setDraft((prev) => ({ ...prev, dateRange: option.key }))} + /> + ))} +
+ {draft.dateRange === "custom" && ( +
+ setDraft((prev) => ({ ...prev, customFrom: value }))} + /> + setDraft((prev) => ({ ...prev, customTo: value }))} + /> +
+ )} +
+ + + ({ + value: String(site.id), + label: site.name, + }))} + selected={draft.sites} + onToggle={(value) => toggleIn("sites", value)} + onBulkToggle={(values, select) => setMany("sites", values, select)} + placeholder="Search site…" + /> + + + + toggleIn("types", type)} + onSelectAll={(types, select) => setMany("types", types, select)} + /> + + + + ({ value: String(user.id), label: user.name })), + ]} + selected={draft.dispatchers} + onToggle={(value) => toggleIn("dispatchers", value)} + onBulkToggle={(values, select) => setMany("dispatchers", values, select)} + placeholder="Search assigned to…" + renderLeft={(id) => + id === ASSIGNEE_FILTER_UNASSIGNED ? ( + + ) : ( + + ) + } + /> + + + + {selectAllRow("statuses", [...ALL_WIZARD_STATUSES], draft.statuses.size)} +
+ {ALL_WIZARD_STATUSES.map((status) => ( + toggleIn("statuses", status)} + /> + ))} +
+
+ + + ({ value: pmType, label: pmType }))} + selected={draft.pmTypes} + onToggle={(value) => toggleIn("pmTypes", value)} + onBulkToggle={(values, select) => setMany("pmTypes", values, select)} + placeholder="Search pm type…" + /> + + + + ({ value: name, label: name }))} + selected={draft.vendorTechs} + onToggle={(value) => toggleIn("vendorTechs", value)} + onBulkToggle={(values, select) => setMany("vendorTechs", values, select)} + placeholder="Search vendor / tech…" + /> + + + + {selectAllRow("docs", [...COMPLETION_DOC_OPTIONS], draft.docs.size)} +
+ {COMPLETION_DOC_OPTIONS.map((doc) => ( + toggleIn("docs", doc)} + /> + ))} +
+
+
+ +
+ +
+ {activeCount > 0 && ( + + {activeCount} filter set + + )} +
+ +
+
+ ); +} diff --git a/src/app/(protected)/workorders/index.tsx b/src/app/(protected)/workorders/index.tsx index 66eecc87..5f28f6d2 100644 --- a/src/app/(protected)/workorders/index.tsx +++ b/src/app/(protected)/workorders/index.tsx @@ -3,6 +3,7 @@ import { Box } from "@mui/material"; import { useNavigate } from "react-router"; import { PageHeader } from "@/components/ui/page-header"; import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal"; +import { WorkOrdersAdvancedSearchSheet } from "@/app/(protected)/workorders/_components/list/advanced-search/work-orders-advanced-search-sheet"; import { WorkOrdersAdvancedFiltersBanner } from "@/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner"; import { WorkOrdersFilterBar } from "@/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar"; import { WorkOrdersListHeader } from "@/app/(protected)/workorders/_components/list/work-orders-list-header"; @@ -44,6 +45,11 @@ export default function WorkOrdersListPage() { [problemOptions], ); + const vendorTechs = useMemo( + () => [...new Set(vendors.map((vendor) => vendor.companyName).filter(Boolean))], + [vendors], + ); + const totalCount = tableData.totalCount; const scopeTotalCount = tableData.scopeTotalCount; @@ -62,6 +68,7 @@ export default function WorkOrdersListPage() { filters.setAdvOpen(true)} activeFilterCount={filters.activeFilterCount} isFetching={tableData.isFetching && !tableData.isLoading} /> @@ -104,6 +111,19 @@ export default function WorkOrdersListPage() { }} onOpenMedia={setMediaRow} onClearSearch={() => filters.handleSearchChange("")} + onOpenAdvancedFilters={() => filters.setAdvOpen(true)} + /> + + {activeMediaRow != null && ( 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 new file mode 100644 index 00000000..ab94481a --- /dev/null +++ b/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx @@ -0,0 +1,154 @@ +import { fireEvent, screen, within } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import WorkOrdersListPage from "@/app/(protected)/workorders/index"; +import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters"; +import { renderWithProviders } from "@/test/test-utils"; + +const setAdvOpen = vi.fn(); +const applyAdvancedFilters = vi.fn(); +const clearAdvancedFilters = vi.fn(); +let advOpen = false; +let advApplied: typeof EMPTY_ADVANCED_FILTERS | null = null; + +vi.mock("react-router", async () => { + const actual = await vi.importActual("react-router"); + return { + ...actual, + useNavigate: () => vi.fn(), + }; +}); + +vi.mock("@/providers/auth-context", () => ({ + useAuthContext: () => ({ user: { id: "u1" } }), +})); + +vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-list-filters", () => ({ + useWorkOrdersListFilters: () => ({ + search: "", + handleSearchChange: vi.fn(), + advOpen, + setAdvOpen: (open: boolean) => { + advOpen = open; + setAdvOpen(open); + }, + advApplied, + advInitial: advApplied ?? EMPTY_ADVANCED_FILTERS, + applyAdvancedFilters: (filters: typeof EMPTY_ADVANCED_FILTERS) => { + advApplied = filters; + applyAdvancedFilters(filters); + }, + clearAdvancedFilters: () => { + advApplied = null; + clearAdvancedFilters(); + }, + clearAdvancedFilterChip: vi.fn(), + activeFilterCount: advApplied ? 1 : 0, + weekMonday: "2026-07-20", + setWeekMonday: vi.fn(), + dispatcherIds: new Set(), + setDispatcherIds: vi.fn(), + typeFilter: "All", + setTypeFilter: vi.fn(), + }), +})); + +vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-table-data", () => ({ + useWorkOrdersTableData: () => ({ + rows: [], + totalCount: 0, + scopeTotalCount: 0, + isFetching: false, + isLoading: false, + setSelectedId: vi.fn(), + patchRow: vi.fn(), + }), +})); + +vi.mock("@/domain/work-orders/use-cases/use-work-order-table-mutations", () => ({ + useWorkOrderTableMutations: () => ({ patchField: vi.fn() }), +})); + +vi.mock("@/domain/work-orders/use-cases/use-work-orders-list", () => ({ + useWorkOrderUsers: () => ({ + data: [{ id: "u1", name: "Dispatcher One" }], + }), +})); + +vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({ + useWorkOrderLocations: () => ({ + data: [{ id: "loc-1", name: "Seattle" }], + }), + useWorkOrderVendors: () => ({ + data: [{ companyName: "Vendor Co", email: "real@vendor.com" }], + }), +})); + +vi.mock("@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category", () => ({ + useDropdownOptionsByCategory: () => ({ data: [{ value: "HVAC" }] }), +})); + +vi.mock("@/domain/work-orders/mocks/is-wo-table-mock-enabled", () => ({ + isWoTableMockEnabled: () => false, +})); + +vi.mock("@/app/(protected)/workorders/_components/list/table/wo-table", () => ({ + WoTable: () => null, +})); + +vi.mock("@/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar", () => ({ + WorkOrdersFilterBar: () => null, +})); + +vi.mock("@/app/(protected)/workorders/_components/media/media-upload-modal", () => ({ + MediaUploadModal: () => null, +})); + +describe("WorkOrders Advanced Search page wiring (SH-121)", () => { + beforeEach(() => { + advOpen = false; + advApplied = null; + setAdvOpen.mockClear(); + applyAdvancedFilters.mockClear(); + clearAdvancedFilters.mockClear(); + }); + + it("opens the sheet from Advanced Filters, applies, then clears", () => { + const view = renderWithProviders(, { + route: "/workorders", + withAuth: false, + }); + + expect(screen.queryByRole("button", { name: /apply filters/i })).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Advanced Filters" })); + expect(setAdvOpen).toHaveBeenCalledWith(true); + + advOpen = true; + view.rerender(); + + const sheet = screen.getByRole("dialog", { name: "Advanced Filters" }); + 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"]) { + fireEvent.click(within(sheet).getByRole("button", { name: new RegExp(label, "i") })); + } + expect(within(sheet).getByText("Seattle")).toBeInTheDocument(); + expect(within(sheet).getByText("Dispatcher One")).toBeInTheDocument(); + expect(within(sheet).getByText("HVAC")).toBeInTheDocument(); + expect(within(sheet).getByText("Vendor Co")).toBeInTheDocument(); + + fireEvent.click(within(sheet).getByRole("button", { name: /apply filters/i })); + expect(applyAdvancedFilters).toHaveBeenCalled(); + expect(setAdvOpen).toHaveBeenCalledWith(false); + + advOpen = true; + view.rerender(); + fireEvent.click( + within(screen.getByRole("dialog", { name: "Advanced Filters" })).getByRole("button", { + name: /clear all/i, + }), + ); + expect(clearAdvancedFilters).toHaveBeenCalled(); + }); +}); diff --git a/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx b/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx index effb2edc..1dcae791 100644 --- a/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx +++ b/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx @@ -25,10 +25,24 @@ vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-list-filters", () = search: "", handleSearchChange: vi.fn(), setAdvOpen: vi.fn(), - activeFilterCount: 0, + advOpen: false, advApplied: null, - clearAdvancedFilterChip: vi.fn(), + advInitial: { + dateRange: "this-week", + customFrom: "", + customTo: "", + sites: new Set(), + types: new Set(), + dispatchers: new Set(), + statuses: new Set(), + pmTypes: new Set(), + vendorTechs: new Set(), + docs: new Set(), + }, + applyAdvancedFilters: vi.fn(), clearAdvancedFilters: vi.fn(), + clearAdvancedFilterChip: vi.fn(), + activeFilterCount: 0, weekMonday: "2026-07-20", setWeekMonday: vi.fn(), dispatcherIds: new Set(), @@ -165,13 +179,12 @@ describe("WorkOrdersListHeader slice affordances", () => { }); describe("WorkOrdersListPage board-core public interface", () => { - it("does not render New WO or Advanced Filters, and View navigates to detail", () => { + it("does not render New WO, and View navigates to detail", () => { navigate.mockReset(); renderWithProviders(, { route: "/workorders", withAuth: false }); expect(screen.queryByRole("button", { name: /new wo/i })).not.toBeInTheDocument(); - expect(screen.queryByLabelText("Advanced Filters")).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: /view doc|draft/i })).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: /view canceled row/i }));