feat(work-orders): add board advanced search UI (#42)

* feat(work-orders): add board advanced search UI

* fix(work-orders): wire Advanced Search sheet and cover SH-121 flow

* style(work-orders): format board fixture

---------

Co-authored-by: Arthur Bassi <arthur.winiarski.ranger@outlook.com>
Co-authored-by: Alexandre Brandizzi <alex_brandizzi@hotmail.com>
This commit is contained in:
Arthur Bassi 2026-07-24 17:59:47 -03:00 • committed by GitHub
parent 4337cc662b
commit 52e77dc197
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 704 additions and 4 deletions

View file

@ -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 (
<TogglePill selected={selected} onClick={onClick} className={className}>
{label}
</TogglePill>
);
}
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 (
<DotTogglePill
label={label}
dotColor={dotColor}
selected={selected}
selectedBg={selectedBg}
selectedText={selectedText}
onClick={onClick}
/>
);
}
type AdvDocPillProps = {
label: string;
selected: boolean;
onClick: () => void;
};
export function AdvDocPill({ label, selected, onClick }: AdvDocPillProps) {
return (
<ToggleSegment selected={selected} onClick={onClick}>
{label}
</ToggleSegment>
);
}

View file

@ -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<string>;
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 (
<div>
<SearchInput
value={query}
onChange={setQuery}
placeholder={placeholder}
fullWidth
className="mb-2"
/>
<div className="mb-1.5 flex items-center justify-between">
<span className="text-[11px] text-muted-foreground">
{selected.size} of {options.length} selected
</span>
<SelectAllAction
anySelected={anySelected}
onClick={() =>
anySelected
? onBulkToggle?.(
options.map((option) => option.value),
false,
)
: onBulkToggle?.(
list.map((option) => option.value),
true,
)
}
/>
</div>
<div className="max-h-48 overflow-y-auto rounded-lg border border-border p-1">
{list.map((option) => (
<CheckboxListItem
key={option.value}
selected={selected.has(option.value)}
label={option.label}
left={renderLeft?.(option.value)}
onClick={() => onToggle(option.value)}
/>
))}
{list.length === 0 && (
<div className="px-2 py-3 text-center text-xs text-muted-foreground">No matches</div>
)}
</div>
</div>
);
}

View file

@ -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 <CollapsibleSection {...props} />;
}

View file

@ -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<WOType>;
onToggle: (type: WOType) => void;
onSelectAll: (types: WOType[], select: boolean) => void;
};
export function AdvTypeGrid({ selected, onToggle, onSelectAll }: AdvTypeGridProps) {
return (
<>
<div className="mb-2 flex justify-end">
<SelectAllAction
anySelected={selected.size > 0}
onClick={() => onSelectAll([...WO_TYPES], selected.size === 0)}
/>
</div>
<div className="grid grid-cols-2 gap-2">
{WO_TYPES.map((type) => (
<ToggleCard
key={type}
selected={selected.has(type)}
title={type}
description={WO_TYPE_DESCRIPTIONS[type]}
onClick={() => onToggle(type)}
/>
))}
</div>
</>
);
}

View file

