feat(work-orders): relocate dispatcher filter under the title (SH-242)

Place dispatcher scoping under the Work Orders heading as a plain
trigger so the week/day bar stays boxed, without changing board params.
This commit is contained in:
Arthur Bassi 2026-08-20 15:53:03 -03:00
parent c101110148
commit 8614f9415c
7 changed files with 282 additions and 64 deletions

View file

@ -80,6 +80,19 @@ test.describe("Work Orders week/day navigation (SH-240)", () => {
await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible({
timeout: 15_000,
});
const dispatcher = page.getByRole("button", { name: "All dispatchers" });
await expect(dispatcher).toBeVisible();
await expect(
page.getByLabel("Week and day").getByRole("button", { name: "All dispatchers" }),
).toHaveCount(0);
const titleBox = await page.getByRole("heading", { name: "Work Orders" }).boundingBox();
const dispatcherBox = await dispatcher.boundingBox();
const weekBox = await page.getByRole("button", { name: "Week 8/17–8/21" }).boundingBox();
expect(titleBox).toBeTruthy();
expect(dispatcherBox).toBeTruthy();
expect(weekBox).toBeTruthy();
expect(dispatcherBox!.y).toBeGreaterThan(titleBox!.y);
expect(weekBox!.y).toBeGreaterThan(dispatcherBox!.y);
await expect(page.getByRole("button", { name: "Week 8/17–8/21" })).toBeVisible();
await expect(page.getByRole("button", { name: "Week 8/17–8/21" })).toContainText("Live");

View file

@ -27,6 +27,7 @@ function userInitials(name: string): string {
function DispatcherAvatar({ name, color }: { name: string; color: string }) {
return (
<span
aria-hidden
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[9px] font-semibold"
style={{ background: `${color}25`, color }}
>
@ -75,8 +76,8 @@ export function DispatcherFilter({
return (
<Skeleton
variant="rounded"
width={160}
height={38}
width={140}
height={18}
animation="wave"
aria-label="Loading dispatchers"
/>
@ -89,35 +90,13 @@ export function DispatcherFilter({
onAnchor={setAnchor}
open={open}
disabled={disabled}
className="flex w-auto shrink-0 items-center gap-2 border whitespace-nowrap transition-colors"
style={
!allSelected
? {
height: 38,
padding: "0 12px",
borderRadius: 6,
fontSize: 13,
fontWeight: 600,
background: "var(--color-primary-tint)",
borderColor: "#bfe2f5",
color: "var(--primary)",
}
: {
height: 38,
padding: "0 12px",
borderRadius: 6,
fontSize: 13,
fontWeight: 600,
background: "var(--card)",
borderColor: "var(--border)",
color: "var(--foreground)",
}
}
className="flex w-auto shrink-0 items-center gap-1.5 whitespace-nowrap transition-colors"
style={{ fontSize: 13, fontWeight: 500, color: "var(--muted-foreground)" }}
aria-haspopup="listbox"
>
<Users size={15} style={{ color: "var(--muted-foreground)" }} />
<Users size={13} aria-hidden style={{ color: "var(--muted-foreground)" }} />
{label}
<ChevronDown size={14} style={{ color: "var(--muted-foreground)" }} />
<ChevronDown size={13} aria-hidden style={{ color: "var(--muted-foreground)" }} />
</MenuAnchorButton>
<Popover

View file

@ -1,7 +1,5 @@
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
import type { WorkOrderTypeFilter } from "@/domain/work-orders/types/work-order-list-filters";
import { DayTabs } from "@/app/(protected)/workorders/_components/list/filter-bar/day-tabs";
import { DispatcherFilter } from "@/app/(protected)/workorders/_components/list/filter-bar/dispatcher-filter";
import { WeekSelector } from "@/app/(protected)/workorders/_components/list/filter-bar/week-selector";
import { WoTypeSegment } from "@/app/(protected)/workorders/_components/list/filter-bar/wo-type-segment";
import { Text } from "@/components/ui/text";
@ -11,13 +9,8 @@ type WorkOrdersFilterBarProps = {
onWeekChange: (mondayIso: string) => void;
selectedDayIso: string;
onSelectedDayChange: (dayIso: string) => void;
dispatcherIds: Set<string>;
onDispatcherChange: (ids: Set<string>) => void;
typeFilter: WorkOrderTypeFilter;
onTypeChange: (value: WorkOrderTypeFilter) => void;
users: WorkOrderUser[];
usersLoading?: boolean;
currentUserId?: string | number;
filteredCount: number;
scopeTotalCount: number;
disabled?: boolean;
@ -28,13 +21,8 @@ export function WorkOrdersFilterBar({
onWeekChange,
selectedDayIso,
onSelectedDayChange,
dispatcherIds,
onDispatcherChange,
typeFilter,
onTypeChange,
users,
usersLoading = false,
currentUserId,
filteredCount,
scopeTotalCount,
disabled = false,
@ -46,29 +34,21 @@ export function WorkOrdersFilterBar({
className="-mx-2.5 shrink-0 border-b border-(--color-border) bg-(--color-bg-surface)"
aria-disabled={disabled}
>
<div className="flex items-center gap-3 px-4 pt-2">
<DispatcherFilter
selectedIds={dispatcherIds}
onChange={onDispatcherChange}
users={users}
usersLoading={usersLoading}
currentUserId={currentUserId}
disabled={disabled}
/>
<div className="flex items-center gap-3 px-4 pt-2" aria-label="Week and day">
<div className="flex min-w-0 flex-col gap-1">
<WeekSelector weekMonday={weekMonday} onChange={onWeekChange} disabled={disabled} />
<DayTabs
weekMonday={weekMonday}
selectedDayIso={selectedDayIso}
onChange={onSelectedDayChange}
disabled={disabled}
/>
</div>
<div className="flex-1" />
<Text variant="caption" tone="muted" className="shrink-0">
{resultLabel}
</Text>
</div>
<div className="flex flex-col gap-1 px-4 pt-1" aria-label="Week and day">
<WeekSelector weekMonday={weekMonday} onChange={onWeekChange} disabled={disabled} />
<DayTabs
weekMonday={weekMonday}
selectedDayIso={selectedDayIso}
onChange={onSelectedDayChange}
disabled={disabled}
/>
</div>
<div className="flex items-center gap-3 px-4 pt-1 pb-2">
<WoTypeSegment value={typeFilter} onChange={onTypeChange} disabled={disabled} />
</div>

View file

@ -2,6 +2,7 @@ import { Box } from "@mui/material";
import { PageHeader } from "@/components/ui/page-header";
import { QueryErrorPanel } from "@/components/ui/query-error-panel";
import { WorkOrdersAdvancedFiltersBanner } from "@/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner";
import { DispatcherFilter } from "@/app/(protected)/workorders/_components/list/filter-bar/dispatcher-filter";
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";
import { WorkOrdersListPagePanels } from "@/app/(protected)/workorders/_components/list/work-orders-list-page-panels";
@ -30,6 +31,16 @@ export default function WorkOrdersListPage() {
surface
eyebrow="OPERATIONS"
title="Work Orders"
belowTitle={
<DispatcherFilter
selectedIds={filters.dispatcherIds}
onChange={filters.setDispatcherIds}
users={page.users}
usersLoading={page.usersLoading}
currentUserId={user?.id}
disabled={filters.advApplied != null}
/>
}
actions={
<WorkOrdersListHeader
search={filters.search}
@ -47,13 +58,8 @@ export default function WorkOrdersListPage() {
onWeekChange={filters.setWeekMonday}
selectedDayIso={filters.selectedDayIso}
onSelectedDayChange={filters.setSelectedDayIso}
dispatcherIds={filters.dispatcherIds}
onDispatcherChange={filters.setDispatcherIds}
typeFilter={filters.typeFilter}
onTypeChange={filters.setTypeFilter}
users={page.users}
usersLoading={page.usersLoading}
currentUserId={user?.id}
filteredCount={tableData.totalCount}
scopeTotalCount={tableData.scopeTotalCount}
disabled={filters.advApplied != null}

View file

@ -1,4 +1,4 @@
import type { ReactElement } from "react";
import type { ReactElement, ReactNode } from "react";
import { Stack } from "@mui/material";
import { Text } from "@/components/ui/text";
@ -8,6 +8,7 @@ type PageHeaderProps = {
eyebrow?: string;
title: string;
subtitle?: string;
belowTitle?: ReactNode;
actions?: ReactElement;
className?: string;
/** White surface bar (list page headers). */
@ -18,6 +19,7 @@ export function PageHeader({
eyebrow,
title,
subtitle,
belowTitle,
actions,
className,
surface = false,
@ -63,6 +65,7 @@ export function PageHeader({
>
{subtitle}
</Text>
{Boolean(belowTitle) && <div className="mt-1">{belowTitle}</div>}
</Stack>
{Boolean(actions) && (
<Stack

View file

@ -0,0 +1,125 @@
import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { DispatcherFilter } from "@/app/(protected)/workorders/_components/list/filter-bar/dispatcher-filter";
import { ASSIGNEE_FILTER_UNASSIGNED } from "@/domain/work-orders/types/work-order-list";
import { renderWithProviders } from "@/test/test-utils";
const users = [
{ id: "u1", name: "Alex Rivera" },
{ id: "u2", name: "Blair Cho" },
];
describe("DispatcherFilter (SH-242)", () => {
it("renders a plain trigger without a boxed border", () => {
renderWithProviders(
<DispatcherFilter selectedIds={new Set()} onChange={vi.fn()} users={users} />,
{ withAuth: false },
);
const trigger = screen.getByRole("button", { name: "All dispatchers" });
expect(trigger.className.split(/\s+/)).not.toContain("border");
expect(trigger.style.height).not.toBe("38px");
expect(trigger.style.borderColor).toBe("");
expect(trigger.style.background).toBe("");
expect(trigger.style.fontWeight).toBe("500");
});
it("opens the menu from the keyboard", async () => {
const user = userEvent.setup();
renderWithProviders(
<DispatcherFilter
selectedIds={new Set()}
onChange={vi.fn()}
users={users}
currentUserId="u1"
/>,
{ withAuth: false },
);
screen.getByRole("button", { name: "All dispatchers" }).focus();
await user.keyboard("{Enter}");
expect(screen.getByRole("button", { name: "My WOs only" })).toBeInTheDocument();
});
it("shows loading skeleton while dispatchers are fetching", () => {
renderWithProviders(
<DispatcherFilter selectedIds={new Set()} onChange={vi.fn()} users={[]} usersLoading />,
{ withAuth: false },
);
expect(screen.getByLabelText("Loading dispatchers")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "All dispatchers" })).not.toBeInTheDocument();
});
it("disables the trigger when requested", () => {
renderWithProviders(
<DispatcherFilter selectedIds={new Set()} onChange={vi.fn()} users={users} disabled />,
{ withAuth: false },
);
expect(screen.getByRole("button", { name: "All dispatchers" })).toBeDisabled();
});
it("labels My WOs, Unassigned, and a multi-select count", () => {
const { rerender } = renderWithProviders(
<DispatcherFilter
selectedIds={new Set(["u1"])}
onChange={vi.fn()}
users={users}
currentUserId="u1"
/>,
{ withAuth: false },
);
expect(screen.getByRole("button", { name: "My WOs" })).toBeInTheDocument();
rerender(
<DispatcherFilter
selectedIds={new Set([ASSIGNEE_FILTER_UNASSIGNED])}
onChange={vi.fn()}
users={users}
currentUserId="u1"
/>,
);
expect(screen.getByRole("button", { name: "Unassigned" })).toBeInTheDocument();
rerender(
<DispatcherFilter
selectedIds={new Set(["u1", "u2"])}
onChange={vi.fn()}
users={users}
currentUserId="u1"
/>,
);
expect(screen.getByRole("button", { name: "2 selected" })).toBeInTheDocument();
});
it("scopes All, My WOs, Unassigned, and a named dispatcher", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
renderWithProviders(
<DispatcherFilter
selectedIds={new Set()}
onChange={onChange}
users={users}
currentUserId="u1"
/>,
{ withAuth: false },
);
await user.click(screen.getByRole("button", { name: "All dispatchers" }));
await user.click(screen.getByRole("button", { name: "My WOs only" }));
expect(onChange).toHaveBeenLastCalledWith(new Set(["u1"]));
await user.click(screen.getByRole("button", { name: "Unassigned" }));
expect(onChange).toHaveBeenLastCalledWith(new Set([ASSIGNEE_FILTER_UNASSIGNED]));
await user.click(screen.getByRole("button", { name: "Blair Cho" }));
expect(onChange).toHaveBeenLastCalledWith(new Set(["u2"]));
const menu = screen.getByRole("presentation");
await user.click(within(menu).getByRole("button", { name: "All dispatchers" }));
expect(onChange).toHaveBeenLastCalledWith(new Set());
});
});

View file

@ -0,0 +1,112 @@
import { screen } from "@testing-library/react";
import { 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";
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: false,
setAdvOpen: vi.fn(),
advApplied: null,
advInitial: EMPTY_ADVANCED_FILTERS,
applyAdvancedFilters: vi.fn(),
clearAdvancedFilters: vi.fn(),
clearAdvancedFilterChip: vi.fn(),
activeFilterCount: 0,
weekMonday: "2026-07-20",
selectedDayIso: "2026-07-20",
setWeekMonday: vi.fn(),
setSelectedDayIso: vi.fn(),
navigateToDay: 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: [] }),
useWorkOrderVendors: () => ({ data: [] }),
useWorkOrderBoardDetail: () => ({
data: undefined,
isLoading: false,
isFetching: false,
error: null,
refetch: vi.fn(),
}),
}));
vi.mock("@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category", () => ({
useDropdownOptionsByCategory: () => ({ data: [] }),
}));
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/media/media-upload-modal", () => ({
MediaUploadModal: () => null,
}));
describe("Work Orders dispatcher filter layout (SH-242)", () => {
it("places the dispatcher trigger under the title and above week/day controls", () => {
renderWithProviders(<WorkOrdersListPage />, { route: "/workorders", withAuth: false });
const title = screen.getByRole("heading", { name: "Work Orders" });
const dispatcher = screen.getByRole("button", { name: "All dispatchers" });
const weekAndDay = screen.getByLabelText("Week and day");
const week = screen.getByRole("button", { name: "Week 7/20–7/24" });
expect(title.compareDocumentPosition(dispatcher) & Node.DOCUMENT_POSITION_FOLLOWING).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
);
expect(dispatcher.compareDocumentPosition(weekAndDay) & Node.DOCUMENT_POSITION_FOLLOWING).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
);
expect(weekAndDay.contains(dispatcher)).toBe(false);
expect(weekAndDay.contains(week)).toBe(true);
expect(screen.getByText("0 of 0")).toBeInTheDocument();
});
});