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
/>
+
{
+ onSave("", "");
+ setAnchorEl(null);
+ setSearch("");
+ }}
+ className="flex w-full items-center justify-between gap-2 rounded px-1.5 py-1 transition-colors hover:bg-[#F9FAFB]"
+ >
+ Unassigned
+ {!dispatcherId && }
+
{filtered.map((d) => (
void;
-
onOpenSlideOver: () => void;
-
onPastDueAttempt?: () => void;
};
+function rowClosabilityGaps(wo: WorkOrderTableRow) {
+ return getClosabilityGaps({
+ dispatcherId: wo.dispatcherId,
+ dispatcherName: wo.dispatcherName,
+ company: wo.company,
+ tech: wo.tech,
+ completedDate: wo.completedDate ?? "",
+ woNumber: wo.woNumber,
+ pm: wo.pm,
+ docStatus: wo.docStatus,
+ mediaCount: wo.mediaCount,
+ type: wo.type,
+ upliftSummary: wo.upliftSummary,
+ hasPendingUplift: wo.hasPendingUplift,
+ });
+}
+
+function PastDueStatusCell({
+ onOpenSlideOver,
+ onPastDueAttempt,
+}: {
+ onOpenSlideOver: () => void;
+ onPastDueAttempt?: () => void;
+}) {
+ const [anchorEl, setAnchorEl] = useState(null);
+
+ return (
+ <>
+ {
+ e.stopPropagation();
+ onPastDueAttempt?.();
+ setAnchorEl(e.currentTarget);
+ }}
+ title="This WO missed its deadline. Update Schedule On to unlock status updates."
+ className="group/cell flex w-full cursor-pointer items-center gap-1.5 whitespace-nowrap leading-none transition-opacity hover:opacity-80"
+ style={{ fontSize: 12.5, fontWeight: 600, color: "var(--status-pastdue-text)" }}
+ >
+
+ Past Due
+
+
+
+ setAnchorEl(null)}
+ anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
+ onClick={(e) => e.stopPropagation()}
+ slotProps={{
+ paper: { className: "w-[264px] overflow-hidden rounded-lg border border-border p-0" },
+ }}
+ >
+
+
+
+
+ This work order is past due. Update Schedule On before the status can be updated.
+
+ {
+ e.stopPropagation();
+ onOpenSlideOver();
+ setAnchorEl(null);
+ }}
+ className="mt-2 block text-[11px] font-medium text-[var(--color-primary)]"
+ >
+ Update Schedule On →
+
+
+
+
+ >
+ );
+}
+
export function StatusCell({
wo,
-
onChangeStatus,
-
onOpenSlideOver,
-
onPastDueAttempt,
}: StatusCellProps) {
const [anchorEl, setAnchorEl] = useState(null);
+ const closabilityGaps = rowClosabilityGaps(wo);
if (wo.isPastDue) {
return (
- <>
- {
- e.stopPropagation();
-
- onPastDueAttempt?.();
-
- setAnchorEl(e.currentTarget);
- }}
- title="This WO missed its deadline. Update Schedule On to unlock status updates."
- className="group/cell flex w-full cursor-pointer items-center gap-1.5 whitespace-nowrap leading-none transition-opacity hover:opacity-80"
- style={{ fontSize: 12.5, fontWeight: 600, color: "var(--status-pastdue-text)" }}
- >
-
- Past Due
-
-
-
- setAnchorEl(null)}
- anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
- onClick={(e) => e.stopPropagation()}
- slotProps={{
- paper: { className: "w-[264px] overflow-hidden rounded-lg border border-border p-0" },
- }}
- >
-
-
-
-
-
- This work order is past due. Update Schedule On before the status can be updated.
-
-
- {
- e.stopPropagation();
-
- onOpenSlideOver();
-
- setAnchorEl(null);
- }}
- className="mt-2 block text-[11px] font-medium text-[var(--color-primary)]"
- >
- Update Schedule On →
-
-
-
-
- >
+
);
}
@@ -112,7 +130,6 @@ export function StatusCell({
type="button"
onClick={(e) => {
e.stopPropagation();
-
setAnchorEl(e.currentTarget);
}}
className="group/cell flex w-full items-center gap-1 transition-opacity hover:opacity-80"
@@ -122,7 +139,6 @@ export function StatusCell({
) : (
—
)}
-
@@ -134,24 +150,38 @@ export function StatusCell({
onClick={(e) => e.stopPropagation()}
slotProps={{ paper: { className: "min-w-[168px] rounded-lg border border-border p-1" } }}
>
- {ALL_WIZARD_STATUSES.map((status) => (
-
{
- onChangeStatus(status);
+ {ALL_WIZARD_STATUSES.map((status) => {
+ const isCompletedBlocked = status === "Completed" && closabilityGaps.length > 0;
+ const missingTitle = isCompletedBlocked
+ ? formatClosabilityMissingTitle(closabilityGaps)
+ : undefined;
- setAnchorEl(null);
- }}
- className="flex w-full items-center gap-2 rounded px-2 py-1.5 hover:bg-[#F9FAFB]"
- >
-
-
- {wo.status === status && (
-
- )}
-
- ))}
+ return (
+
{
+ if (isCompletedBlocked) {
+ return;
+ }
+ onChangeStatus(status);
+ setAnchorEl(null);
+ }}
+ className={
+ isCompletedBlocked
+ ? "flex w-full cursor-not-allowed items-center gap-2 rounded px-2 py-1.5 opacity-50"
+ : "flex w-full items-center gap-2 rounded px-2 py-1.5 hover:bg-[#F9FAFB]"
+ }
+ >
+
+ {Boolean(wo.status === status) && (
+
+ )}
+
+ );
+ })}
>
);
diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-body.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-body.tsx
index b859994f..7e51c960 100644
--- a/src/app/(protected)/workorders/_components/list/table/wo-table-body.tsx
+++ b/src/app/(protected)/workorders/_components/list/table/wo-table-body.tsx
@@ -1,7 +1,12 @@
import { Fragment, type ReactNode } from "react";
-import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
-import { UNSCHEDULED_DAY_KEY } from "@/domain/work-orders/types/work-order-table-row";
+import { Text } from "@/components/ui/text";
+import {
+ UNASSIGNED_DAY_KEY,
+ UNSCHEDULED_DAY_KEY,
+ type WorkOrderTableRow,
+} from "@/domain/work-orders/types/work-order-table-row";
import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types";
+import { WO_TABLE_COLS } from "./wo-table-cols";
import { WoTableSectionHeader } from "./wo-table-section-header";
type WoTableBodyProps = {
@@ -15,11 +20,65 @@ export function WoTableBody({ tableData, renderRow, renderInlineRow }: WoTableBo
return
{tableData.advResults.map((row) => renderRow(row))} ;
}
+ const unassignedRows = tableData.unassigned ?? [];
const showUnscheduled =
- tableData.unscheduled.length > 0 || tableData.draftDayKey === UNSCHEDULED_DAY_KEY;
+ (tableData.unscheduled?.length ?? 0) > 0 || tableData.draftDayKey === UNSCHEDULED_DAY_KEY;
+ // SH-165: "still loading" and "zero unassigned" are distinct states. Without
+ // this split the pin announced "No unassigned work orders." while the queue
+ // was still paging board/search on first mount.
+ const showUnassignedLoading =
+ tableData.unassignedOpen &&
+ unassignedRows.length === 0 &&
+ !tableData.unassignedError &&
+ tableData.unassignedLoading;
+ const showUnassignedEmpty =
+ tableData.unassignedOpen &&
+ unassignedRows.length === 0 &&
+ !tableData.unassignedError &&
+ !tableData.unassignedLoading;
return (
+ tableData.setUnassignedOpen((v) => !v)}
+ />
+ {tableData.unassignedOpen && (
+ <>
+ {renderInlineRow(UNASSIGNED_DAY_KEY)}
+ {Boolean(tableData.unassignedError) && (
+
+
+
+ {tableData.unassignedError?.message ?? "Unable to load unassigned work orders."}
+
+
+
+ )}
+ {showUnassignedLoading && (
+
+
+
+ Loading unassigned work orders…
+
+
+
+ )}
+ {showUnassignedEmpty && (
+
+
+
+ No unassigned work orders.
+
+
+
+ )}
+ {unassignedRows.map((row) => renderRow(row))}
+ >
+ )}
{showUnscheduled && (
<>
= {
+ unassigned: {
+ background: "rgba(99, 102, 241, 0.08)",
+ borderTop: "1px solid #C7D2FE",
+ borderBottom: "1px solid #C7D2FE",
+ accentColor: "var(--primary)",
+ boxShadow: "inset 3px 0 0 var(--primary)",
+ labelFontFamily: "var(--font-mono)",
+ labelLetterSpacing: "0.08em",
+ },
+ unscheduled: {
+ background: "rgba(217, 119, 6, 0.08)",
+ borderTop: "1px solid #FDE68A",
+ borderBottom: "1px solid #FDE68A",
+ accentColor: "var(--warning)",
+ boxShadow: "inset 3px 0 0 var(--warning)",
+ labelFontFamily: "var(--font-mono)",
+ labelLetterSpacing: "0.08em",
+ },
+};
+
+type WoTableCollapsibleSectionHeaderProps = {
+ tone: "unassigned" | "unscheduled";
+ icon: LucideIcon;
+ label: string;
+ count: number;
+ collapsed?: boolean;
+ onToggleCollapse: () => void;
+ onAddWo?: () => void;
+};
+
+export function WoTableCollapsibleSectionHeader({
+ tone,
+ icon: Icon,
+ label,
+ count,
+ collapsed,
+ onToggleCollapse,
+ onAddWo,
+}: WoTableCollapsibleSectionHeaderProps) {
+ const style = SECTION_STYLES[tone];
+
+ return (
+
+
+
+
+
+
+ {label}
+
+
+ {count} work order{count !== 1 ? "s" : ""}
+
+
+ {collapsed ? : }
+
+
+ {Boolean(onAddWo) && (
+
+
+ Add WO
+
+ )}
+
+
+
+ );
+}
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" : ""}
-
-
{
- e.stopPropagation();
- onAddWo();
- }}
- >
-
- Add WO
-
-
- {collapsed ? : }
-
-
-
-
+ onToggleCollapse?.()}
+ />
+ );
+ }
+
+ if (variant === "unscheduled") {
+ return (
+ onToggleCollapse?.()}
+ onAddWo={onAddWo}
+ />
);
}
@@ -125,18 +98,17 @@ export function WoTableSectionHeader({
Today
)}
- {
- e.stopPropagation();
- onAddWo();
- }}
- >
-
- Add WO
-
+ {Boolean(onAddWo) && (
+
+
+ Add WO
+
+ )}
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/mappers/work-order-table-row-mapper.ts b/src/domain/work-orders/mappers/work-order-table-row-mapper.ts
index 2ae3817c..41ee9924 100644
--- a/src/domain/work-orders/mappers/work-order-table-row-mapper.ts
+++ b/src/domain/work-orders/mappers/work-order-table-row-mapper.ts
@@ -8,6 +8,7 @@ import {
import type {
CompletionDocStatus,
WorkOrderTableRow,
+ WorkOrderUpliftSummary,
} from "@/domain/work-orders/types/work-order-table-row";
import { parseWorkOrderSeverity } from "@/domain/work-orders/types/work-order-severity";
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
@@ -215,6 +216,55 @@ function readFlagColor(record: Record): string | null {
return null;
}
+function readUpliftSummary(item: Record): WorkOrderUpliftSummary | undefined {
+ const nested = asRecord(item.upliftSummary ?? item.UpliftSummary);
+ const hasNested = Object.keys(nested).length > 0;
+ const source = hasNested ? nested : item;
+
+ // Board/search/detail contract (SH-188): pendingUpliftCount is authoritative when present.
+ const boardCount = readNumber(item, "pendingUpliftCount", "PendingUpliftCount");
+ const nestedCount = readNumber(source, "pendingCount", "PendingCount");
+ const pendingCount = nestedCount ?? boardCount ?? 0;
+ const hasUplift =
+ readBool(source, "hasUplift", "HasUplift") ||
+ readBool(item, "hasUplift", "HasUplift") ||
+ pendingCount > 0;
+
+ // No nested summary and no board count → omit (legacy payloads without the contract field).
+ if (!hasNested && boardCount == null && !hasUplift) {
+ return undefined;
+ }
+
+ const summary: WorkOrderUpliftSummary = {
+ hasUplift: hasUplift || pendingCount > 0,
+ pendingCount,
+ };
+ const primaryStatus =
+ readString(nested, "primaryStatus", "PrimaryStatus") ||
+ readString(item, "primaryStatus", "PrimaryStatus");
+ if (primaryStatus) {
+ summary.primaryStatus = primaryStatus;
+ }
+ return summary;
+}
+
+function readHasPendingUplift(
+ item: Record,
+ upliftSummary: WorkOrderUpliftSummary | undefined,
+): boolean | undefined {
+ if ("hasPendingUplift" in item || "HasPendingUplift" in item) {
+ return Boolean(item.hasPendingUplift ?? item.HasPendingUplift);
+ }
+ const boardCount = readNumber(item, "pendingUpliftCount", "PendingUpliftCount");
+ if (boardCount != null) {
+ return boardCount > 0;
+ }
+ if (upliftSummary != null) {
+ return upliftSummary.pendingCount > 0;
+ }
+ return undefined;
+}
+
export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
const item = asRecord(raw);
const scheduledOn = toDateInputValue(
@@ -269,6 +319,8 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
const locationIdRaw = item.locationId ?? item.LocationId;
const vendorIdNum = readNumber(item, "vendorId", "VendorId");
const primaryDispatchId = readNumber(item, "primaryDispatchId", "PrimaryDispatchId");
+ const upliftSummary = readUpliftSummary(item);
+ const hasPendingUplift = readHasPendingUplift(item, upliftSummary);
const row: WorkOrderTableRow = {
id: readId(item),
@@ -279,7 +331,9 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
dispatcherId,
dispatcherName,
scheduledOn,
- completedDate: toDateInputValue(readString(item, "completedDate", "CompletedDate")),
+ completedDate: toDateInputValue(
+ readString(item, "completedDate", "CompletedDate", "completedOn", "CompletedOn"),
+ ),
targetWeek: toDateInputValue(readString(item, "targetWeek", "TargetWeek")),
dayGroup,
dayLabel,
@@ -316,6 +370,8 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
rowVersion: readRowVersion(item, "rowVersion", "RowVersion"),
dispatchRowVersion: readRowVersion(item, "dispatchRowVersion", "DispatchRowVersion"),
flagColor: readFlagColor(item),
+ upliftSummary,
+ hasPendingUplift,
};
return row;
diff --git a/src/domain/work-orders/types/work-order-board.ts b/src/domain/work-orders/types/work-order-board.ts
index f8be9b1e..9d6e822e 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",
@@ -179,6 +191,11 @@ export interface WorkOrderBoardRowApi {
primaryDispatchId?: number | null;
rowVersion?: string | null;
dispatchRowVersion?: string | null;
+ /**
+ * Authoritative pending-uplift count across all dispatches for this WO (SH-188 / BE board contract).
+ * Drives the Completed closability gate.
+ */
+ pendingUpliftCount?: number;
}
export interface WorkOrderBoardCountsApi {
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 9dd1dff3..70719349 100644
--- a/src/domain/work-orders/types/work-order-table-row.ts
+++ b/src/domain/work-orders/types/work-order-table-row.ts
@@ -5,6 +5,12 @@ export type CompletionDocStatus = "Yes" | "No" | "NN" | "";
export const WORK_ORDERS_TABLE_PAGE_SIZE = 100;
+export type WorkOrderUpliftSummary = {
+ hasUplift: boolean;
+ pendingCount: number;
+ primaryStatus?: string;
+};
+
export interface WorkOrderTableRow {
id: string | number;
woNumber: string;
@@ -15,7 +21,7 @@ export interface WorkOrderTableRow {
dispatcherId: string;
dispatcherName: string;
scheduledOn: string;
- /** Work completed date (API CompletedDate); distinct from scheduledOn. */
+ /** Work completed date (API CompletedDate); used by closability / COMPLETED DATE column. */
completedDate: string;
targetWeek: string;
dayGroup: string;
@@ -59,6 +65,8 @@ export interface WorkOrderTableRow {
dispatchRowVersion: string;
/** Board flag color (#RRGGBB). Null/empty = no flag. Distinct from dispatcher avatar `color`. */
flagColor: string | null;
+ upliftSummary?: WorkOrderUpliftSummary | null;
+ hasPendingUplift?: boolean;
}
export interface WorkOrderTableDraft {
@@ -130,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 36592b05..8c1e403e 100644
--- a/src/domain/work-orders/utils/board-query-params.ts
+++ b/src/domain/work-orders/utils/board-query-params.ts
@@ -9,6 +9,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";
@@ -23,6 +24,14 @@ export const BOARD_SEARCH_MAX_PAGE_SIZE = 100;
export const ADVANCED_SEARCH_ALL_WEEKS_FROM = "2000-01-01";
export const ADVANCED_SEARCH_ALL_WEEKS_TO = "2099-12-31";
+/**
+ * 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;
@@ -235,3 +244,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/domain/work-orders/utils/wo-closability.ts b/src/domain/work-orders/utils/wo-closability.ts
new file mode 100644
index 00000000..be377091
--- /dev/null
+++ b/src/domain/work-orders/utils/wo-closability.ts
@@ -0,0 +1,148 @@
+import type {
+ CompletionDocStatus,
+ WorkOrderUpliftSummary,
+} from "@/domain/work-orders/types/work-order-table-row";
+import type { WOType, WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
+
+/** Fields required to evaluate whether a WO may be marked Completed (SH-183 ∪ pending uplift). */
+export type WoClosabilityInput = {
+ dispatcherId: string;
+ dispatcherName: string;
+ /** Company / vendor assignment — required for Completed (SH-183). */
+ company: string;
+ /** Technician is optional for closability; modeled separately from company. */
+ tech: string;
+ /** Work completed date (API CompletedDate). Empty until set. */
+ completedDate: string;
+ woNumber: string;
+ pm: string;
+ docStatus: CompletionDocStatus;
+ mediaCount: number;
+ type: WOType | "";
+ upliftSummary?: WorkOrderUpliftSummary | null;
+ hasPendingUplift?: boolean;
+};
+
+export const PENDING_UPLIFT_GAP = "Pending uplift approval";
+
+/** SH-188 tooltip when Completed is blocked solely by a pending uplift. */
+export const UPLIFT_PENDING_COMPLETED_TITLE =
+ "This work order has an uplift pending approval and cannot be completed.";
+
+/** Real (non-placeholder) WO number — non-empty and not an SH-prefixed draft id. */
+export function isRealWoNumber(woNumber: string): boolean {
+ const trimmed = woNumber.trim();
+ if (!trimmed) {
+ return false;
+ }
+ return !/^SH/i.test(trimmed);
+}
+
+export function hasPendingUplift(
+ row: Pick,
+): boolean {
+ if (row.hasPendingUplift === true) {
+ return true;
+ }
+ return (row.upliftSummary?.pendingCount ?? 0) > 0;
+}
+
+function hasDispatcher(row: Pick): boolean {
+ return Boolean(row.dispatcherId.trim() || row.dispatcherName.trim());
+}
+
+function hasCompany(company: string): boolean {
+ return Boolean(company.trim());
+}
+
+function hasService(pm: string): boolean {
+ return Boolean(pm.trim());
+}
+
+function hasCompletionDocument(docStatus: CompletionDocStatus): boolean {
+ return docStatus === "Yes" || docStatus === "NN";
+}
+
+/**
+ * Gaps that block Completed, in design hierarchy:
+ * fields → uplift pending → completion document / photos.
+ * (Scheduled-date gate is a separate design P1 — not in this module yet.)
+ */
+export function getClosabilityGaps(row: WoClosabilityInput): string[] {
+ const gaps: string[] = [];
+
+ if (!hasDispatcher(row)) {
+ gaps.push("Dispatcher");
+ }
+ if (!hasCompany(row.company)) {
+ gaps.push("Company");
+ }
+ if (!row.completedDate.trim()) {
+ gaps.push("Completed Date");
+ }
+ if (!isRealWoNumber(row.woNumber)) {
+ gaps.push("WO number");
+ }
+ if (!hasService(row.pm)) {
+ gaps.push("Service");
+ }
+
+ // Authoritative pending uplift (board pendingUpliftCount / hasPendingUplift) blocks completion.
+ // Missing uplift fields mean no pending evidence until the board contract supplies the count.
+ if (hasPendingUplift(row)) {
+ gaps.push(PENDING_UPLIFT_GAP);
+ }
+
+ if (row.type === "PM" || row.type === "Overdue") {
+ if (!hasCompletionDocument(row.docStatus)) {
+ gaps.push("Completion document");
+ }
+ }
+
+ if (row.type === "Emergency" || row.type === "Reactive") {
+ if (row.mediaCount < 1) {
+ gaps.push("At least 1 photo");
+ }
+ }
+
+ return gaps;
+}
+
+export function formatClosabilityMissingTitle(gaps: string[]): string {
+ if (gaps.includes(PENDING_UPLIFT_GAP)) {
+ return UPLIFT_PENDING_COMPLETED_TITLE;
+ }
+ return `Missing: ${gaps.join(", ")}`;
+}
+
+export type CompDocGateResult = { allowed: true } | { allowed: false; reason: string };
+
+/**
+ * Gate for completion-document generation. CompDocDialog (not yet on `dev`) should call this
+ * before opening generate flow and surface `reason` when blocked.
+ *
+ * Intentionally omits the "Completion document" closability gap — generate is how that gap is filled.
+ */
+export function canGenerateCompDoc(row: WoClosabilityInput): CompDocGateResult {
+ const gaps = getClosabilityGaps(row).filter((gap) => gap !== "Completion document");
+ if (gaps.length > 0) {
+ return { allowed: false, reason: formatClosabilityMissingTitle(gaps) };
+ }
+ return { allowed: true };
+}
+
+/** Map create-wizard draft onto the shared closability contract. */
+export function wizardDraftToClosabilityInput(draft: WorkOrderWizardDraft): WoClosabilityInput {
+ return {
+ dispatcherId: draft.assigneeId,
+ dispatcherName: draft.assigneeName,
+ company: draft.vendorName,
+ tech: "",
+ completedDate: "",
+ woNumber: draft.woNumber,
+ pm: draft.pm,
+ docStatus: "",
+ mediaCount: 0,
+ type: draft.type,
+ };
+}
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/status-cell-pending-uplift.test.tsx b/src/test/app/(protected)/workorders/status-cell-pending-uplift.test.tsx
new file mode 100644
index 00000000..d886232a
--- /dev/null
+++ b/src/test/app/(protected)/workorders/status-cell-pending-uplift.test.tsx
@@ -0,0 +1,160 @@
+import { fireEvent, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import { StatusCell } from "@/app/(protected)/workorders/_components/list/table/cells/status-cell";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+import { renderWithProviders } from "@/test/test-utils";
+
+function makeRow(overrides: Partial = {}): WorkOrderTableRow {
+ return {
+ id: 1,
+ woNumber: "1001",
+ site: "BK5",
+ type: "PM",
+ severity: null,
+ dispatcherId: "u1",
+ dispatcherName: "Alex",
+ scheduledOn: "2026-07-15",
+ completedDate: "2026-07-20",
+ targetWeek: "2026-07-15",
+ dayGroup: "07/15",
+ dayLabel: "Tue",
+ location: "Lobby",
+ locationId: "loc-1",
+ pm: "HVAC",
+ serviceNotes: "",
+ extraServices: [],
+ vendorId: "7",
+ company: "Acme",
+ tech: "Sam",
+ techPhone: "",
+ apptStart: "",
+ apptEnd: "",
+ apptTime: "",
+ status: "In Progress",
+ docStatus: "Yes",
+ pocName: "",
+ pocPhone: "",
+ pocNotes: "",
+ dueDate: "",
+ rescheduleCount: 0,
+ carriedOver: 0,
+ originalWeek: "",
+ originalDate: "",
+ isPastDue: false,
+ scheduleWeekOnly: false,
+ isAddOn: false,
+ mediaCount: 1,
+ title: "",
+ priority: "",
+ primaryDispatchId: null,
+ rowVersion: "",
+ dispatchRowVersion: "",
+ flagColor: null,
+ ...overrides,
+ };
+}
+
+describe("StatusCell pending uplift closability", () => {
+ it("disables Completed with tooltip when uplift is pending", () => {
+ const onChangeStatus = vi.fn();
+
+ renderWithProviders(
+ ,
+ { withAuth: false },
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: /in progress/i }));
+
+ const completed = screen.getByRole("button", { name: /completed/i });
+ expect(completed).toBeDisabled();
+ expect(completed).toHaveAttribute(
+ "title",
+ "This work order has an uplift pending approval and cannot be completed.",
+ );
+
+ fireEvent.click(completed);
+ expect(onChangeStatus).not.toHaveBeenCalled();
+ });
+
+ it("disables Completed with tooltip when board pendingUpliftCount maps to pending", () => {
+ const onChangeStatus = vi.fn();
+
+ renderWithProviders(
+ ,
+ { withAuth: false },
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: /in progress/i }));
+
+ const completed = screen.getByRole("button", { name: /completed/i });
+ expect(completed).toBeDisabled();
+ expect(completed).toHaveAttribute(
+ "title",
+ "This work order has an uplift pending approval and cannot be completed.",
+ );
+ });
+
+ it("allows Completed when no pending uplift", () => {
+ const onChangeStatus = vi.fn();
+
+ renderWithProviders(
+ ,
+ { withAuth: false },
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: /in progress/i }));
+ fireEvent.click(screen.getByRole("button", { name: /completed/i }));
+
+ expect(onChangeStatus).toHaveBeenCalledWith("Completed");
+ });
+
+ it("disables Completed when company is missing (SH-183)", () => {
+ const onChangeStatus = vi.fn();
+
+ renderWithProviders(
+ ,
+ { withAuth: false },
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: /in progress/i }));
+
+ const completed = screen.getByRole("button", { name: /completed/i });
+ expect(completed).toBeDisabled();
+ expect(completed).toHaveAttribute("title", "Missing: Company");
+ fireEvent.click(completed);
+ expect(onChangeStatus).not.toHaveBeenCalled();
+ });
+
+ it("allows Completed when technician is empty if company is set (SH-183)", () => {
+ const onChangeStatus = vi.fn();
+
+ renderWithProviders(
+ ,
+ { withAuth: false },
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: /in progress/i }));
+ fireEvent.click(screen.getByRole("button", { name: /completed/i }));
+
+ expect(onChangeStatus).toHaveBeenCalledWith("Completed");
+ });
+});
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 (
+
+ (
+
+ {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,
+ ),
+ );
+ }}
+ />
+
+
+ )}
+ renderInlineRow={() => null}
+ />
+
+ );
+}
+
+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(
+
+ (
+
+ {row.woNumber}
+
+ )}
+ renderInlineRow={() => null}
+ />
+
,
+ );
+
+ 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(
+
+ (
+
+ {row.woNumber}
+
+ )}
+ renderInlineRow={() => null}
+ />
+
,
+ );
+
+ 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(
+
+ (
+
+ {row.woNumber}
+
+ )}
+ renderInlineRow={() => null}
+ />
+
,
+ );
+
+ 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(
+
+ (
+
+ {row.woNumber}
+
+ )}
+ renderInlineRow={() => null}
+ />
+
,
+ );
+
+ expect(screen.getByText("queue unavailable")).toBeInTheDocument();
+ });
+
+ it("keeps the Unassigned section visible with an empty state when the queue is empty", () => {
+ render(
+
+ (
+
+ {row.woNumber}
+
+ )}
+ renderInlineRow={() => null}
+ />
+
,
+ );
+
+ 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/mappers/work-order-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-mapper.test.ts
index 5251e7fe..107d63f0 100644
--- a/src/test/domain/work-orders/mappers/work-order-mapper.test.ts
+++ b/src/test/domain/work-orders/mappers/work-order-mapper.test.ts
@@ -174,4 +174,64 @@ describe("mapWorkOrderTableRow (board DTO)", () => {
expect(mapWorkOrderTableRow({ id: 1, flagColor: "" }).flagColor).toBeNull();
expect(mapWorkOrderTableRow({ id: 1, FlagColor: "#3B82F6" }).flagColor).toBe("#3B82F6");
});
+
+ it("maps uplift summary and pending flag from API payload", () => {
+ const row = mapWorkOrderTableRow({
+ id: 303,
+ upliftSummary: {
+ hasUplift: true,
+ pendingCount: 2,
+ primaryStatus: "pending",
+ },
+ });
+
+ expect(row.upliftSummary).toEqual({
+ hasUplift: true,
+ pendingCount: 2,
+ primaryStatus: "pending",
+ });
+ expect(row.hasPendingUplift).toBe(true);
+ });
+
+ it("maps board pendingUpliftCount into hasPendingUplift (SH-188 contract)", () => {
+ const pending = mapWorkOrderTableRow({
+ id: 305,
+ pendingUpliftCount: 1,
+ });
+ expect(pending.hasPendingUplift).toBe(true);
+ expect(pending.upliftSummary).toEqual({ hasUplift: true, pendingCount: 1 });
+
+ const clear = mapWorkOrderTableRow({
+ id: 306,
+ PendingUpliftCount: 0,
+ });
+ expect(clear.hasPendingUplift).toBe(false);
+ expect(clear.upliftSummary).toEqual({ hasUplift: false, pendingCount: 0 });
+ });
+
+ it("does not copy work-order status into uplift primaryStatus on flat board rows", () => {
+ const row = mapWorkOrderTableRow({
+ id: 308,
+ pendingUpliftCount: 1,
+ status: "In Progress",
+ Status: "In Progress",
+ });
+ expect(row.upliftSummary?.primaryStatus).toBeUndefined();
+ });
+
+ it("leaves hasPendingUplift undefined when board count is absent", () => {
+ const row = mapWorkOrderTableRow({ id: 307 });
+ expect(row.hasPendingUplift).toBeUndefined();
+ expect(row.upliftSummary).toBeUndefined();
+ });
+
+ it("maps hasPendingUplift from top-level API flag", () => {
+ const row = mapWorkOrderTableRow({
+ id: 304,
+ hasPendingUplift: true,
+ });
+
+ expect(row.hasPendingUplift).toBe(true);
+ expect(row.upliftSummary).toBeUndefined();
+ });
});
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 53f79b3b..5d80c1b4 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", () => {
@@ -221,3 +224,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" });
diff --git a/src/test/domain/work-orders/utils/wo-closability.test.ts b/src/test/domain/work-orders/utils/wo-closability.test.ts
new file mode 100644
index 00000000..8af2724e
--- /dev/null
+++ b/src/test/domain/work-orders/utils/wo-closability.test.ts
@@ -0,0 +1,159 @@
+import { describe, expect, it } from "vitest";
+import type { WoClosabilityInput } from "@/domain/work-orders/utils/wo-closability";
+import {
+ PENDING_UPLIFT_GAP,
+ UPLIFT_PENDING_COMPLETED_TITLE,
+ canGenerateCompDoc,
+ formatClosabilityMissingTitle,
+ getClosabilityGaps,
+ hasPendingUplift,
+ isRealWoNumber,
+} from "@/domain/work-orders/utils/wo-closability";
+
+function makeInput(overrides: Partial = {}): WoClosabilityInput {
+ return {
+ dispatcherId: "u1",
+ dispatcherName: "Alex",
+ company: "Acme HVAC",
+ tech: "Sam",
+ completedDate: "2026-07-01",
+ woNumber: "1001",
+ pm: "HVAC",
+ docStatus: "Yes",
+ mediaCount: 1,
+ type: "PM",
+ hasPendingUplift: false,
+ ...overrides,
+ };
+}
+
+describe("isRealWoNumber", () => {
+ it("rejects empty and SH-prefixed provisional numbers", () => {
+ expect(isRealWoNumber("")).toBe(false);
+ expect(isRealWoNumber("SH-9")).toBe(false);
+ expect(isRealWoNumber("sh123")).toBe(false);
+ expect(isRealWoNumber("98765")).toBe(true);
+ });
+});
+
+describe("hasPendingUplift", () => {
+ it("is true when hasPendingUplift flag is set", () => {
+ expect(hasPendingUplift(makeInput({ hasPendingUplift: true }))).toBe(true);
+ });
+
+ it("is true when upliftSummary.pendingCount is positive", () => {
+ expect(
+ hasPendingUplift(makeInput({ upliftSummary: { hasUplift: true, pendingCount: 2 } })),
+ ).toBe(true);
+ });
+
+ it("is false when uplift is explicitly not pending", () => {
+ expect(hasPendingUplift(makeInput())).toBe(false);
+ expect(
+ hasPendingUplift(makeInput({ upliftSummary: { hasUplift: false, pendingCount: 0 } })),
+ ).toBe(false);
+ });
+});
+
+describe("getClosabilityGaps", () => {
+ it("returns empty when all gates are satisfied", () => {
+ expect(getClosabilityGaps(makeInput())).toEqual([]);
+ });
+
+ it("allows company without technician (SH-183)", () => {
+ const gaps = getClosabilityGaps(makeInput({ tech: "" }));
+ expect(gaps).toEqual([]);
+ expect(gaps).not.toContain("Technician");
+ });
+
+ it("blocks Completed when company is missing even if technician is assigned", () => {
+ expect(getClosabilityGaps(makeInput({ company: "", tech: "Sam" }))).toEqual(["Company"]);
+ expect(getClosabilityGaps(makeInput({ company: " ", tech: "Sam" }))).toEqual(["Company"]);
+ });
+
+ it("lists field gaps before uplift and document gaps", () => {
+ expect(
+ getClosabilityGaps(
+ makeInput({
+ dispatcherId: "",
+ dispatcherName: "",
+ company: " ",
+ tech: " ",
+ completedDate: "",
+ woNumber: "SH-1",
+ pm: "",
+ hasPendingUplift: true,
+ docStatus: "No",
+ type: "PM",
+ }),
+ ),
+ ).toEqual([
+ "Dispatcher",
+ "Company",
+ "Completed Date",
+ "WO number",
+ "Service",
+ PENDING_UPLIFT_GAP,
+ "Completion document",
+ ]);
+ });
+
+ it("blocks Completed when uplift is pending via summary", () => {
+ expect(
+ getClosabilityGaps(makeInput({ upliftSummary: { hasUplift: true, pendingCount: 1 } })),
+ ).toEqual([PENDING_UPLIFT_GAP]);
+ });
+
+ it("blocks Completed when hasPendingUplift is true", () => {
+ expect(getClosabilityGaps(makeInput({ hasPendingUplift: true }))).toEqual([PENDING_UPLIFT_GAP]);
+ });
+
+ it("allows Completed when uplift status fields are absent (no pending evidence)", () => {
+ expect(
+ getClosabilityGaps(makeInput({ hasPendingUplift: undefined, upliftSummary: undefined })),
+ ).toEqual([]);
+ });
+
+ it("requires a photo for Emergency / Reactive", () => {
+ expect(
+ getClosabilityGaps(makeInput({ type: "Emergency", mediaCount: 0, docStatus: "No" })),
+ ).toEqual(["At least 1 photo"]);
+ });
+});
+
+describe("formatClosabilityMissingTitle", () => {
+ it("uses design Completed title when only uplift is missing", () => {
+ expect(formatClosabilityMissingTitle([PENDING_UPLIFT_GAP])).toBe(
+ UPLIFT_PENDING_COMPLETED_TITLE,
+ );
+ });
+
+ it("uses design Completed title when pending uplift is among mixed gaps", () => {
+ expect(formatClosabilityMissingTitle(["Dispatcher", PENDING_UPLIFT_GAP])).toBe(
+ UPLIFT_PENDING_COMPLETED_TITLE,
+ );
+ });
+
+ it("joins multiple non-uplift gaps with Missing prefix", () => {
+ expect(formatClosabilityMissingTitle(["Dispatcher", "Company"])).toBe(
+ "Missing: Dispatcher, Company",
+ );
+ });
+});
+
+describe("canGenerateCompDoc", () => {
+ it("allows generation when no closability gaps", () => {
+ expect(canGenerateCompDoc(makeInput())).toEqual({ allowed: true });
+ });
+
+ it("allows generation when only the completion document is missing", () => {
+ expect(canGenerateCompDoc(makeInput({ docStatus: "No" }))).toEqual({ allowed: true });
+ });
+
+ it("blocks generation with design reason when only pending uplift exists", () => {
+ expect(canGenerateCompDoc(makeInput({ hasPendingUplift: true }))).toEqual({
+ allowed: false,
+ reason: UPLIFT_PENDING_COMPLETED_TITLE,
+ });
+ });
+});
diff --git a/tmp/pr-descriptions/pr4-uplift-pending-close-gate.md b/tmp/pr-descriptions/pr4-uplift-pending-close-gate.md
new file mode 100644
index 00000000..13a8b08c
--- /dev/null
+++ b/tmp/pr-descriptions/pr4-uplift-pending-close-gate.md
@@ -0,0 +1,39 @@
+# PR4 — Pending uplift close gate (+ SH-114 closability union)
+
+## Summary
+
+Blocks marking a work order **Completed** (and future completion-document generation) when closability gaps remain — **SH-114 field gates ∪ pending uplift** (SH-188).
+
+- Unifies `wo-closability.ts` / `getClosabilityGaps` with hierarchy: **fields → uplift pending → completion document / photos**.
+- Field gates: dispatcher, technician, completed date, real WO# (non-SH), service; then PM/Overdue completion doc; Emergency/Reactive ≥1 photo.
+- Pending uplift via `hasPendingUplift` or `upliftSummary.pendingCount`.
+- Completed tooltip uses design copy when the **only** gap is pending uplift.
+- Maps optional `completedDate` on board rows for the Completed Date gate.
+
+## Jira
+
+- [SH-188](https://luby-us.atlassian.net/browse/SH-188) — pending uplift close gate
+- Related: [SH-114](https://luby-us.atlassian.net/browse/SH-114) — closability field matrix (pulled into this branch so sister merges do not wipe gates)
+
+## Comp Doc Generate
+
+`CompDocDialog` is **not** mounted on `dev` yet. This PR exports `canGenerateCompDoc(row)` so the completion-doc slice can block generate/open with the same reason string. No UI wiring in this PR.
+
+## Out of scope
+
+- Full uplift CRUD (PR3)
+- Advanced filter facets for uplift
+- Scheduled-requires-date gate (design P1)
+- COL_HELP tooltips (P1)
+
+## Test plan
+
+- [ ] Row with all fields ok + `hasPendingUplift: true` — Completed disabled; design tooltip about pending uplift.
+- [ ] Row missing dispatcher / completed date — Completed disabled; `Missing: …` tooltip.
+- [ ] Row fully closable — Completed selectable.
+- [ ] `canGenerateCompDoc` blocked when gaps exist.
+- [ ] Board API `completedDate` / `upliftSummary.pendingCount` map through to the gate.
+
+## Base
+
+Branch `feature/wo-uplift-pending-close-gate` from local `dev`. Merge **before** or instead of stacking raw SH-114 alone with an uplift-only closability overwrite.