fix(work-orders): load SH-165 Unassigned queue from global board search

This commit is contained in:
arthur.bassi 2026-08-17 14:57:39 -03:00
parent e1667702e1
commit 958467fe40
15 changed files with 393 additions and 15 deletions

View file

@ -1,10 +1,12 @@
import { Fragment, type ReactNode } from "react"; import { Fragment, type ReactNode } from "react";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import { Text } from "@/components/ui/text";
import { import {
UNASSIGNED_DAY_KEY, UNASSIGNED_DAY_KEY,
UNSCHEDULED_DAY_KEY, UNSCHEDULED_DAY_KEY,
type WorkOrderTableRow,
} from "@/domain/work-orders/types/work-order-table-row"; } from "@/domain/work-orders/types/work-order-table-row";
import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types"; import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types";
import { WO_TABLE_COLS } from "./wo-table-cols";
import { WoTableSectionHeader } from "./wo-table-section-header"; import { WoTableSectionHeader } from "./wo-table-section-header";
type WoTableBodyProps = { type WoTableBodyProps = {
@ -19,9 +21,11 @@ export function WoTableBody({ tableData, renderRow, renderInlineRow }: WoTableBo
} }
const showUnassigned = const showUnassigned =
tableData.unassigned.length > 0 || tableData.draftDayKey === UNASSIGNED_DAY_KEY; (tableData.unassigned?.length ?? 0) > 0 ||
tableData.draftDayKey === UNASSIGNED_DAY_KEY ||
Boolean(tableData.unassignedError);
const showUnscheduled = const showUnscheduled =
tableData.unscheduled.length > 0 || tableData.draftDayKey === UNSCHEDULED_DAY_KEY; (tableData.unscheduled?.length ?? 0) > 0 || tableData.draftDayKey === UNSCHEDULED_DAY_KEY;
return ( return (
<tbody> <tbody>
@ -37,7 +41,17 @@ export function WoTableBody({ tableData, renderRow, renderInlineRow }: WoTableBo
{tableData.unassignedOpen && ( {tableData.unassignedOpen && (
<> <>
{renderInlineRow(UNASSIGNED_DAY_KEY)} {renderInlineRow(UNASSIGNED_DAY_KEY)}
{tableData.unassigned.map((row) => renderRow(row))} {Boolean(tableData.unassignedError) && (
<tr>
<td colSpan={WO_TABLE_COLS.length} style={{ padding: "8px 14px" }}>
<Text variant="error" tone="error">
{tableData.unassignedError?.message ??
"Unable to load unassigned work orders."}
</Text>
</td>
</tr>
)}
{(tableData.unassigned ?? []).map((row) => renderRow(row))}
</> </>
)} )}
</> </>

View file

@ -1,22 +1,28 @@
import { useMemo } from "react"; import { useCallback, useMemo } from "react";
import type { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters"; import type { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters";
import { buildResolvedBoardSearchParams } from "@/app/(protected)/workorders/_hooks/build-resolved-board-search-params"; import { buildResolvedBoardSearchParams } from "@/app/(protected)/workorders/_hooks/build-resolved-board-search-params";
import { import {
useWorkOrdersBoard, useWorkOrdersBoard,
useWorkOrdersBoardSearch, useWorkOrdersBoardSearch,
useWorkOrdersUnassignedQueue,
} from "@/domain/work-orders/use-cases/use-work-orders-list"; } from "@/domain/work-orders/use-cases/use-work-orders-list";
import { import {
useWorkOrderLocations, useWorkOrderLocations,
useWorkOrderVendors, useWorkOrderVendors,
} from "@/domain/work-orders/use-cases/use-work-order-detail"; } from "@/domain/work-orders/use-cases/use-work-order-detail";
import type { WorkOrderBoardListResult } from "@/domain/work-orders/types/work-order-board"; import type { WorkOrderBoardListResult } from "@/domain/work-orders/types/work-order-board";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { UseQueryResult } from "@tanstack/react-query"; import type { UseQueryResult } from "@tanstack/react-query";
type FiltersReturn = ReturnType<typeof useWorkOrdersListFilters>; type FiltersReturn = ReturnType<typeof useWorkOrdersListFilters>;
const EMPTY_UNASSIGNED_QUEUE: WorkOrderTableRow[] = [];
export type WorkOrdersBoardSource = { export type WorkOrdersBoardSource = {
advActive: boolean; advActive: boolean;
data: WorkOrderBoardListResult | undefined; data: WorkOrderBoardListResult | undefined;
unassignedQueue: WorkOrderTableRow[];
unassignedError: Error | null;
isLoading: boolean; isLoading: boolean;
isFetching: boolean; isFetching: boolean;
error: Error | null; error: Error | null;
@ -67,13 +73,30 @@ export function useWorkOrdersBoardSource(filters: FiltersReturn): WorkOrdersBoar
error: searchError, error: searchError,
refetch: refetchSearch, refetch: refetchSearch,
} = useWorkOrdersBoardSearch(resolvedSearchParams ?? { datePreset: "ThisWeek" }, advActive); } = useWorkOrdersBoardSearch(resolvedSearchParams ?? { datePreset: "ThisWeek" }, advActive);
const {
data: queueData,
isFetching: queueFetching,
error: queueError,
refetch: refetchQueue,
} = useWorkOrdersUnassignedQueue(!advActive);
const refetchBoardWithQueue: UseQueryResult<WorkOrderBoardListResult, Error>["refetch"] =
useCallback(
async (options) => {
const [boardResult] = await Promise.all([refetchBoard(options), refetchQueue()]);
return boardResult;
},
[refetchBoard, refetchQueue],
);
return { return {
advActive, advActive,
data: advActive ? searchData : boardData, data: advActive ? searchData : boardData,
unassignedQueue: advActive ? EMPTY_UNASSIGNED_QUEUE : (queueData ?? EMPTY_UNASSIGNED_QUEUE),
unassignedError: advActive ? null : (queueError ?? null),
isLoading: advActive ? searchLoading : boardLoading, isLoading: advActive ? searchLoading : boardLoading,
isFetching: advActive ? searchFetching : boardFetching, isFetching: advActive ? searchFetching : boardFetching || queueFetching,
error: advActive ? searchError : boardError, error: advActive ? searchError : boardError,
refetch: advActive ? refetchSearch : refetchBoard, refetch: advActive ? refetchSearch : refetchBoardWithQueue,
}; };
} }

View file

@ -3,11 +3,14 @@ import type { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hoo
import { useWorkOrdersBoardSource } from "@/app/(protected)/workorders/_hooks/use-work-orders-board-source"; import { useWorkOrdersBoardSource } from "@/app/(protected)/workorders/_hooks/use-work-orders-board-source";
import { useWorkOrdersTableLocalState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-local-state"; import { useWorkOrdersTableLocalState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-local-state";
import { currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils"; import { currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { buildWeekSkeleton, deriveDayFields } from "@/domain/work-orders/utils/table-day-utils"; import { buildWeekSkeleton, deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
import { import {
buildDayGroups, buildDayGroups,
composeBoardSections,
filterByBarAndSearch, filterByBarAndSearch,
partitionBoardRows, mergeWeeklyAndUnassignedQueue,
rowIdSet,
splitScheduledUnscheduled, splitScheduledUnscheduled,
} from "@/domain/work-orders/utils/table-filter-utils"; } from "@/domain/work-orders/utils/table-filter-utils";
@ -15,12 +18,30 @@ export type { DragVizState } from "@/app/(protected)/workorders/_hooks/use-work-
type FiltersReturn = ReturnType<typeof useWorkOrdersListFilters>; type FiltersReturn = ReturnType<typeof useWorkOrdersListFilters>;
const EMPTY_WEEKLY_ITEMS: WorkOrderTableRow[] = [];
export function useWorkOrdersTableData(filters: FiltersReturn) { export function useWorkOrdersTableData(filters: FiltersReturn) {
const { advActive, data, isLoading, isFetching, error, refetch } = const {
useWorkOrdersBoardSource(filters); advActive,
data,
unassignedQueue,
unassignedError,
isLoading,
isFetching,
error,
refetch,
} = useWorkOrdersBoardSource(filters);
const weeklyItems = data?.items ?? EMPTY_WEEKLY_ITEMS;
const items = useMemo(
() => mergeWeeklyAndUnassignedQueue(weeklyItems, unassignedQueue),
[weeklyItems, unassignedQueue],
);
const weeklyIds = useMemo(() => rowIdSet(weeklyItems), [weeklyItems]);
const queueIds = useMemo(() => rowIdSet(unassignedQueue), [unassignedQueue]);
const local = useWorkOrdersTableLocalState({ const local = useWorkOrdersTableLocalState({
items: data?.items ?? [], items,
weekMonday: filters.weekMonday, weekMonday: filters.weekMonday,
advActive, advActive,
isFetching, isFetching,
@ -44,8 +65,13 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
const split = splitScheduledUnscheduled(advResults); const split = splitScheduledUnscheduled(advResults);
return { unassigned: [], ...split }; return { unassigned: [], ...split };
} }
return partitionBoardRows(filtered); return composeBoardSections({
}, [advActive, advResults, filtered]); displayRows,
weekRows: filtered,
weeklyIds,
queueIds,
});
}, [advActive, advResults, displayRows, filtered, weeklyIds, queueIds]);
const dayGroups = useMemo(() => buildDayGroups(scheduled), [scheduled]); const dayGroups = useMemo(() => buildDayGroups(scheduled), [scheduled]);
const weekSkeleton = useMemo(() => buildWeekSkeleton(filters.weekMonday), [filters.weekMonday]); const weekSkeleton = useMemo(() => buildWeekSkeleton(filters.weekMonday), [filters.weekMonday]);
@ -80,6 +106,7 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
advActive, advActive,
advResults, advResults,
unassigned, unassigned,
unassignedError,
unscheduled, unscheduled,
displayGroups, displayGroups,
searchActive, searchActive,
@ -111,7 +138,7 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
scopeTotalCount: data?.totalCount ?? 0, scopeTotalCount: data?.totalCount ?? 0,
totalCount: advActive totalCount: advActive
? (data?.totalCount ?? advResults.length) ? (data?.totalCount ?? advResults.length)
: (data?.returnedCount ?? visibleRows.length), : (data?.returnedCount ?? weeklyItems.length),
search: filters.search, search: filters.search,
}; };
} }

View file

@ -52,7 +52,7 @@ export function useWorkOrdersTableUiState(weekMonday: string) {
(key: string, scheduledIso?: string) => { (key: string, scheduledIso?: string) => {
setDraftDayKey(key); setDraftDayKey(key);
// Unscheduled "+ Add WO" starts with no schedule (SH-109); day groups prefill the day. // Unscheduled "+ Add WO" starts with no schedule (SH-109); day groups prefill the day.
if (key === UNSCHEDULED_DAY_KEY) { if (key === UNSCHEDULED_DAY_KEY || key === UNASSIGNED_DAY_KEY) {
setDraft({ ...EMPTY_TABLE_DRAFT }); setDraft({ ...EMPTY_TABLE_DRAFT });
return; return;
} }

View file

@ -0,0 +1,29 @@
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { toUnassignedQueueQueryParams } from "@/domain/work-orders/utils/board-query-params";
const UNASSIGNED_QUEUE_MAX_PAGES = 50;
/** Exhausts GET /board/search pages for the global Unassigned pin (SH-165). */
export async function fetchUnassignedBoardRows(): Promise<WorkOrderTableRow[]> {
const rows: WorkOrderTableRow[] = [];
let page = 0;
let totalCount = Number.POSITIVE_INFINITY;
while (rows.length < totalCount) {
if (page >= UNASSIGNED_QUEUE_MAX_PAGES) {
throw new Error(
"Unable to load the unassigned queue: search result set exceeded the safety page limit.",
);
}
const result = await workOrdersApi.searchBoard(toUnassignedQueueQueryParams(page));
rows.push(...result.items);
totalCount = result.totalCount;
if (result.items.length === 0) {
break;
}
page += 1;
}
return rows;
}

View file

@ -41,6 +41,18 @@ export const LIFECYCLE_STATUS_API = {
export type LifecycleStatusApi = (typeof LIFECYCLE_STATUS_API)[keyof typeof LIFECYCLE_STATUS_API]; export type LifecycleStatusApi = (typeof LIFECYCLE_STATUS_API)[keyof typeof LIFECYCLE_STATUS_API];
/** Non-terminal lifecycle statuses for the global Unassigned queue (SH-165). */
export const NON_TERMINAL_LIFECYCLE_STATUSES: LifecycleStatusApi[] = [
LIFECYCLE_STATUS_API.Incomplete,
LIFECYCLE_STATUS_API.Pending,
LIFECYCLE_STATUS_API.Scheduled,
LIFECYCLE_STATUS_API["En Route"],
LIFECYCLE_STATUS_API["On Site"],
LIFECYCLE_STATUS_API["In Progress"],
LIFECYCLE_STATUS_API.Rescheduled,
LIFECYCLE_STATUS_API["Pending Quote"],
];
export const WORK_ORDER_TYPE_API_TO_FE: Record<number, WOType | ""> = { export const WORK_ORDER_TYPE_API_TO_FE: Record<number, WOType | ""> = {
[WORK_ORDER_TYPE_API.PM]: "PM", [WORK_ORDER_TYPE_API.PM]: "PM",
[WORK_ORDER_TYPE_API.Reactive]: "Reactive", [WORK_ORDER_TYPE_API.Reactive]: "Reactive",

View file

@ -1,4 +1,5 @@
import { keepPreviousData, useQuery, type UseQueryResult } from "@tanstack/react-query"; import { keepPreviousData, useQuery, type UseQueryResult } from "@tanstack/react-query";
import { fetchUnassignedBoardRows } from "@/domain/work-orders/api/fetch-unassigned-board-rows";
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api"; import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
import type { import type {
WorkOrdersListParams, WorkOrdersListParams,
@ -10,6 +11,7 @@ import type {
WorkOrderBoardQueryParams, WorkOrderBoardQueryParams,
WorkOrderBoardSearchQueryParams, WorkOrderBoardSearchQueryParams,
} from "@/domain/work-orders/types/work-order-board"; } from "@/domain/work-orders/types/work-order-board";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { queryKeys } from "@/infra/query-key/query-key"; import { queryKeys } from "@/infra/query-key/query-key";
export function useWorkOrdersList( export function useWorkOrdersList(
@ -46,6 +48,17 @@ export function useWorkOrdersBoardSearch(
}); });
} }
export function useWorkOrdersUnassignedQueue(
enabled = true,
): UseQueryResult<WorkOrderTableRow[], Error> {
return useQuery({
queryKey: queryKeys.workOrders.unassignedQueue(),
queryFn: fetchUnassignedBoardRows,
enabled,
placeholderData: keepPreviousData,
});
}
export function useWorkOrderUsers(): UseQueryResult<WorkOrderUser[], Error> { export function useWorkOrderUsers(): UseQueryResult<WorkOrderUser[], Error> {
return useQuery({ return useQuery({
queryKey: queryKeys.workOrders.dispatchers(), queryKey: queryKeys.workOrders.dispatchers(),

View file

@ -6,6 +6,7 @@ import {
DATE_RANGE_TO_PRESET, DATE_RANGE_TO_PRESET,
DOC_STATUS_API, DOC_STATUS_API,
LIFECYCLE_STATUS_API, LIFECYCLE_STATUS_API,
NON_TERMINAL_LIFECYCLE_STATUSES,
type WorkOrderBoardQueryParams, type WorkOrderBoardQueryParams,
type WorkOrderBoardSearchQueryParams, type WorkOrderBoardSearchQueryParams,
} from "@/domain/work-orders/types/work-order-board"; } from "@/domain/work-orders/types/work-order-board";
@ -16,6 +17,14 @@ import type { WizardWOStatus, WOType } from "@/domain/work-orders/types/work-ord
export const BOARD_SEARCH_MAX_PAGE_SIZE = 100; export const BOARD_SEARCH_MAX_PAGE_SIZE = 100;
/**
* Wide Custom window for SH-165. GET /board/search defaults omitted
* `datePreset` to ThisWeek (`WorkOrderSearchDateRangeResolver`), which would
* hide scheduled-unassigned WOs outside the current week.
*/
export const UNASSIGNED_QUEUE_DATE_FROM = "1970-01-01";
export const UNASSIGNED_QUEUE_DATE_TO = "2099-12-31";
function toBoardDispatcherId(id: string): string { function toBoardDispatcherId(id: string): string {
if (id === ASSIGNEE_FILTER_UNASSIGNED || id === BOARD_DISPATCHER_UNASSIGNED) { if (id === ASSIGNEE_FILTER_UNASSIGNED || id === BOARD_DISPATCHER_UNASSIGNED) {
return BOARD_DISPATCHER_UNASSIGNED; return BOARD_DISPATCHER_UNASSIGNED;
@ -175,3 +184,16 @@ export function buildBoardSearchParamsRecord(
export function woTypeFilterIsOverdue(type: string): boolean { export function woTypeFilterIsOverdue(type: string): boolean {
return type === "Overdue"; return type === "Overdue";
} }
/** Week-independent GET /board/search params for the pinned Unassigned queue. */
export function toUnassignedQueueQueryParams(page = 0): WorkOrderBoardSearchQueryParams {
return {
page,
pageSize: BOARD_SEARCH_MAX_PAGE_SIZE,
dispatchers: [BOARD_DISPATCHER_UNASSIGNED],
statuses: [...NON_TERMINAL_LIFECYCLE_STATUSES],
datePreset: "Custom",
dateFrom: UNASSIGNED_QUEUE_DATE_FROM,
dateTo: UNASSIGNED_QUEUE_DATE_TO,
};
}

View file

@ -129,6 +129,51 @@ export function partitionBoardRows(rows: WorkOrderTableRow[]): {
return { unassigned, unscheduled, scheduled }; return { unassigned, unscheduled, scheduled };
} }
export function rowIdSet(rows: WorkOrderTableRow[]): Set<string> {
return new Set(rows.map((row) => String(row.id)));
}
/** Weekly board rows win on id overlap so PATCH state stays authoritative. */
export function mergeWeeklyAndUnassignedQueue(
weekly: WorkOrderTableRow[],
queue: WorkOrderTableRow[],
): WorkOrderTableRow[] {
if (queue.length === 0) {
return weekly;
}
const seen = rowIdSet(weekly);
const extras = queue.filter((row) => !seen.has(String(row.id)));
return extras.length === 0 ? weekly : [...weekly, ...extras];
}
type ComposeBoardSectionsInput = {
displayRows: WorkOrderTableRow[];
weekRows: WorkOrderTableRow[];
weeklyIds: ReadonlySet<string>;
queueIds: ReadonlySet<string>;
};
/**
* Pin is every non-terminal unassigned row (including out-of-week).
* Day/Unscheduled groups stay week-scoped and never repeat pin ids.
*/
export function composeBoardSections(input: ComposeBoardSectionsInput): {
unassigned: WorkOrderTableRow[];
unscheduled: WorkOrderTableRow[];
scheduled: WorkOrderTableRow[];
} {
const unassigned = input.displayRows.filter(isUnassignedBoardRow);
const pinIds = rowIdSet(unassigned);
const weekAssigned = input.weekRows.filter((row) => {
const id = String(row.id);
if (pinIds.has(id) || isUnassignedBoardRow(row)) {
return false;
}
return input.weeklyIds.has(id) || !input.queueIds.has(id);
});
return { unassigned, ...splitScheduledUnscheduled(weekAssigned) };
}
export function splitScheduledUnscheduled(rows: WorkOrderTableRow[]): { export function splitScheduledUnscheduled(rows: WorkOrderTableRow[]): {
scheduled: WorkOrderTableRow[]; scheduled: WorkOrderTableRow[];
unscheduled: WorkOrderTableRow[]; unscheduled: WorkOrderTableRow[];

View file

@ -17,6 +17,7 @@ export const queryKeys = {
[...queryKeys.workOrders.all, "board", params ?? {}] as const, [...queryKeys.workOrders.all, "board", params ?? {}] as const,
boardSearch: (params?: WorkOrderBoardSearchQueryParams) => boardSearch: (params?: WorkOrderBoardSearchQueryParams) =>
[...queryKeys.workOrders.all, "boardSearch", params ?? {}] as const, [...queryKeys.workOrders.all, "boardSearch", params ?? {}] as const,
unassignedQueue: () => [...queryKeys.workOrders.all, "unassignedQueue"] as const,
byLocation: (locationId: string | number, search = "") => byLocation: (locationId: string | number, search = "") =>
[...queryKeys.workOrders.all, "byLocation", locationId, search] as const, [...queryKeys.workOrders.all, "byLocation", locationId, search] as const,
detail: (id: string | number) => [...queryKeys.workOrders.all, "detail", id] as const, detail: (id: string | number) => [...queryKeys.workOrders.all, "detail", id] as const,

View file

@ -11,9 +11,11 @@ function makeRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
woNumber: "24991", woNumber: "24991",
site: "CLV2", site: "CLV2",
type: "PM", type: "PM",
severity: null,
dispatcherId: "", dispatcherId: "",
dispatcherName: "", dispatcherName: "",
scheduledOn: "2026-06-02", scheduledOn: "2026-06-02",
completedDate: "",
targetWeek: "", targetWeek: "",
dayGroup: "06/02", dayGroup: "06/02",
dayLabel: "TUE · 06/02", dayLabel: "TUE · 06/02",
@ -64,6 +66,7 @@ function baseTableData(
advActive: false, advActive: false,
advResults: [], advResults: [],
unassigned: [], unassigned: [],
unassignedError: null,
unscheduled: [], unscheduled: [],
displayGroups: [], displayGroups: [],
searchActive: false, searchActive: false,
@ -202,4 +205,51 @@ describe("WoTableBody Unassigned queue (SH-165)", () => {
await user.click(screen.getByRole("button", { name: "Unassigned, 1 work order, expanded" })); await user.click(screen.getByRole("button", { name: "Unassigned, 1 work order, expanded" }));
expect(setUnassignedOpen).toHaveBeenCalledWith(expect.any(Function)); expect(setUnassignedOpen).toHaveBeenCalledWith(expect.any(Function));
}); });
it("renders a scheduled unassigned WO from outside the selected week in the pin", () => {
const outOfWeek = makeRow({
id: "out-week",
woNumber: "26001",
scheduledOn: "2026-07-15",
dayGroup: "07/15",
dayLabel: "WED · 07/15",
});
render(
<table>
<WoTableBody
tableData={baseTableData({ unassigned: [outOfWeek] })}
renderRow={(row) => (
<tr key={String(row.id)} data-testid={`row-${row.id}`}>
<td>{row.woNumber}</td>
</tr>
)}
renderInlineRow={() => null}
/>
</table>,
);
expect(screen.getByTestId("row-out-week")).toBeInTheDocument();
expect(screen.queryByText("WED · 07/15")).not.toBeInTheDocument();
});
it("keeps the Unassigned pin visible when the queue query fails", () => {
render(
<table>
<WoTableBody
tableData={baseTableData({
unassignedError: new Error("queue unavailable"),
})}
renderRow={(row) => (
<tr key={String(row.id)} data-testid={`row-${row.id}`}>
<td>{row.woNumber}</td>
</tr>
)}
renderInlineRow={() => null}
/>
</table>,
);
expect(screen.getByText("queue unavailable")).toBeInTheDocument();
});
}); });

View file

@ -75,6 +75,10 @@ const handlers: WoTableRowHandlers = {
function renderUnscheduledHarness() { function renderUnscheduledHarness() {
const tableData = { const tableData = {
advActive: false, advActive: false,
unassigned: [],
unassignedError: null,
unassignedOpen: true,
setUnassignedOpen: vi.fn(),
unscheduled: [unscheduledRow], unscheduled: [unscheduledRow],
unschedOpen: true, unschedOpen: true,
draftDayKey: null, draftDayKey: null,

View file

@ -0,0 +1,52 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { BOARD_SEARCH_MAX_PAGE_SIZE } from "@/domain/work-orders/utils/board-query-params";
const searchBoard = vi.fn();
vi.mock("@/domain/work-orders/api/work-orders-api", () => ({
workOrdersApi: {
searchBoard: (...args: unknown[]) => searchBoard(...args),
},
}));
import { fetchUnassignedBoardRows } from "@/domain/work-orders/api/fetch-unassigned-board-rows";
function pageResult(items: Pick<WorkOrderTableRow, "id">[], totalCount: number) {
return {
items,
scheduled: [],
unscheduled: [],
totalCount,
returnedCount: items.length,
page: 0,
pageSize: BOARD_SEARCH_MAX_PAGE_SIZE,
};
}
describe("fetchUnassignedBoardRows", () => {
beforeEach(() => {
searchBoard.mockReset();
});
it("pages until totalCount is exhausted", async () => {
const first = Array.from({ length: BOARD_SEARCH_MAX_PAGE_SIZE }, (_, index) => ({
id: `a-${index}`,
}));
const second = [{ id: "b-0" }];
searchBoard
.mockResolvedValueOnce(pageResult(first, 101))
.mockResolvedValueOnce(pageResult(second, 101));
const rows = await fetchUnassignedBoardRows();
expect(searchBoard).toHaveBeenCalledTimes(2);
expect(searchBoard.mock.calls[0]?.[0]).toMatchObject({
page: 0,
dispatchers: ["__unassigned__"],
datePreset: "Custom",
});
expect(searchBoard.mock.calls[1]?.[0]).toMatchObject({ page: 1 });
expect(rows).toHaveLength(101);
});
});

View file

@ -7,6 +7,9 @@ import {
buildBoardSearchParamsRecord, buildBoardSearchParamsRecord,
toBoardQueryParams, toBoardQueryParams,
toBoardSearchQueryParams, toBoardSearchQueryParams,
toUnassignedQueueQueryParams,
UNASSIGNED_QUEUE_DATE_FROM,
UNASSIGNED_QUEUE_DATE_TO,
} from "@/domain/work-orders/utils/board-query-params"; } from "@/domain/work-orders/utils/board-query-params";
describe("toBoardQueryParams", () => { describe("toBoardQueryParams", () => {
@ -174,3 +177,21 @@ describe("buildBoardSearchParamsRecord", () => {
expect(Object.keys(record)).not.toContain("types[]"); expect(Object.keys(record)).not.toContain("types[]");
}); });
}); });
describe("toUnassignedQueueQueryParams", () => {
it("requests unassigned dispatchers with a Custom range, not the weekly board window", () => {
const params = toUnassignedQueueQueryParams(2);
const record = buildBoardSearchParamsRecord(params);
expect(params.page).toBe(2);
expect(params.pageSize).toBe(BOARD_SEARCH_MAX_PAGE_SIZE);
expect(params.dispatchers).toEqual(["__unassigned__"]);
expect(params.statuses).not.toContain(7);
expect(params.statuses).not.toContain(9);
expect(params.datePreset).toBe("Custom");
expect(params.dateFrom).toBe(UNASSIGNED_QUEUE_DATE_FROM);
expect(params.dateTo).toBe(UNASSIGNED_QUEUE_DATE_TO);
expect(record).not.toHaveProperty("weekStart");
expect(record).not.toHaveProperty("weekEnd");
});
});

View file

@ -4,11 +4,13 @@ import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-ad
import type { WorkOrderBarFilters } from "@/domain/work-orders/types/work-order-list-filters"; import type { WorkOrderBarFilters } from "@/domain/work-orders/types/work-order-list-filters";
import { import {
buildDayGroups, buildDayGroups,
composeBoardSections,
computeIsPastDue, computeIsPastDue,
enrichTableRow, enrichTableRow,
filterByBarAndSearch, filterByBarAndSearch,
isUnassignedBoardRow, isUnassignedBoardRow,
matchesAdvancedFilters, matchesAdvancedFilters,
mergeWeeklyAndUnassignedQueue,
partitionBoardRows, partitionBoardRows,
splitScheduledUnscheduled, splitScheduledUnscheduled,
} from "@/domain/work-orders/utils/table-filter-utils"; } from "@/domain/work-orders/utils/table-filter-utils";
@ -184,6 +186,69 @@ describe("partitionBoardRows", () => {
}); });
}); });
describe("mergeWeeklyAndUnassignedQueue", () => {
it("keeps the weekly row when the same id is also in the queue", () => {
const weekly = makeRow({ id: "dup", dispatcherName: "Weekly" });
const queue = makeRow({ id: "dup", dispatcherName: "Queue" });
const extra = makeRow({ id: "out", dispatcherId: "", dispatcherName: "" });
const merged = mergeWeeklyAndUnassignedQueue([weekly], [queue, extra]);
expect(merged.map((row) => row.id)).toEqual(["dup", "out"]);
expect(merged[0]?.dispatcherName).toBe("Weekly");
});
});
describe("composeBoardSections", () => {
it("pins a scheduled unassigned WO that is outside the selected week", () => {
const inWeekAssigned = makeRow({
id: "in-week",
dispatcherId: "sarah",
dispatcherName: "Sarah",
scheduledOn: "2026-06-03",
...deriveDayFields("2026-06-03"),
});
const outOfWeekUnassigned = makeRow({
id: "out-week",
dispatcherId: "",
dispatcherName: "",
scheduledOn: "2026-07-15",
...deriveDayFields("2026-07-15"),
});
const result = composeBoardSections({
displayRows: [inWeekAssigned, outOfWeekUnassigned],
weekRows: [inWeekAssigned],
weeklyIds: new Set(["in-week"]),
queueIds: new Set(["out-week"]),
});
expect(result.unassigned.map((row) => row.id)).toEqual(["out-week"]);
expect(result.scheduled.map((row) => row.id)).toEqual(["in-week"]);
expect(result.unscheduled).toEqual([]);
});
it("does not place an assigned leftover queue row into the current week groups", () => {
const assignedOutOfWeek = makeRow({
id: "was-queue",
dispatcherId: "sarah",
dispatcherName: "Sarah",
scheduledOn: "2026-07-15",
...deriveDayFields("2026-07-15"),
});
const result = composeBoardSections({
displayRows: [assignedOutOfWeek],
weekRows: [assignedOutOfWeek],
weeklyIds: new Set(),
queueIds: new Set(["was-queue"]),
});
expect(result.unassigned).toEqual([]);
expect(result.scheduled).toEqual([]);
expect(result.unscheduled).toEqual([]);
});
});
describe("splitScheduledUnscheduled", () => { describe("splitScheduledUnscheduled", () => {
it("splits rows without scheduledOn into unscheduled", () => { it("splits rows without scheduledOn into unscheduled", () => {
const scheduled = makeRow({ id: "a", scheduledOn: "2026-06-23" }); const scheduled = makeRow({ id: "a", scheduledOn: "2026-06-23" });