mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-05 07:12:06 +00:00
fix(work-orders): load SH-165 Unassigned queue from global board search
This commit is contained in:
parent
e1667702e1
commit
958467fe40
15 changed files with 393 additions and 15 deletions
|
|
@ -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))}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
29
src/domain/work-orders/api/fetch-unassigned-board-rows.ts
Normal file
29
src/domain/work-orders/api/fetch-unassigned-board-rows.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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[];
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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" });
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue