Compare commits

...

2 commits

Author SHA1 Message Date
Codex Review Integration
7679c28682 refactor(dashboard): extract dispatcher tables into a section component
Move the two paged dispatcher cards and their paging view-model derivation out
of DashboardPage into DispatcherTablesSection (a fragment, so both cards remain
direct children of the dashboard grid). This keeps DashboardPage under the
project complexity threshold after the pager additions, with no change to the
rendered layout or behaviour.
2026-09-17 00:46:39 -03:00
Codex Review Integration
0279cfac64 feat(dashboard): paginate dispatcher tables and fix Aveta drilldown
Workload and Performance page server-side at 10 rows and now return Page,
PageSize, and TotalDispatchers on dev (backend#126/#127), so wire the pager
that SH-346 and SH-347 require: getWorkload/getPerformance send a 1-based
`page` and parse the paging envelope, the hooks and query keys carry the page,
and each dispatcher table renders a 10-row pager. Page state is per-table and
resets on a range change so switching range never lands on an out-of-range
empty page that reads as a false "no dispatchers" state.

Drop `docs=No` from the Aveta Pending drilldown. `docs` filters on
completion-document status, not the Aveta clearance document the tile counts
(SH-294 / backend#138), so it narrowed to the wrong rows. The board has no
missing-Aveta-document filter, so the drilldown is now the Aveta-required work
in the two-day open window — a deliberate superset of the count.
2026-09-17 00:36:55 -03:00
16 changed files with 419 additions and 55 deletions

View file

@ -8,12 +8,17 @@ import {
workOrderDrilldownUrl,
} from "@/domain/dashboard/utils/work-order-drilldown-links";
import { DashboardCard } from "./dashboard-card";
import { DispatcherTablePager } from "./dispatcher-table-pager";
const EMPTY_MESSAGE = "No dispatchers are set up yet.";
interface DispatcherPerformanceCardProps {
rows: DispatcherPerformanceRow[];
range: DashboardRangeKey;
page: number;
pageSize: number;
totalDispatchers: number;
onPageChange: (page: number) => void;
isLoading?: boolean;
error?: unknown;
}
@ -59,6 +64,10 @@ function DispatcherNameCell({
export function DispatcherPerformanceCard({
rows,
range,
page,
pageSize,
totalDispatchers,
onPageChange,
isLoading = false,
error,
}: DispatcherPerformanceCardProps) {
@ -98,6 +107,13 @@ export function DispatcherPerformanceCard({
</TableBody>
</Table>
</TableContainer>
<DispatcherTablePager
page={page}
pageSize={pageSize}
totalDispatchers={totalDispatchers}
onPageChange={onPageChange}
label="Dispatcher Performance pagination"
/>
</DashboardCard>
);
}

View file

@ -0,0 +1,40 @@
import { Box, Pagination } from "@mui/material";
interface DispatcherTablePagerProps {
page: number;
pageSize: number;
totalDispatchers: number;
onPageChange: (page: number) => void;
label: string;
}
/**
* 10-row pager for the dispatcher tables (SH-346/SH-347). Renders nothing until
* there is more than one page, so single-page tables are unchanged.
*/
export function DispatcherTablePager({
page,
pageSize,
totalDispatchers,
onPageChange,
label,
}: DispatcherTablePagerProps) {
const pageCount = pageSize > 0 ? Math.ceil(totalDispatchers / pageSize) : 1;
if (pageCount <= 1) {
return null;
}
return (
<Box className="mt-3 flex justify-end">
<Pagination
size="small"
color="primary"
count={pageCount}
page={page}
onChange={(_event, value) => onPageChange(value)}
aria-label={label}
/>
</Box>
);
}

View file

@ -0,0 +1,60 @@
import type { UseQueryResult } from "@tanstack/react-query";
import {
DISPATCHER_PAGE_SIZE,
type DispatcherPage,
} from "@/domain/dashboard/types/dashboard-dispatcher-page";
import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range";
import type { DispatcherPerformanceRow } from "@/domain/dashboard/types/dashboard-performance";
import type { DispatcherWorkloadRow } from "@/domain/dashboard/types/dashboard-workload";
import { DispatcherPerformanceCard } from "./dispatcher-performance-card";
import { DispatcherWorkloadCard } from "./dispatcher-workload-card";
interface DispatcherTablesSectionProps {
range: DashboardRangeKey;
workload: UseQueryResult<DispatcherPage<DispatcherWorkloadRow>, Error>;
performance: UseQueryResult<DispatcherPage<DispatcherPerformanceRow>, Error>;
workloadPage: number;
performancePage: number;
onWorkloadPageChange: (page: number) => void;
onPerformancePageChange: (page: number) => void;
}
/**
* The two paged dispatcher tables. Kept as a fragment so both cards stay direct
* children of the dashboard grid, and so the paging view-model derivation lives
* here rather than inflating the page component.
*/
export function DispatcherTablesSection({
range,
workload,
performance,
workloadPage,
performancePage,
onWorkloadPageChange,
onPerformancePageChange,
}: DispatcherTablesSectionProps) {
return (
<>
<DispatcherWorkloadCard
rows={workload.data?.items ?? []}
range={range}
page={workloadPage}
pageSize={workload.data?.pageSize ?? DISPATCHER_PAGE_SIZE}
totalDispatchers={workload.data?.totalDispatchers ?? 0}
onPageChange={onWorkloadPageChange}
isLoading={workload.isLoading}
error={workload.error}
/>
<DispatcherPerformanceCard
rows={performance.data?.items ?? []}
range={range}
page={performancePage}
pageSize={performance.data?.pageSize ?? DISPATCHER_PAGE_SIZE}
totalDispatchers={performance.data?.totalDispatchers ?? 0}
onPageChange={onPerformancePageChange}
isLoading={performance.isLoading}
error={performance.error}
/>
</>
);
}

View file

@ -8,12 +8,17 @@ import {
workOrderDrilldownUrl,
} from "@/domain/dashboard/utils/work-order-drilldown-links";
import { DashboardCard } from "./dashboard-card";
import { DispatcherTablePager } from "./dispatcher-table-pager";
const EMPTY_MESSAGE = "No dispatchers are set up yet.";
interface DispatcherWorkloadCardProps {
rows: DispatcherWorkloadRow[];
range: DashboardRangeKey;
page: number;
pageSize: number;
totalDispatchers: number;
onPageChange: (page: number) => void;
isLoading?: boolean;
error?: unknown;
}
@ -49,6 +54,10 @@ function DispatcherNameCell({
export function DispatcherWorkloadCard({
rows,
range,
page,
pageSize,
totalDispatchers,
onPageChange,
isLoading = false,
error,
}: DispatcherWorkloadCardProps) {
@ -86,6 +95,13 @@ export function DispatcherWorkloadCard({
</TableBody>
</Table>
</TableContainer>
<DispatcherTablePager
page={page}
pageSize={pageSize}
totalDispatchers={totalDispatchers}
onPageChange={onPageChange}
label="Dispatcher Workload pagination"
/>
</DashboardCard>
);
}

View file

@ -8,8 +8,7 @@ import { PageHeader } from "@/components/ui/page-header";
import { DashboardKpiCard } from "@/app/(protected)/dashboard/_components/dashboard-kpi-card";
import { DashboardRangeToggle } from "@/app/(protected)/dashboard/_components/dashboard-range-toggle";
import { DashboardTrendCard } from "@/app/(protected)/dashboard/_components/dashboard-trend-card";
import { DispatcherPerformanceCard } from "@/app/(protected)/dashboard/_components/dispatcher-performance-card";
import { DispatcherWorkloadCard } from "@/app/(protected)/dashboard/_components/dispatcher-workload-card";
import { DispatcherTablesSection } from "@/app/(protected)/dashboard/_components/dispatcher-tables-section";
import { VendorInsightsCard } from "@/app/(protected)/dashboard/_components/vendor-insights-card";
import { WorkOrderBreakdownCard } from "@/app/(protected)/dashboard/_components/work-order-breakdown-card";
import { WorkOrdersByRegionCard } from "@/app/(protected)/dashboard/_components/work-orders-by-region-card";
@ -32,12 +31,25 @@ import {
export default function DashboardPage() {
const navigate = useNavigate();
const [range, setRange] = useState<DashboardRangeKey>(DEFAULT_DASHBOARD_RANGE);
const [workloadPage, setWorkloadPage] = useState(1);
const [performancePage, setPerformancePage] = useState(1);
const stats = useDashboardStats(range);
const workload = useDashboardWorkload(range);
const performance = useDashboardPerformance(range);
const workload = useDashboardWorkload(range, workloadPage);
const performance = useDashboardPerformance(range, performancePage);
const regions = useDashboardRegions(range);
const vendors = useDashboardVendorInsights();
// A range change re-scopes both dispatcher tables, so their page must reset —
// otherwise a switch while on page 3 lands on an out-of-range empty page that
// reads as a false "no dispatchers" state.
const changeRange = (next: DashboardRangeKey) => {
setRange(next);
setWorkloadPage(1);
setPerformancePage(1);
};
const resetFilters = () => changeRange(DEFAULT_DASHBOARD_RANGE);
const isRefreshing =
[stats, workload, performance, regions, vendors].some((query) => query.isFetching) &&
!stats.isLoading;
@ -50,11 +62,11 @@ export default function DashboardPage() {
<Box className="flex items-center gap-2">
{isRefreshing && <CircularProgress size={16} />}
{range !== DEFAULT_DASHBOARD_RANGE && (
<Button size="small" onClick={() => setRange(DEFAULT_DASHBOARD_RANGE)}>
<Button size="small" onClick={resetFilters}>
Reset filters
</Button>
)}
<DashboardRangeToggle value={range} onChange={setRange} />
<DashboardRangeToggle value={range} onChange={changeRange} />
</Box>
}
/>
@ -99,17 +111,14 @@ export default function DashboardPage() {
isLoading={regions.isLoading}
error={regions.error}
/>
<DispatcherWorkloadCard
rows={workload.data ?? []}
<DispatcherTablesSection
range={range}
isLoading={workload.isLoading}
error={workload.error}
/>
<DispatcherPerformanceCard
rows={performance.data ?? []}
range={range}
isLoading={performance.isLoading}
error={performance.error}
workload={workload}
performance={performance}
workloadPage={workloadPage}
performancePage={performancePage}
onWorkloadPageChange={setWorkloadPage}
onPerformancePageChange={setPerformancePage}
/>
</Box>
<DashboardTrendCard range={range} />

View file

@ -8,6 +8,10 @@ import type {
DashboardStats,
} from "@/domain/dashboard/types/dashboard-stats";
import type { DispatcherPerformanceRow } from "@/domain/dashboard/types/dashboard-performance";
import {
DISPATCHER_PAGE_SIZE,
type DispatcherPage,
} from "@/domain/dashboard/types/dashboard-dispatcher-page";
import type { DashboardTrendParams } from "@/domain/dashboard/types/dashboard-trend";
import type {
DashboardTrend,
@ -83,6 +87,44 @@ function rangeSearchParams(range?: DashboardRangeParams): Options | undefined {
return { searchParams: { dateFrom: range.dateFrom, dateTo: range.dateTo } };
}
/**
* Workload and Performance are paged server-side (backend#126/#127): `page` is
* 1-based and always sent, and the range dates ride along when present.
*/
function dispatcherPageSearchParams(
range: DashboardRangeParams | undefined,
page: number,
): Options {
const searchParams: Record<string, string | number> = { page };
if (range?.dateFrom && range?.dateTo) {
searchParams.dateFrom = range.dateFrom;
searchParams.dateTo = range.dateTo;
}
return { searchParams };
}
/**
* Reads the paging envelope the dispatcher endpoints return (`Page`, `PageSize`,
* `TotalDispatchers`). Falls back to the requested page, the default page size,
* and the item count for the legacy bare-array shape so older responses still
* render (with the pager hidden).
*/
function readDispatcherPageMeta(
raw: unknown,
requestedPage: number,
itemCount: number,
): { page: number; pageSize: number; totalDispatchers: number } {
const data = toRecord(raw);
const pageValue = readField(data, "page");
const pageSizeValue = readField(data, "pageSize");
const totalValue = readField(data, "totalDispatchers");
return {
page: pageValue == null ? requestedPage : toNumber(pageValue),
pageSize: pageSizeValue == null ? DISPATCHER_PAGE_SIZE : toNumber(pageSizeValue),
totalDispatchers: totalValue == null ? itemCount : toNumber(totalValue),
};
}
function trendSearchParams(params?: DashboardTrendParams): Options | undefined {
if (
!params ||
@ -290,13 +332,29 @@ export const dashboardApi = {
const data = await apiGet<unknown>(API_PATHS.dashboard.stats, rangeSearchParams(range));
return mapDashboardStats(handleApiResponse<unknown>(data));
},
getWorkload: async (range?: DashboardRangeParams): Promise<DispatcherWorkloadRow[]> => {
const data = await apiGet<unknown>(API_PATHS.dashboard.workload, rangeSearchParams(range));
return mapDispatcherWorkload(handleApiResponse<unknown>(data));
getWorkload: async (
range?: DashboardRangeParams,
page = 1,
): Promise<DispatcherPage<DispatcherWorkloadRow>> => {
const data = await apiGet<unknown>(
API_PATHS.dashboard.workload,
dispatcherPageSearchParams(range, page),
);
const response = handleApiResponse<unknown>(data);
const items = mapDispatcherWorkload(response);
return { items, ...readDispatcherPageMeta(response, page, items.length) };
},
getPerformance: async (range?: DashboardRangeParams): Promise<DispatcherPerformanceRow[]> => {
const data = await apiGet<unknown>(API_PATHS.dashboard.performance, rangeSearchParams(range));
return mapDispatcherPerformance(handleApiResponse<unknown>(data));
getPerformance: async (
range?: DashboardRangeParams,
page = 1,
): Promise<DispatcherPage<DispatcherPerformanceRow>> => {
const data = await apiGet<unknown>(
API_PATHS.dashboard.performance,
dispatcherPageSearchParams(range, page),
);
const response = handleApiResponse<unknown>(data);
const items = mapDispatcherPerformance(response);
return { items, ...readDispatcherPageMeta(response, page, items.length) };
},
getRegions: async (range?: DashboardRangeParams): Promise<RegionWorkOrdersRow[]> => {
const data = await apiGet<unknown>(API_PATHS.dashboard.regions, rangeSearchParams(range));

View file

@ -0,0 +1,10 @@
/** Backend pages both dispatcher tables at 10 rows (backend#126/#127). */
export const DISPATCHER_PAGE_SIZE = 10;
/** A single page of dispatcher rows plus the paging metadata the tables need. */
export interface DispatcherPage<TRow> {
items: TRow[];
page: number;
pageSize: number;
totalDispatchers: number;
}

View file

@ -1,5 +1,6 @@
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
import { dashboardApi } from "@/domain/dashboard/api/dashboard-api";
import type { DispatcherPage } from "@/domain/dashboard/types/dashboard-dispatcher-page";
import type { DispatcherPerformanceRow } from "@/domain/dashboard/types/dashboard-performance";
import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range";
import { dashboardRangeParams } from "@/domain/dashboard/utils/dashboard-range-utils";
@ -7,10 +8,11 @@ import { queryKeys } from "@/infra/query-key/query-key";
export function useDashboardPerformance(
range: DashboardRangeKey,
): UseQueryResult<DispatcherPerformanceRow[], Error> {
page = 1,
): UseQueryResult<DispatcherPage<DispatcherPerformanceRow>, Error> {
return useQuery({
queryKey: queryKeys.dashboard.performance(range),
queryFn: () => dashboardApi.getPerformance(dashboardRangeParams(range)),
queryKey: queryKeys.dashboard.performance(range, page),
queryFn: () => dashboardApi.getPerformance(dashboardRangeParams(range), page),
staleTime: 60 * 1000,
});
}

View file

@ -1,5 +1,6 @@
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
import { dashboardApi } from "@/domain/dashboard/api/dashboard-api";
import type { DispatcherPage } from "@/domain/dashboard/types/dashboard-dispatcher-page";
import type { DispatcherWorkloadRow } from "@/domain/dashboard/types/dashboard-workload";
import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range";
import { dashboardRangeParams } from "@/domain/dashboard/utils/dashboard-range-utils";
@ -7,10 +8,11 @@ import { queryKeys } from "@/infra/query-key/query-key";
export function useDashboardWorkload(
range: DashboardRangeKey,
): UseQueryResult<DispatcherWorkloadRow[], Error> {
page = 1,
): UseQueryResult<DispatcherPage<DispatcherWorkloadRow>, Error> {
return useQuery({
queryKey: queryKeys.dashboard.workload(range),
queryFn: () => dashboardApi.getWorkload(dashboardRangeParams(range)),
queryKey: queryKeys.dashboard.workload(range, page),
queryFn: () => dashboardApi.getWorkload(dashboardRangeParams(range), page),
staleTime: 60 * 1000,
});
}

View file

@ -72,11 +72,16 @@ export function scheduledTomorrowDrilldownSearch(
}
export function avetaPendingDrilldownSearch(today: string = businessTodayIso()): URLSearchParams {
// The Aveta Pending count (backend#138 / SH-294) is: AvetaRequired, scheduled
// today or tomorrow, open, and missing its Aveta-category document. The board
// has no missing-Aveta-document filter, so the closest reachable set is the
// Aveta-required work in the two-day open window. `docs=No` is deliberately
// NOT sent: it filters on completion-document status, a different document
// from the Aveta clearance one, so it would narrow to the wrong rows.
const search = new URLSearchParams();
search.set("dateFrom", today);
search.set("dateTo", addDaysIso(today, 1));
search.set("aveta", "1");
search.set("docs", "No");
search.set("statuses", OPEN_WIZARD_STATUSES.join(","));
return search;
}

View file

@ -52,10 +52,10 @@ export const queryKeys = {
dashboard: {
all: ["dashboard"] as const,
stats: (range: DashboardRangeKey) => [...queryKeys.dashboard.all, "stats", range] as const,
workload: (range: DashboardRangeKey) =>
[...queryKeys.dashboard.all, "workload", range] as const,
performance: (range: DashboardRangeKey) =>
[...queryKeys.dashboard.all, "performance", range] as const,
workload: (range: DashboardRangeKey, page = 1) =>
[...queryKeys.dashboard.all, "workload", range, page] as const,
performance: (range: DashboardRangeKey, page = 1) =>
[...queryKeys.dashboard.all, "performance", range, page] as const,
regions: (range: DashboardRangeKey) => [...queryKeys.dashboard.all, "regions", range] as const,
vendorInsights: () => [...queryKeys.dashboard.all, "vendorInsights"] as const,
trend: (params?: DashboardTrendParams) =>

View file

@ -5,6 +5,12 @@ import { renderWithProviders } from "@/test/test-utils";
const navigate = vi.fn();
const statsRange = vi.fn<(range: DashboardRangeKey) => void>();
const { workloadMock, performanceMock } = vi.hoisted(() => ({
workloadMock: vi.fn(),
performanceMock: vi.fn(),
}));
const EMPTY_DISPATCHER_PAGE = { items: [], page: 1, pageSize: 10, totalDispatchers: 0 };
vi.mock("react-router", async (importOriginal) => ({
...(await importOriginal<typeof import("react-router")>()),
@ -12,11 +18,11 @@ vi.mock("react-router", async (importOriginal) => ({
}));
vi.mock("@/domain/dashboard/use-cases/use-dashboard-workload", () => ({
useDashboardWorkload: () => ({ data: [], isLoading: false, isFetching: false, error: null }),
useDashboardWorkload: workloadMock,
}));
vi.mock("@/domain/dashboard/use-cases/use-dashboard-performance", () => ({
useDashboardPerformance: () => ({ data: [], isLoading: false, isFetching: false, error: null }),
useDashboardPerformance: performanceMock,
}));
vi.mock("@/domain/dashboard/use-cases/use-dashboard-regions", () => ({
@ -73,6 +79,20 @@ describe("DashboardPage", () => {
beforeEach(() => {
navigate.mockReset();
statsRange.mockReset();
workloadMock.mockReset();
performanceMock.mockReset();
workloadMock.mockReturnValue({
data: EMPTY_DISPATCHER_PAGE,
isLoading: false,
isFetching: false,
error: null,
});
performanceMock.mockReturnValue({
data: EMPTY_DISPATCHER_PAGE,
isLoading: false,
isFetching: false,
error: null,
});
});
it("renders the prototype title, range options, and card titles", () => {
@ -154,6 +174,53 @@ describe("DashboardPage", () => {
expect(screen.getByText("No active vendor has any work orders yet.")).toBeInTheDocument();
});
it("pages the dispatcher tables at 10 rows and re-queries on page change", () => {
const rows = Array.from({ length: 10 }, (_, index) => ({
dispatcherId: String(index + 1),
dispatcherName: `Dispatcher ${index + 1}`,
openWorkOrders: 1,
totalWorkOrders: 2,
}));
workloadMock.mockReturnValue({
data: { items: rows, page: 1, pageSize: 10, totalDispatchers: 23 },
isLoading: false,
isFetching: false,
error: null,
});
renderDashboard();
// 23 dispatchers at 10/page = 3 pages; selecting page 2 re-queries with page 2.
fireEvent.click(screen.getByRole("button", { name: "Go to page 2" }));
const requestedPages = workloadMock.mock.calls.map((call) => call[1]);
expect(requestedPages).toContain(2);
});
it("resets the dispatcher page when the range changes", () => {
const rows = Array.from({ length: 10 }, (_, index) => ({
dispatcherId: String(index + 1),
dispatcherName: `Dispatcher ${index + 1}`,
openWorkOrders: 1,
totalWorkOrders: 2,
}));
workloadMock.mockReturnValue({
data: { items: rows, page: 2, pageSize: 10, totalDispatchers: 23 },
isLoading: false,
isFetching: false,
error: null,
});
renderDashboard();
fireEvent.click(screen.getByRole("button", { name: "Go to page 2" }));
fireEvent.click(screen.getByRole("button", { name: "Today" }));
const calls = workloadMock.mock.calls;
const lastRequestedPage = calls[calls.length - 1]?.[1];
expect(lastRequestedPage).toBe(1);
});
it("keeps drilldown dates deterministic on the business clock", () => {
const today = businessTodayIso();

View file

@ -0,0 +1,39 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { DispatcherTablePager } from "@/app/(protected)/dashboard/_components/dispatcher-table-pager";
describe("DispatcherTablePager", () => {
it("renders nothing when everything fits on one page", () => {
const { container } = render(
<DispatcherTablePager
page={1}
pageSize={10}
totalDispatchers={10}
onPageChange={vi.fn()}
label="pager"
/>,
);
expect(container).toBeEmptyDOMElement();
});
it("renders a page per 10 rows and reports the selected page", () => {
const onPageChange = vi.fn();
render(
<DispatcherTablePager
page={1}
pageSize={10}
totalDispatchers={23}
onPageChange={onPageChange}
label="pager"
/>,
);
// 23 rows at 10/page = 3 pages.
expect(screen.getByRole("button", { name: "Go to page 3" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Go to page 4" })).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Go to page 2" }));
expect(onPageChange).toHaveBeenCalledWith(2);
});
});

View file

@ -84,23 +84,31 @@ describe("dashboardApi", () => {
expect(apiGet).toHaveBeenCalledWith("Dashboard/Stats", undefined);
});
it("maps workload rows", async () => {
it("maps workload rows and returns the paging envelope", async () => {
apiGet.mockResolvedValue({
Items: [{ DispatcherId: 12, DispatcherName: "Dana Reyes", OpenCount: "4", TotalCount: 6 }],
Page: 1,
PageSize: 10,
TotalDispatchers: 23,
});
await expect(dashboardApi.getWorkload()).resolves.toEqual([
{
dispatcherId: "12",
dispatcherName: "Dana Reyes",
openWorkOrders: 4,
totalWorkOrders: 6,
},
]);
expect(apiGet).toHaveBeenCalledWith("Dashboard/Workload", undefined);
await expect(dashboardApi.getWorkload()).resolves.toEqual({
items: [
{
dispatcherId: "12",
dispatcherName: "Dana Reyes",
openWorkOrders: 4,
totalWorkOrders: 6,
},
],
page: 1,
pageSize: 10,
totalDispatchers: 23,
});
expect(apiGet).toHaveBeenCalledWith("Dashboard/Workload", { searchParams: { page: 1 } });
});
it("maps performance rows", async () => {
it("sends the requested page and range and maps performance rows", async () => {
apiGet.mockResolvedValue({
Items: [
{
@ -110,16 +118,44 @@ describe("dashboardApi", () => {
AverageResolutionDays: 3.25,
},
],
Page: 2,
PageSize: 10,
TotalDispatchers: 14,
});
await expect(dashboardApi.getPerformance()).resolves.toEqual([
{
dispatcherId: "12",
dispatcherName: "Dana Reyes",
completionRate: 96.5,
averageResolutionDays: 3.25,
},
await expect(
dashboardApi.getPerformance({ dateFrom: "2026-09-14", dateTo: "2026-09-18" }, 2),
).resolves.toEqual({
items: [
{
dispatcherId: "12",
dispatcherName: "Dana Reyes",
completionRate: 96.5,
averageResolutionDays: 3.25,
},
],
page: 2,
pageSize: 10,
totalDispatchers: 14,
});
expect(apiGet).toHaveBeenCalledWith("Dashboard/Performance", {
searchParams: { dateFrom: "2026-09-14", dateTo: "2026-09-18", page: 2 },
});
});
it("falls back to the requested page for the legacy bare-array shape", async () => {
apiGet.mockResolvedValue([
{ DispatcherId: 7, DispatcherName: "Lee Ash", OpenCount: 1, TotalCount: 2 },
]);
await expect(dashboardApi.getWorkload({}, 1)).resolves.toEqual({
items: [
{ dispatcherId: "7", dispatcherName: "Lee Ash", openWorkOrders: 1, totalWorkOrders: 2 },
],
page: 1,
pageSize: 10,
totalDispatchers: 1,
});
});
it("maps unknown regions to Unmapped", async () => {

View file

@ -22,7 +22,9 @@ describe("work-order drilldown links", () => {
expect(search.get("dateFrom")).toBe("2026-09-16");
expect(search.get("dateTo")).toBe("2026-09-17");
expect(search.get("aveta")).toBe("1");
expect(search.get("docs")).toBe("No");
// `docs=No` is the completion-document status, not the Aveta document, so it
// must not be part of the Aveta Pending drilldown (SH-294 / backend#138).
expect(search.get("docs")).toBeNull();
expect(statuses).toContain("Scheduled");
expect(statuses).not.toContain("Completed");
});

View file

@ -29,7 +29,9 @@ describe("parseDashboardDrilldownParams", () => {
expect(filters).not.toBeNull();
expect(filters?.avetaOnly).toBe(true);
expect(filters?.docs).toEqual(new Set(["No"]));
// The Aveta drilldown no longer sends `docs=No` (completion-doc status), so
// no completion-document filter is applied.
expect(filters?.docs.size).toBe(0);
expect(filters?.statuses).not.toBeNull();
expect([...(filters?.statuses ?? [])]).not.toContain("Completed");
expect([...(filters?.statuses ?? [])]).not.toContain("NotAStatus");