@ -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<WorkOrderAdvancedFilters>(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<string>);
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<string>);
values.forEach((value) => (select ? next.add(value) : next.delete(value)));
return { ...prev, [key]: next };
});
};
const selectAllRow = (key: AdvancedFilterSetKey, all: string[], selectedSize: number) => (
<div className="mb-2 flex justify-end">
<SelectAllAction
anySelected={selectedSize > 0}
onClick={() => setMany(key, all, selectedSize === 0)}
/>
</div>
);
const handleClearAll = () => {
setDraft(cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS));
onClearAll();
};
const handleApply = () => {
onApply(cloneAdvancedFilters(draft));
onOpenChange(false);
};
return (
<SideSheet open={open} onClose={() => onOpenChange(false)} aria-label="Advanced Filters">
<span className="sr-only">Advanced Filters</span>
<div className="flex h-14 shrink-0 items-center justify-between border-b border-border px-4">
<div className="flex items-baseline gap-2">
<span className="flex items-center gap-2">
<SlidersHorizontal size={16} className="text-primary" />
<span className="font-display text-[15px] font-bold text-foreground">
Advanced Filters
</span>
</span>
{activeCount > 0 && (
<span className="text-xs text-muted-foreground">· {activeCount} active</span>
)}
</div>
<IconCloseButton onClick={() => onOpenChange(false)} />
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-4">
<AdvSection
icon={CalendarDays}
label="Date range"
count={isDefaultDateRange(draft.dateRange) ? 0 : 1}
>
<div className="flex flex-wrap gap-1.5">
{DATE_RANGE_OPTIONS.map((option) => (
<AdvFilterPill
key={option.key}
label={option.label}
selected={draft.dateRange === option.key}
onClick={() => setDraft((prev) => ({ ...prev, dateRange: option.key }))}
/>
))}
</div>
{draft.dateRange === "custom" && (
<div className="mt-2.5 flex items-center gap-2">
<DateField
label="From"
value={draft.customFrom}
onChange={(value) => setDraft((prev) => ({ ...prev, customFrom: value }))}
/>
<DateField
label="To"
value={draft.customTo}
onChange={(value) => setDraft((prev) => ({ ...prev, customTo: value }))}
/>
</div>
)}
</AdvSection>
<AdvSection icon={MapPin} label="Site" count={draft.sites.size}>
<AdvMultiSelect
options={sites.map((site) => ({
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…"
/>
</AdvSection>
<AdvSection icon={Tag} label="Type of WO" count={draft.types.size}>
<AdvTypeGrid
selected={draft.types}
onToggle={(type) => toggleIn("types", type)}
onSelectAll={(types, select) => setMany("types", types, select)}
/>
</AdvSection>
<AdvSection icon={Users} label="Assigned to" count={draft.dispatchers.size}>
<AdvMultiSelect
options={[
{ value: ASSIGNEE_FILTER_UNASSIGNED, label: "Unassigned" },
...users.map((user) => ({ 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 ? (
<UserX size={14} className="shrink-0 text-muted-foreground" />
) : (
<span className="size-2 shrink-0 rounded-full bg-muted-foreground" />
)
}
/>
</AdvSection>
<AdvSection icon={CheckCircle2} label="Status" count={draft.statuses.size}>
{selectAllRow("statuses", [...ALL_WIZARD_STATUSES], draft.statuses.size)}
<div className="flex flex-wrap gap-1.5">
{ALL_WIZARD_STATUSES.map((status) => (
<AdvStatusPill
key={status}
label={status}
dotColor={WIZARD_STATUS_DOTS[status]}
selected={draft.statuses.has(status)}
selectedBg="var(--color-primary-tint)"
onClick={() => toggleIn("statuses", status)}
/>
))}
</div>
</AdvSection>
<AdvSection icon={Flame} label="PM type" 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…"
/>
</AdvSection>
<AdvSection icon={Truck} label="Vendor / tech" count={draft.vendorTechs.size}>
<AdvMultiSelect
options={vendorTechs.map((name) => ({ value: name, label: name }))}
selected={draft.vendorTechs}
onToggle={(value) => toggleIn("vendorTechs", value)}
onBulkToggle={(values, select) => setMany("vendorTechs", values, select)}
placeholder="Search vendor / tech…"
/>
</AdvSection>
<AdvSection icon={FileText} label="Completion doc" count={draft.docs.size}>
{selectAllRow("docs", [...COMPLETION_DOC_OPTIONS], draft.docs.size)}
<div className="flex items-center gap-2">
{COMPLETION_DOC_OPTIONS.map((doc) => (
<AdvDocPill
key={doc}
label={DOC_FILTER_LABELS[doc]}
selected={draft.docs.has(doc)}
onClick={() => toggleIn("docs", doc)}
/>
))}
</div>
</AdvSection>
</div>
<div className="flex shrink-0 items-center gap-2 border-t border-border px-4 py-3">
<Button variant="ghost" className="text-xs" onClick={handleClearAll}>
Clear all
</Button>
<div className="flex flex-1 justify-center">
{activeCount > 0 && (
<span className="text-xs font-medium text-muted-foreground">
{activeCount} filter set
</span>
)}
</div>
<Button className="px-5 text-xs" onClick={handleApply}>
Apply filters
</Button>
</div>
</SideSheet>
);
}

View file

@ -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() {
<WorkOrdersListHeader
search={filters.search}
onSearchChange={filters.handleSearchChange}
onOpenAdvancedFilters={() => 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)}
/>
<WorkOrdersAdvancedSearchSheet
open={filters.advOpen}
onOpenChange={filters.setAdvOpen}
initial={filters.advInitial}
onApply={filters.applyAdvancedFilters}
onClearAll={filters.clearAdvancedFilters}
sites={locations}
users={users}
pmTypes={pmTypes}
vendorTechs={vendorTechs}
/>
{activeMediaRow != null && (

View file

@ -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<typeof import("react-router")>("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<string>(),
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(<WorkOrdersListPage />, {
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(<WorkOrdersListPage />);
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(<WorkOrdersListPage />);
fireEvent.click(
within(screen.getByRole("dialog", { name: "Advanced Filters" })).getByRole("button", {
name: /clear all/i,
}),
);
expect(clearAdvancedFilters).toHaveBeenCalled();
});
});

View file

@ -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<string>(),
@ -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(<WorkOrdersListPage />, { 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 }));