Merge pull request #111 from Sea-Haven-Industries/feat/SH-165-unscheduled-pin
Some checks are pending
CI / ci (push) Waiting to run
CI / governance (push) Waiting to run
CI / vendor-visual-regression (push) Waiting to run
Deploy / deploy (push) Waiting to run

feat(work-orders): align Unscheduled board pin with design (SH-165)
This commit is contained in:
Arthur Bassi 2026-08-18 21:04:51 -03:00 • committed by GitHub
commit b3180ef886
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1142 additions and 86 deletions

View file

@ -75,6 +75,18 @@ export function AssignedToCell({ dispatcherId, users, highlight, onSave }: Assig
/>
</div>
<div className="max-h-52 overflow-y-auto">
<button
type="button"
onClick={() => {
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]"
>
<span className="text-[13px] text-[var(--color-text-muted)]">Unassigned</span>
{!dispatcherId && <Check size={10} className="shrink-0 text-[var(--color-primary)]" />}
</button>
{filtered.map((d) => (
<button
key={String(d.id)}

View file

@ -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 <tbody>{tableData.advResults.map((row) => renderRow(row))}</tbody>;
}
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 (
<tbody>
<WoTableSectionHeader
variant="unassigned"
label="Unassigned"
count={unassignedRows.length}
collapsed={!tableData.unassignedOpen}
onToggleCollapse={() => tableData.setUnassignedOpen((v) => !v)}
/>
{tableData.unassignedOpen && (
<>
{renderInlineRow(UNASSIGNED_DAY_KEY)}
{Boolean(tableData.unassignedError) && (
<tr>
<td colSpan={WO_TABLE_COLS.length} style={{ padding: "8px 14px" }}>
<Text variant="error" tone="error">
{tableData.unassignedError?.message ?? "Unable to load unassigned work orders."}
</Text>
</td>
</tr>
)}
{showUnassignedLoading && (
<tr aria-busy="true">
<td colSpan={WO_TABLE_COLS.length} style={{ padding: "8px 14px" }}>
<Text variant="feedback" tone="muted">
Loading unassigned work orders…
</Text>
</td>
</tr>
)}
{showUnassignedEmpty && (
<tr>
<td colSpan={WO_TABLE_COLS.length} style={{ padding: "8px 14px" }}>
<Text variant="feedback" tone="muted">
No unassigned work orders.
</Text>
</td>
</tr>
)}
{unassignedRows.map((row) => renderRow(row))}
</>
)}
{showUnscheduled && (
<>
<WoTableSectionHeader

View file

@ -0,0 +1,119 @@
import type { LucideIcon } from "lucide-react";
import { ChevronDown, ChevronRight, Plus } from "lucide-react";
import { WO_TABLE_COLS } from "./wo-table-cols";
type CollapsibleSectionStyle = {
background: string;
borderTop: string;
borderBottom: string;
accentColor: string;
boxShadow: string;
labelFontFamily: string;
labelLetterSpacing: string;
};
const SECTION_STYLES: Record<"unassigned" | "unscheduled", CollapsibleSectionStyle> = {
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 (
<tr style={{ background: style.background }}>
<td
colSpan={WO_TABLE_COLS.length}
style={{
height: 40,
padding: "0 14px",
boxShadow: style.boxShadow,
borderTop: style.borderTop,
borderBottom: style.borderBottom,
}}
>
<div className="flex items-center gap-2.5">
<button
type="button"
className="flex flex-1 items-center gap-2.5"
aria-expanded={!collapsed}
aria-label={`${label}, ${count} work order${count !== 1 ? "s" : ""}${collapsed ? ", collapsed" : ", expanded"}`}
onClick={onToggleCollapse}
style={{
background: "transparent",
border: "none",
padding: 0,
cursor: "pointer",
color: "inherit",
}}
>
<Icon size={14} className="shrink-0" style={{ color: style.accentColor }} />
<span
className="uppercase"
style={{
fontFamily: style.labelFontFamily,
fontSize: 12,
fontWeight: 700,
letterSpacing: style.labelLetterSpacing,
color: style.accentColor,
}}
>
{label}
</span>
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
{count} work order{count !== 1 ? "s" : ""}
</span>
<span className="ml-auto flex items-center" style={{ color: style.accentColor }}>
{collapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
</span>
</button>
{Boolean(onAddWo) && (
<button
type="button"
className="flex items-center gap-1 transition-opacity hover:opacity-70"
style={{ fontSize: 12.5, fontWeight: 600, color: "var(--primary)" }}
onClick={onAddWo}
>
<Plus size={13} />
Add WO
</button>
)}
</div>
</td>
</tr>
);
}

View file

@ -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);

View file

@ -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 (
<tr style={{ background: "rgba(217, 119, 6, 0.08)" }}>
<td
colSpan={WO_TABLE_COLS.length}
onClick={onToggleCollapse}
style={{
height: 40,
padding: "0 14px",
cursor: "pointer",
boxShadow: "inset 3px 0 0 var(--warning)",
borderTop: "1px solid #FDE68A",
borderBottom: "1px solid #FDE68A",
}}
>
<div className="flex items-center gap-2.5">
<AlertTriangle size={14} className="shrink-0" style={{ color: "var(--warning)" }} />
<span
className="uppercase"
style={{
fontFamily: "var(--font-mono)",
fontSize: 12,
fontWeight: 700,
letterSpacing: "0.08em",
color: "var(--warning)",
}}
>
{label}
</span>
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
{count} work order{count !== 1 ? "s" : ""}
</span>
<button
type="button"
className="flex items-center gap-1 transition-opacity hover:opacity-70"
style={{ fontSize: 12.5, fontWeight: 600, color: "var(--primary)" }}
onClick={(e) => {
e.stopPropagation();
onAddWo();
}}
>
<Plus size={13} />
Add WO
</button>
<span className="ml-auto flex items-center" style={{ color: "var(--warning)" }}>
{collapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
</span>
</div>
</td>
</tr>
<WoTableCollapsibleSectionHeader
tone="unassigned"
icon={Users}
label={label}
count={count}
collapsed={collapsed}
onToggleCollapse={() => onToggleCollapse?.()}
/>
);
}
if (variant === "unscheduled") {
return (
<WoTableCollapsibleSectionHeader
tone="unscheduled"
icon={AlertTriangle}
label={label}
count={count}
collapsed={collapsed}
onToggleCollapse={() => onToggleCollapse?.()}
onAddWo={onAddWo}
/>
);
}
@ -125,18 +98,17 @@ export function WoTableSectionHeader({
Today
</span>
)}
<button
type="button"
className="flex items-center gap-1 transition-opacity hover:opacity-70"
style={{ fontSize: 12.5, fontWeight: 600, color: "var(--primary)" }}
onClick={(e) => {
e.stopPropagation();
onAddWo();
}}
>
<Plus size={13} />
Add WO
</button>
{Boolean(onAddWo) && (
<button
type="button"
className="flex items-center gap-1 transition-opacity hover:opacity-70"
style={{ fontSize: 12.5, fontWeight: 600, color: "var(--primary)" }}
onClick={onAddWo}
>
<Plus size={13} />
Add WO
</button>
)}
</div>
</td>
</tr>

View file

@ -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<typeof useWorkOrdersListFilters>;
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<WorkOrderBoardListResult, Error>["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,
};
}

View file

@ -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<typeof useWorkOrdersListFilters>;
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,
};
}

View file

@ -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,

View file

@ -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<string | null>(null);
const [draft, setDraft] = useState<WorkOrderTableDraft>(EMPTY_TABLE_DRAFT);
const [unassignedOpen, setUnassignedOpen] = useState(true);
const [unschedOpen, setUnschedOpen] = useState(true);
const [selectedId, setSelectedId] = useState<string | number | null>(null);
const [flashId, setFlashId] = useState<string | number | null>(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,

View file

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

View file

@ -41,6 +41,18 @@ export const LIFECYCLE_STATUS_API = {
export type LifecycleStatusApi = (typeof LIFECYCLE_STATUS_API)[keyof typeof LIFECYCLE_STATUS_API];
/** 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<number, WOType | ""> = {
[WORK_ORDER_TYPE_API.PM]: "PM",

View file

@ -138,3 +138,4 @@ export type WorkOrderDayGroup = {
export type WorkOrderTablePatch = Partial<WorkOrderTableRow>;
export const UNSCHEDULED_DAY_KEY = "__unscheduled__" as const;
export const UNASSIGNED_DAY_KEY = "__unassigned__" as const;

View file

@ -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";

View file

@ -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<WorkOrderTableRow[], Error> {
return useQuery({
queryKey: queryKeys.workOrders.unassignedQueue(),
queryFn: fetchUnassignedBoardRows,
enabled,
placeholderData: keepPreviousData,
});
}
export function useWorkOrderUsers(): UseQueryResult<WorkOrderUser[], Error> {
return useQuery({
queryKey: queryKeys.workOrders.dispatchers(),

View file

@ -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,
};
}

View file

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

View file

@ -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,

View file

@ -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> = {}): 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> = {},
): 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<WorkOrderTableRow>;
}) {
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 (
<table>
<WoTableBody
tableData={tableData}
renderRow={(row) => (
<tr key={String(row.id)} data-testid={`row-${row.id}`}>
<td>{row.woNumber}</td>
<td>
<AssignedToCell
dispatcherId={row.dispatcherId}
users={[DISPATCHER]}
highlight={!row.dispatcherId}
onSave={async (id, name) => {
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,
),
);
}}
/>
</td>
</tr>
)}
renderInlineRow={() => null}
/>
</table>
);
}
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(
<table>
<WoTableBody
tableData={tableData}
renderRow={(row) => (
<tr key={String(row.id)} data-testid={`row-${row.id}`}>
<td>{row.woNumber}</td>
</tr>
)}
renderInlineRow={() => null}
/>
</table>,
);
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(
<table>
<WoTableBody
tableData={tableData}
renderRow={(row) => (
<tr key={String(row.id)} data-testid={`row-${row.id}`}>
<td>{row.woNumber}</td>
</tr>
)}
renderInlineRow={() => null}
/>
</table>,
);
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(
<table>
<WoTableBody
tableData={baseTableData({ unassigned: [outOfWeek] })}
renderRow={(row) => (
<tr key={String(row.id)} data-testid={`row-${row.id}`}>
<td>{row.woNumber}</td>
</tr>
)}
renderInlineRow={() => null}
/>
</table>,
);
expect(screen.getByTestId("row-out-week")).toBeInTheDocument();
expect(screen.queryByText("WED · 07/15")).not.toBeInTheDocument();
});
it("distinguishes a still-loading queue from an empty one", () => {
render(
<table>
<WoTableBody
tableData={baseTableData({ unassignedLoading: true })}
renderRow={() => null}
renderInlineRow={() => null}
/>
</table>,
);
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(
<table>
<WoTableBody
tableData={baseTableData({ unassignedLoading: false })}
renderRow={() => null}
renderInlineRow={() => null}
/>
</table>,
);
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(
<table>
<WoTableBody
tableData={baseTableData({
unassignedError: new Error("queue unavailable"),
})}
renderRow={(row) => (
<tr key={String(row.id)} data-testid={`row-${row.id}`}>
<td>{row.woNumber}</td>
</tr>
)}
renderInlineRow={() => null}
/>
</table>,
);
expect(screen.getByText("queue unavailable")).toBeInTheDocument();
});
it("keeps the Unassigned section visible with an empty state when the queue is empty", () => {
render(
<table>
<WoTableBody
tableData={baseTableData()}
renderRow={(row) => (
<tr key={String(row.id)} data-testid={`row-${row.id}`}>
<td>{row.woNumber}</td>
</tr>
)}
renderInlineRow={() => null}
/>
</table>,
);
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(<UnassignedAssignHarness initialRow={pinned} patchBoardField={patchBoardField} />);
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();
});
});

View file

@ -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,

View file

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

View file

@ -7,6 +7,9 @@ import {
buildBoardSearchParamsRecord,
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");
});
});

View file

@ -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> = {}): WorkOrderTableRow
};
}
function bar(
weekMonday: string,
overrides: Partial<WorkOrderBarFilters> = {},
): 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" });