diff --git a/src/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell.tsx index 92557ea2..42a1bf93 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell.tsx @@ -75,6 +75,18 @@ export function AssignedToCell({ dispatcherId, users, highlight, onSave }: Assig />
+ {filtered.map((d) => ( + {Boolean(onAddWo) && ( + + )} +
+ + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row.tsx index 679a0c3b..83e94733 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row.tsx @@ -10,7 +10,10 @@ import type { WorkOrderTableDraft, WorkOrderTableRow, } from "@/domain/work-orders/types/work-order-table-row"; -import { UNSCHEDULED_DAY_KEY } from "@/domain/work-orders/types/work-order-table-row"; +import { + UNASSIGNED_DAY_KEY, + UNSCHEDULED_DAY_KEY, +} from "@/domain/work-orders/types/work-order-table-row"; import { draftHasScheduleDate, getInlineCreateMissing, @@ -66,7 +69,7 @@ export function WoTableInlineRow({ onCancel, onDuplicateFound, }: WoTableInlineRowProps) { - const weekDefault = draftDayKey === UNSCHEDULED_DAY_KEY; + const weekDefault = draftDayKey === UNSCHEDULED_DAY_KEY || draftDayKey === UNASSIGNED_DAY_KEY; const missing = getInlineCreateMissing(draft); const canSave = !saving && missing.length === 0; const [showErrors, setShowErrors] = useState(false); diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-section-header.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-section-header.tsx index 6b22e968..a730c7f5 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-section-header.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-section-header.tsx @@ -1,14 +1,15 @@ -import { AlertTriangle, ChevronDown, ChevronRight, Plus } from "lucide-react"; +import { AlertTriangle, Plus, Users } from "lucide-react"; import { WO_TABLE_COLS } from "./wo-table-cols"; +import { WoTableCollapsibleSectionHeader } from "./wo-table-collapsible-section-header"; type WoTableSectionHeaderProps = { - variant: "unscheduled" | "day"; + variant: "unassigned" | "unscheduled" | "day"; label: string; count: number; isToday?: boolean; collapsed?: boolean; onToggleCollapse?: () => void; - onAddWo: () => void; + onAddWo?: () => void; }; export function WoTableSectionHeader({ @@ -20,58 +21,30 @@ export function WoTableSectionHeader({ onToggleCollapse, onAddWo, }: WoTableSectionHeaderProps) { - const isUnscheduled = variant === "unscheduled"; - - if (isUnscheduled) { + if (variant === "unassigned") { return ( - - -
- - - {label} - - - {count} work order{count !== 1 ? "s" : ""} - - - - {collapsed ? : } - -
- - + onToggleCollapse?.()} + /> + ); + } + + if (variant === "unscheduled") { + return ( + onToggleCollapse?.()} + onAddWo={onAddWo} + /> ); } @@ -125,18 +98,17 @@ export function WoTableSectionHeader({ Today )} - + {Boolean(onAddWo) && ( + + )} diff --git a/src/app/(protected)/workorders/_hooks/use-work-orders-board-source.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-board-source.ts index 7522bb47..914bf835 100644 --- a/src/app/(protected)/workorders/_hooks/use-work-orders-board-source.ts +++ b/src/app/(protected)/workorders/_hooks/use-work-orders-board-source.ts @@ -1,22 +1,29 @@ -import { useMemo } from "react"; +import { useCallback, useMemo } from "react"; 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 { useWorkOrdersBoard, useWorkOrdersBoardSearch, + useWorkOrdersUnassignedQueue, } from "@/domain/work-orders/use-cases/use-work-orders-list"; import { useWorkOrderLocations, useWorkOrderVendors, } from "@/domain/work-orders/use-cases/use-work-order-detail"; 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"; type FiltersReturn = ReturnType; +const EMPTY_UNASSIGNED_QUEUE: WorkOrderTableRow[] = []; + export type WorkOrdersBoardSource = { advActive: boolean; data: WorkOrderBoardListResult | undefined; + unassignedQueue: WorkOrderTableRow[]; + unassignedError: Error | null; + unassignedLoading: boolean; isLoading: boolean; isFetching: boolean; error: Error | null; @@ -67,13 +74,35 @@ export function useWorkOrdersBoardSource(filters: FiltersReturn): WorkOrdersBoar error: searchError, refetch: refetchSearch, } = useWorkOrdersBoardSearch(resolvedSearchParams ?? { datePreset: "ThisWeek" }, advActive); + const { + data: queueData, + isLoading: queueLoading, + isFetching: queueFetching, + error: queueError, + refetch: refetchQueue, + } = useWorkOrdersUnassignedQueue(!advActive); + + const refetchBoardWithQueue: UseQueryResult["refetch"] = + useCallback( + async (options) => { + const [boardResult] = await Promise.all([refetchBoard(options), refetchQueue()]); + return boardResult; + }, + [refetchBoard, refetchQueue], + ); return { advActive, data: advActive ? searchData : boardData, + unassignedQueue: advActive ? EMPTY_UNASSIGNED_QUEUE : (queueData ?? EMPTY_UNASSIGNED_QUEUE), + unassignedError: advActive ? null : (queueError ?? null), + // SH-165: the pinned queue has its own first-load state. Folding it into + // isFetching only made "still paging board/search" indistinguishable from + // "zero unassigned", so the pin announced an empty queue while loading. + unassignedLoading: advActive ? false : queueLoading, isLoading: advActive ? searchLoading : boardLoading, - isFetching: advActive ? searchFetching : boardFetching, + isFetching: advActive ? searchFetching : boardFetching || queueFetching, error: advActive ? searchError : boardError, - refetch: advActive ? refetchSearch : refetchBoard, + refetch: advActive ? refetchSearch : refetchBoardWithQueue, }; } diff --git a/src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts index 09f819b6..752272de 100644 --- a/src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts +++ b/src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts @@ -3,10 +3,14 @@ import type { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hoo 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 { 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 { buildDayGroups, + composeBoardSections, filterByBarAndSearch, + mergeWeeklyAndUnassignedQueue, + rowIdSet, splitScheduledUnscheduled, } from "@/domain/work-orders/utils/table-filter-utils"; @@ -14,12 +18,31 @@ export type { DragVizState } from "@/app/(protected)/workorders/_hooks/use-work- type FiltersReturn = ReturnType; +const EMPTY_WEEKLY_ITEMS: WorkOrderTableRow[] = []; + export function useWorkOrdersTableData(filters: FiltersReturn) { - const { advActive, data, isLoading, isFetching, error, refetch } = - useWorkOrdersBoardSource(filters); + const { + advActive, + data, + unassignedQueue, + unassignedError, + unassignedLoading, + 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({ - items: data?.items ?? [], + items, weekMonday: filters.weekMonday, advActive, isFetching, @@ -38,10 +61,18 @@ export function useWorkOrdersTableData(filters: FiltersReturn) { return displayRows; }, [displayRows, filters.barFilters, filters.search, advActive]); - const { scheduled, unscheduled } = useMemo( - () => (advActive ? splitScheduledUnscheduled(advResults) : splitScheduledUnscheduled(filtered)), - [advActive, advResults, filtered], - ); + const { unassigned, scheduled, unscheduled } = useMemo(() => { + if (advActive) { + const split = splitScheduledUnscheduled(advResults); + return { unassigned: [], ...split }; + } + return composeBoardSections({ + displayRows, + weekRows: filtered, + weeklyIds, + queueIds, + }); + }, [advActive, advResults, displayRows, filtered, weeklyIds, queueIds]); const dayGroups = useMemo(() => buildDayGroups(scheduled), [scheduled]); const weekSkeleton = useMemo(() => buildWeekSkeleton(filters.weekMonday), [filters.weekMonday]); @@ -49,7 +80,12 @@ export function useWorkOrdersTableData(filters: FiltersReturn) { const searchActive = filters.search.trim() !== ""; const visibleRows = advActive ? advResults : filtered; const noSearchResults = visibleRows.length === 0 && searchActive; - const isEmptyWeek = !advActive && scheduled.length === 0 && !searchActive; + const isEmptyWeek = + !advActive && + scheduled.length === 0 && + unassigned.length === 0 && + unscheduled.length === 0 && + !searchActive; const displayGroups = isEmptyWeek ? weekSkeleton : dayGroups; const todayKey = @@ -70,6 +106,9 @@ export function useWorkOrdersTableData(filters: FiltersReturn) { refetch, advActive, advResults, + unassigned, + unassignedError, + unassignedLoading, unscheduled, displayGroups, searchActive, @@ -79,6 +118,8 @@ export function useWorkOrdersTableData(filters: FiltersReturn) { draftDayKey: local.draftDayKey, draft: local.draft, setDraft: local.setDraft, + unassignedOpen: local.unassignedOpen, + setUnassignedOpen: local.setUnassignedOpen, unschedOpen: local.unschedOpen, setUnschedOpen: local.setUnschedOpen, selectedId: local.selectedId, @@ -99,7 +140,7 @@ export function useWorkOrdersTableData(filters: FiltersReturn) { scopeTotalCount: data?.totalCount ?? 0, totalCount: advActive ? (data?.totalCount ?? advResults.length) - : (data?.returnedCount ?? visibleRows.length), + : (data?.returnedCount ?? weeklyItems.length), search: filters.search, }; } diff --git a/src/app/(protected)/workorders/_hooks/use-work-orders-table-local-state.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-table-local-state.ts index 633db819..9401ba1f 100644 --- a/src/app/(protected)/workorders/_hooks/use-work-orders-table-local-state.ts +++ b/src/app/(protected)/workorders/_hooks/use-work-orders-table-local-state.ts @@ -158,6 +158,8 @@ export function useWorkOrdersTableLocalState({ setDraft: ui.setDraft, startDayDraft: ui.startDayDraft, cancelDraft: ui.cancelDraft, + unassignedOpen: ui.unassignedOpen, + setUnassignedOpen: ui.setUnassignedOpen, unschedOpen: ui.unschedOpen, setUnschedOpen: ui.setUnschedOpen, selectedId: ui.selectedId, diff --git a/src/app/(protected)/workorders/_hooks/use-work-orders-table-ui-state.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-table-ui-state.ts index 82cd07c6..196bfe69 100644 --- a/src/app/(protected)/workorders/_hooks/use-work-orders-table-ui-state.ts +++ b/src/app/(protected)/workorders/_hooks/use-work-orders-table-ui-state.ts @@ -1,6 +1,7 @@ import { useCallback, useRef, useState } from "react"; import { EMPTY_TABLE_DRAFT, + UNASSIGNED_DAY_KEY, UNSCHEDULED_DAY_KEY, type WorkOrderTableDraft, } from "@/domain/work-orders/types/work-order-table-row"; @@ -11,7 +12,7 @@ export type DragVizState = { }; function resolveDraftScheduledOn(key: string, weekMonday: string, scheduledIso?: string): string { - if (key === UNSCHEDULED_DAY_KEY) { + if (key === UNSCHEDULED_DAY_KEY || key === UNASSIGNED_DAY_KEY) { return ""; } if (scheduledIso && /^\d{4}-\d{2}-\d{2}$/.test(scheduledIso)) { @@ -31,10 +32,11 @@ function resolveDraftScheduledOn(key: string, weekMonday: string, scheduledIso?: return ""; } -/** Draft, selection, unsched accordion, drag viz and flash scroll helpers. */ +/** Draft, selection, pinned-section accordions, drag viz and flash scroll helpers. */ export function useWorkOrdersTableUiState(weekMonday: string) { const [draftDayKey, setDraftDayKey] = useState(null); const [draft, setDraft] = useState(EMPTY_TABLE_DRAFT); + const [unassignedOpen, setUnassignedOpen] = useState(true); const [unschedOpen, setUnschedOpen] = useState(true); const [selectedId, setSelectedId] = useState(null); const [flashId, setFlashId] = useState(null); @@ -50,7 +52,7 @@ export function useWorkOrdersTableUiState(weekMonday: string) { (key: string, scheduledIso?: string) => { setDraftDayKey(key); // 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 }); return; } @@ -85,6 +87,8 @@ export function useWorkOrdersTableUiState(weekMonday: string) { setDraft, startDayDraft, cancelDraft, + unassignedOpen, + setUnassignedOpen, unschedOpen, setUnschedOpen, selectedId, diff --git a/src/domain/work-orders/api/fetch-unassigned-board-rows.ts b/src/domain/work-orders/api/fetch-unassigned-board-rows.ts new file mode 100644 index 00000000..4887496a --- /dev/null +++ b/src/domain/work-orders/api/fetch-unassigned-board-rows.ts @@ -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 { + 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; +} diff --git a/src/domain/work-orders/types/work-order-board.ts b/src/domain/work-orders/types/work-order-board.ts index 3400d4a7..b7fd99f0 100644 --- a/src/domain/work-orders/types/work-order-board.ts +++ b/src/domain/work-orders/types/work-order-board.ts @@ -41,6 +41,18 @@ export const 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"], +]; + /** Maps API type enums to selectable FE types. Legacy AddOn(7) is not a type. */ export const WORK_ORDER_TYPE_API_TO_FE: Record = { [WORK_ORDER_TYPE_API.PM]: "PM", diff --git a/src/domain/work-orders/types/work-order-table-row.ts b/src/domain/work-orders/types/work-order-table-row.ts index 153b58d1..70719349 100644 --- a/src/domain/work-orders/types/work-order-table-row.ts +++ b/src/domain/work-orders/types/work-order-table-row.ts @@ -138,3 +138,4 @@ export type WorkOrderDayGroup = { export type WorkOrderTablePatch = Partial; export const UNSCHEDULED_DAY_KEY = "__unscheduled__" as const; +export const UNASSIGNED_DAY_KEY = "__unassigned__" as const; diff --git a/src/domain/work-orders/types/work-order.ts b/src/domain/work-orders/types/work-order.ts index f266bc6a..265344da 100644 --- a/src/domain/work-orders/types/work-order.ts +++ b/src/domain/work-orders/types/work-order.ts @@ -57,6 +57,7 @@ export type { export { EMPTY_TABLE_DRAFT, + UNASSIGNED_DAY_KEY, UNSCHEDULED_DAY_KEY, WORK_ORDERS_TABLE_PAGE_SIZE, } from "./work-order-table-row"; diff --git a/src/domain/work-orders/use-cases/use-work-orders-list.ts b/src/domain/work-orders/use-cases/use-work-orders-list.ts index 46fd94ef..59998f5a 100644 --- a/src/domain/work-orders/use-cases/use-work-orders-list.ts +++ b/src/domain/work-orders/use-cases/use-work-orders-list.ts @@ -1,4 +1,5 @@ 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 type { WorkOrdersListParams, @@ -10,6 +11,7 @@ import type { WorkOrderBoardQueryParams, WorkOrderBoardSearchQueryParams, } 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"; export function useWorkOrdersList( @@ -46,6 +48,17 @@ export function useWorkOrdersBoardSearch( }); } +export function useWorkOrdersUnassignedQueue( + enabled = true, +): UseQueryResult { + return useQuery({ + queryKey: queryKeys.workOrders.unassignedQueue(), + queryFn: fetchUnassignedBoardRows, + enabled, + placeholderData: keepPreviousData, + }); +} + export function useWorkOrderUsers(): UseQueryResult { return useQuery({ queryKey: queryKeys.workOrders.dispatchers(), diff --git a/src/domain/work-orders/utils/board-query-params.ts b/src/domain/work-orders/utils/board-query-params.ts index 6f4824fe..293aa597 100644 --- a/src/domain/work-orders/utils/board-query-params.ts +++ b/src/domain/work-orders/utils/board-query-params.ts @@ -6,6 +6,7 @@ import { DATE_RANGE_TO_PRESET, DOC_STATUS_API, LIFECYCLE_STATUS_API, + NON_TERMINAL_LIFECYCLE_STATUSES, type WorkOrderBoardQueryParams, type WorkOrderBoardSearchQueryParams, } 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; +/** + * 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 { if (id === ASSIGNEE_FILTER_UNASSIGNED || id === BOARD_DISPATCHER_UNASSIGNED) { return BOARD_DISPATCHER_UNASSIGNED; @@ -175,3 +184,16 @@ export function buildBoardSearchParamsRecord( export function woTypeFilterIsOverdue(type: string): boolean { 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, + }; +} diff --git a/src/domain/work-orders/utils/table-filter-utils.ts b/src/domain/work-orders/utils/table-filter-utils.ts index 6938c74c..0bf9879c 100644 --- a/src/domain/work-orders/utils/table-filter-utils.ts +++ b/src/domain/work-orders/utils/table-filter-utils.ts @@ -102,6 +102,78 @@ export function filterByBarAndSearch( }); } +export function isUnassignedBoardRow(row: WorkOrderTableRow): boolean { + if (TERMINAL_STATUSES.has(row.status)) { + return false; + } + return !row.dispatcherId; +} + +export function partitionBoardRows(rows: WorkOrderTableRow[]): { + unassigned: WorkOrderTableRow[]; + unscheduled: WorkOrderTableRow[]; + scheduled: WorkOrderTableRow[]; +} { + const unassigned: WorkOrderTableRow[] = []; + const remaining: WorkOrderTableRow[] = []; + + for (const row of rows) { + if (isUnassignedBoardRow(row)) { + unassigned.push(row); + } else { + remaining.push(row); + } + } + + const { scheduled, unscheduled } = splitScheduledUnscheduled(remaining); + return { unassigned, unscheduled, scheduled }; +} + +export function rowIdSet(rows: WorkOrderTableRow[]): Set { + 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; + queueIds: ReadonlySet; +}; + +/** + * 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[]): { scheduled: WorkOrderTableRow[]; unscheduled: WorkOrderTableRow[]; diff --git a/src/infra/query-key/query-key.ts b/src/infra/query-key/query-key.ts index c775e00e..87eeda4e 100644 --- a/src/infra/query-key/query-key.ts +++ b/src/infra/query-key/query-key.ts @@ -17,6 +17,7 @@ export const queryKeys = { [...queryKeys.workOrders.all, "board", params ?? {}] as const, boardSearch: (params?: WorkOrderBoardSearchQueryParams) => [...queryKeys.workOrders.all, "boardSearch", params ?? {}] as const, + unassignedQueue: () => [...queryKeys.workOrders.all, "unassignedQueue"] as const, byLocation: (locationId: string | number, search = "") => [...queryKeys.workOrders.all, "byLocation", locationId, search] as const, detail: (id: string | number) => [...queryKeys.workOrders.all, "detail", id] as const, diff --git a/src/test/app/(protected)/workorders/wo-table-unassigned-queue.test.tsx b/src/test/app/(protected)/workorders/wo-table-unassigned-queue.test.tsx new file mode 100644 index 00000000..66a61208 --- /dev/null +++ b/src/test/app/(protected)/workorders/wo-table-unassigned-queue.test.tsx @@ -0,0 +1,412 @@ +import { useMemo, useState } from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { AssignedToCell } from "@/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell"; +import { WoTableBody } from "@/app/(protected)/workorders/_components/list/table/wo-table-body"; +import type { ReturnTypeOfUseWorkOrdersTableData } from "@/app/(protected)/workorders/_components/list/table/wo-table-types"; +import { expandTablePatchToBoardFieldOps } from "@/domain/work-orders/mappers/work-order-board-patch-mapper"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { + buildDayGroups, + composeBoardSections, + rowIdSet, +} from "@/domain/work-orders/utils/table-filter-utils"; + +function makeRow(overrides: Partial = {}): WorkOrderTableRow { + return { + id: "1", + woNumber: "24991", + site: "CLV2", + type: "PM", + severity: null, + dispatcherId: "", + dispatcherName: "", + scheduledOn: "2026-06-02", + completedDate: "", + targetWeek: "", + dayGroup: "06/02", + dayLabel: "TUE · 06/02", + location: "Cleveland, OH", + locationId: "", + pm: "Backflow", + serviceNotes: "", + extraServices: [], + company: "", + tech: "", + techPhone: "", + apptStart: "", + apptEnd: "", + apptTime: "—", + status: "Incomplete", + docStatus: "No", + pocName: "", + pocPhone: "", + pocNotes: "", + dueDate: "", + rescheduleCount: 0, + carriedOver: 0, + isAddOn: false, + originalWeek: "", + originalDate: "", + isPastDue: false, + scheduleWeekOnly: false, + mediaCount: 0, + title: "", + priority: "", + vendorId: "", + primaryDispatchId: null, + rowVersion: "", + dispatchRowVersion: "", + flagColor: null, + ...overrides, + }; +} + +function baseTableData( + overrides: Partial = {}, +): ReturnTypeOfUseWorkOrdersTableData { + return { + rows: [], + isLoading: false, + isFetching: false, + error: null, + refetch: vi.fn(), + advActive: false, + advResults: [], + unassigned: [], + unassignedError: null, + unassignedLoading: false, + unscheduled: [], + displayGroups: [], + searchActive: false, + noSearchResults: false, + isEmptyWeek: false, + todayKey: null, + draftDayKey: null, + draft: null, + setDraft: vi.fn(), + unassignedOpen: true, + setUnassignedOpen: vi.fn(), + unschedOpen: true, + setUnschedOpen: vi.fn(), + selectedId: null, + setSelectedId: vi.fn(), + selectedRow: null, + flashId: null, + triggerFlash: vi.fn(), + dragViz: null, + setDragViz: vi.fn(), + dragState: null, + patchRow: vi.fn(), + clearRowPatch: vi.fn(), + reorderRows: vi.fn(), + endDrag: vi.fn(), + startDayDraft: vi.fn(), + cancelDraft: vi.fn(), + addRow: vi.fn(), + scopeTotalCount: 0, + totalCount: 0, + search: "", + ...overrides, + } as ReturnTypeOfUseWorkOrdersTableData; +} + +const DISPATCHER = { id: "sarah", name: "Sarah Chen", initials: "SC", color: "#3366cc" }; + +function UnassignedAssignHarness({ + initialRow, + patchBoardField, +}: { + initialRow: WorkOrderTableRow; + patchBoardField: ( + id: string | number, + payload: { field: string; value?: string | null }, + ) => Promise; +}) { + const [rows, setRows] = useState([initialRow]); + const weeklyIds = rowIdSet(rows); + const sections = useMemo( + () => + composeBoardSections({ + displayRows: rows, + weekRows: rows, + weeklyIds, + queueIds: weeklyIds, + }), + [rows, weeklyIds], + ); + const tableData = baseTableData({ + unassigned: sections.unassigned, + unscheduled: sections.unscheduled, + displayGroups: buildDayGroups(sections.scheduled), + }); + + return ( + + ( + + + + + )} + renderInlineRow={() => null} + /> +
{row.woNumber} + { + const ops = expandTablePatchToBoardFieldOps({ + dispatcherId: id, + dispatcherName: name, + }); + const patched = await patchBoardField(row.id, ops[0]!); + setRows((current) => + current.map((item) => + String(item.id) === String(row.id) + ? { ...patched, dispatcherId: id, dispatcherName: name } + : item, + ), + ); + }} + /> +
+ ); +} + +describe("WoTableBody Unassigned queue (SH-165)", () => { + it("renders Unassigned above Unscheduled and day groups without duplicate rows", () => { + const datedUnassigned = makeRow({ id: "u-dated", woNumber: "24991" }); + const undatedUnassigned = makeRow({ + id: "us1", + woNumber: "25021", + scheduledOn: "", + dayGroup: "", + dayLabel: "", + }); + const undatedAssigned = makeRow({ + id: "us-assigned", + woNumber: "25022", + dispatcherId: "sarah", + dispatcherName: "Sarah", + scheduledOn: "", + dayGroup: "", + dayLabel: "", + }); + const datedAssigned = makeRow({ + id: "day-assigned", + woNumber: "25023", + dispatcherId: "sarah", + dispatcherName: "Sarah", + }); + + const tableData = baseTableData({ + unassigned: [datedUnassigned, undatedUnassigned], + unscheduled: [undatedAssigned], + displayGroups: [ + { + key: "06/02", + label: "TUE · 06/02", + date: "2026-06-02", + items: [datedAssigned], + }, + ], + }); + + render( + + ( + + + + )} + renderInlineRow={() => null} + /> +
{row.woNumber}
, + ); + + const unassignedHeader = screen.getByRole("button", { + name: "Unassigned, 2 work orders, expanded", + }); + const unscheduledHeader = screen.getByRole("button", { + name: "Unscheduled, 1 work order, expanded", + }); + const dayLabel = screen.getByText("TUE · 06/02"); + + expect(unassignedHeader.compareDocumentPosition(screen.getByTestId("row-u-dated"))).toBe( + Node.DOCUMENT_POSITION_FOLLOWING, + ); + expect(unassignedHeader.compareDocumentPosition(unscheduledHeader)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING, + ); + expect(unscheduledHeader.compareDocumentPosition(dayLabel)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING, + ); + + expect(screen.getByTestId("row-u-dated")).toBeInTheDocument(); + expect(screen.getByTestId("row-us1")).toBeInTheDocument(); + expect(screen.getByTestId("row-us-assigned")).toBeInTheDocument(); + expect(screen.getByTestId("row-day-assigned")).toBeInTheDocument(); + expect(screen.getAllByTestId(/row-/)).toHaveLength(4); + }); + + it("collapses and expands the Unassigned section via the disclosure control", async () => { + const user = userEvent.setup(); + const setUnassignedOpen = vi.fn(); + const tableData = baseTableData({ + unassigned: [makeRow({ id: "u1", woNumber: "24991" })], + setUnassignedOpen, + }); + + render( + + ( + + + + )} + renderInlineRow={() => null} + /> +
{row.woNumber}
, + ); + + await user.click(screen.getByRole("button", { name: "Unassigned, 1 work order, expanded" })); + 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( + + ( + + + + )} + renderInlineRow={() => null} + /> +
{row.woNumber}
, + ); + + expect(screen.getByTestId("row-out-week")).toBeInTheDocument(); + expect(screen.queryByText("WED · 07/15")).not.toBeInTheDocument(); + }); + + it("distinguishes a still-loading queue from an empty one", () => { + render( + + null} + renderInlineRow={() => null} + /> +
, + ); + + expect(screen.getByText(/Loading unassigned work orders/)).toBeInTheDocument(); + expect(screen.queryByText("No unassigned work orders.")).not.toBeInTheDocument(); + }); + + it("announces an empty queue only once loading has settled", () => { + render( + + null} + renderInlineRow={() => null} + /> +
, + ); + + expect(screen.getByText("No unassigned work orders.")).toBeInTheDocument(); + expect(screen.queryByText(/Loading unassigned work orders/)).not.toBeInTheDocument(); + }); + + it("keeps the Unassigned pin visible when the queue query fails", () => { + render( + + ( + + + + )} + renderInlineRow={() => null} + /> +
{row.woNumber}
, + ); + + expect(screen.getByText("queue unavailable")).toBeInTheDocument(); + }); + + it("keeps the Unassigned section visible with an empty state when the queue is empty", () => { + render( + + ( + + + + )} + renderInlineRow={() => null} + /> +
{row.woNumber}
, + ); + + expect( + screen.getByRole("button", { name: "Unassigned, 0 work orders, expanded" }), + ).toBeInTheDocument(); + expect(screen.getByText("No unassigned work orders.")).toBeInTheDocument(); + expect(screen.queryByTestId(/row-/)).not.toBeInTheDocument(); + }); + + it("removes a pinned row immediately after assigning through the board PATCH path", async () => { + const user = userEvent.setup(); + const pinned = makeRow({ + id: "u-pin", + woNumber: "24991", + dispatcherId: "", + dispatcherName: "", + rowVersion: "AAAAAAA=", + }); + const patchBoardField = vi.fn(async (_id: string | number, payload: { field: string }) => { + expect(payload.field).toBe("assignTo"); + return { ...pinned, dispatcherId: DISPATCHER.id, dispatcherName: DISPATCHER.name }; + }); + + render(); + + expect(screen.getByTestId("row-u-pin")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: /Select/ })); + await user.click(screen.getByRole("button", { name: /Sarah Chen/ })); + + expect(patchBoardField).toHaveBeenCalled(); + expect( + screen.getByRole("button", { name: "Unassigned, 0 work orders, expanded" }), + ).toBeInTheDocument(); + expect(screen.getByText("No unassigned work orders.")).toBeInTheDocument(); + expect(screen.getByTestId("row-u-pin")).toBeInTheDocument(); + expect(screen.getByText("TUE · 06/02")).toBeInTheDocument(); + }); +}); diff --git a/src/test/app/(protected)/workorders/wo-table-unscheduled-highlight.test.tsx b/src/test/app/(protected)/workorders/wo-table-unscheduled-highlight.test.tsx index dfd730ad..9a5216c8 100644 --- a/src/test/app/(protected)/workorders/wo-table-unscheduled-highlight.test.tsx +++ b/src/test/app/(protected)/workorders/wo-table-unscheduled-highlight.test.tsx @@ -76,6 +76,11 @@ const handlers: WoTableRowHandlers = { function renderUnscheduledHarness() { const tableData = { advActive: false, + unassigned: [], + unassignedError: null, + unassignedLoading: false, + unassignedOpen: true, + setUnassignedOpen: vi.fn(), unscheduled: [unscheduledRow], unschedOpen: true, draftDayKey: null, diff --git a/src/test/domain/work-orders/api/fetch-unassigned-board-rows.test.ts b/src/test/domain/work-orders/api/fetch-unassigned-board-rows.test.ts new file mode 100644 index 00000000..bf8031e9 --- /dev/null +++ b/src/test/domain/work-orders/api/fetch-unassigned-board-rows.test.ts @@ -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[], 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); + }); +}); diff --git a/src/test/domain/work-orders/utils/board-query-params.test.ts b/src/test/domain/work-orders/utils/board-query-params.test.ts index 3fd01ec9..ba5b25ea 100644 --- a/src/test/domain/work-orders/utils/board-query-params.test.ts +++ b/src/test/domain/work-orders/utils/board-query-params.test.ts @@ -7,6 +7,9 @@ import { buildBoardSearchParamsRecord, toBoardQueryParams, toBoardSearchQueryParams, + toUnassignedQueueQueryParams, + UNASSIGNED_QUEUE_DATE_FROM, + UNASSIGNED_QUEUE_DATE_TO, } from "@/domain/work-orders/utils/board-query-params"; describe("toBoardQueryParams", () => { @@ -174,3 +177,21 @@ describe("buildBoardSearchParamsRecord", () => { 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"); + }); +}); diff --git a/src/test/domain/work-orders/utils/table-filter-utils.test.ts b/src/test/domain/work-orders/utils/table-filter-utils.test.ts index ea303e6c..dbe4abc5 100644 --- a/src/test/domain/work-orders/utils/table-filter-utils.test.ts +++ b/src/test/domain/work-orders/utils/table-filter-utils.test.ts @@ -1,11 +1,17 @@ import { describe, expect, it } from "vitest"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters"; +import type { WorkOrderBarFilters } from "@/domain/work-orders/types/work-order-list-filters"; import { buildDayGroups, + composeBoardSections, computeIsPastDue, enrichTableRow, + filterByBarAndSearch, + isUnassignedBoardRow, matchesAdvancedFilters, + mergeWeeklyAndUnassignedQueue, + partitionBoardRows, splitScheduledUnscheduled, } from "@/domain/work-orders/utils/table-filter-utils"; import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils"; @@ -67,6 +73,18 @@ function makeRow(overrides: Partial = {}): WorkOrderTableRow }; } +function bar( + weekMonday: string, + overrides: Partial = {}, +): WorkOrderBarFilters { + return { + weekMonday, + dispatcherIds: new Set(), + type: "All", + ...overrides, + }; +} + describe("deriveDayFields", () => { it("derives MM/DD group and weekday label", () => { expect(deriveDayFields("2026-06-23")).toEqual({ @@ -76,6 +94,162 @@ describe("deriveDayFields", () => { }); }); +describe("filterByBarAndSearch", () => { + const weekMonday = "2026-06-01"; + + it("keeps undated rows without targetWeek for any active week", () => { + const undated = makeRow({ id: "us1", scheduledOn: "", targetWeek: "" }); + + const ids = filterByBarAndSearch([undated], bar(weekMonday), "").map((row) => row.id); + expect(ids).toEqual(["us1"]); + }); + + it("excludes undated rows whose targetWeek is outside the active week", () => { + const undatedOtherTarget = makeRow({ + id: "tw1", + scheduledOn: "", + targetWeek: "2026-07-06", + }); + + const ids = filterByBarAndSearch([undatedOtherTarget], bar(weekMonday), "").map( + (row) => row.id, + ); + expect(ids).toEqual([]); + }); + + it("excludes dated rows outside the active week", () => { + const inWeek = makeRow({ + id: "in", + scheduledOn: "2026-06-03", + ...deriveDayFields("2026-06-03"), + }); + const outWeek = makeRow({ + id: "out", + scheduledOn: "2026-06-15", + ...deriveDayFields("2026-06-15"), + }); + + const ids = filterByBarAndSearch([inWeek, outWeek], bar(weekMonday), "").map((row) => row.id); + expect(ids).toEqual(["in"]); + }); +}); + +describe("isUnassignedBoardRow", () => { + it("treats empty dispatcher as unassigned unless terminal", () => { + expect(isUnassignedBoardRow(makeRow({ dispatcherId: "" }))).toBe(true); + expect(isUnassignedBoardRow(makeRow({ dispatcherId: "u1" }))).toBe(false); + expect(isUnassignedBoardRow(makeRow({ dispatcherId: "", status: "Completed" }))).toBe(false); + }); +}); + +describe("partitionBoardRows", () => { + it("pins unassigned rows first and keeps assigned undated rows in Unscheduled", () => { + const datedUnassigned = makeRow({ + id: "u-dated", + dispatcherId: "", + dispatcherName: "", + scheduledOn: "2026-06-02", + ...deriveDayFields("2026-06-02"), + }); + const undatedUnassigned = makeRow({ + id: "us-undated", + dispatcherId: "", + dispatcherName: "", + scheduledOn: "", + dayGroup: "", + dayLabel: "", + }); + const undatedAssigned = makeRow({ + id: "us-assigned", + dispatcherId: "sarah", + dispatcherName: "Sarah", + scheduledOn: "", + dayGroup: "", + dayLabel: "", + }); + const datedAssigned = makeRow({ id: "day-assigned", dispatcherId: "sarah" }); + + const result = partitionBoardRows([ + datedUnassigned, + undatedUnassigned, + undatedAssigned, + datedAssigned, + ]); + + expect(result.unassigned.map((row) => row.id)).toEqual(["u-dated", "us-undated"]); + expect(result.unscheduled.map((row) => row.id)).toEqual(["us-assigned"]); + expect(result.scheduled.map((row) => row.id)).toEqual(["day-assigned"]); + + const allIds = [...result.unassigned, ...result.unscheduled, ...result.scheduled].map( + (row) => row.id, + ); + expect(new Set(allIds).size).toBe(allIds.length); + }); +}); + +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", () => { it("splits rows without scheduledOn into unscheduled", () => { const scheduled = makeRow({ id: "a", scheduledOn: "2026-06-23" });