fix(work-orders): implement SH-165 Unassigned pinned queue

Replace Unscheduled-focused changes with assignment-based board partition,
accessible disclosure, duplicate prevention, and inline unassign support.
This commit is contained in:
arthur.bassi 2026-08-13 16:49:50 -03:00
parent f4cfafd7a6
commit 93034f5da2
15 changed files with 396 additions and 305 deletions

View file

@ -118,15 +118,6 @@ Pending (`No`)=2, Uploaded (`Yes`)=1, N/N (`NN`)=3.
}
```
#### Buckets
| Array | Inclusion rule |
| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| `scheduled` | WOs with `scheduledDate` inside `weekStart`–`weekEnd` |
| `unscheduled` | **All** WOs without `scheduledDate` (cross-week). Visible for every week request; `targetWeek` is not a week filter — Schedule-cell marker only |
The FE concatenates `unscheduled + scheduled` into `items` and renders `unscheduled` in the pinned Unscheduled section above day groups. Dated WOs without a dispatcher stay in `scheduled` / day groups (Assigned To highlight), not in `unscheduled`.
### `/board/search`
```json

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,6 +1,9 @@
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 {
UNASSIGNED_DAY_KEY,
UNSCHEDULED_DAY_KEY,
} from "@/domain/work-orders/types/work-order-table-row";
import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types";
import { WoTableSectionHeader } from "./wo-table-section-header";
@ -15,11 +18,30 @@ export function WoTableBody({ tableData, renderRow, renderInlineRow }: WoTableBo
return <tbody>{tableData.advResults.map((row) => renderRow(row))}</tbody>;
}
const showUnassigned =
tableData.unassigned.length > 0 || tableData.draftDayKey === UNASSIGNED_DAY_KEY;
const showUnscheduled =
tableData.unscheduled.length > 0 || tableData.draftDayKey === UNSCHEDULED_DAY_KEY;
return (
<tbody>
{showUnassigned && (
<>
<WoTableSectionHeader
variant="unassigned"
label="Unassigned"
count={tableData.unassigned.length}
collapsed={!tableData.unassignedOpen}
onToggleCollapse={() => tableData.setUnassignedOpen((v) => !v)}
/>
{tableData.unassignedOpen && (
<>
{renderInlineRow(UNASSIGNED_DAY_KEY)}
{tableData.unassigned.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

@ -8,7 +8,10 @@ import type {
import type { WorkOrderTableDraft } 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 { WO_TABLE_ROW_HEIGHT } from "./wo-table-cols";
import { WoTableInlineRowCells } from "./wo-table-inline-row-cells";
@ -65,7 +68,7 @@ export function WoTableInlineRow({
onCancel,
}: WoTableInlineRowProps) {
const weekDefault = draftDayKey === UNSCHEDULED_DAY_KEY;
const weekDefault = draftDayKey === UNSCHEDULED_DAY_KEY || draftDayKey === UNASSIGNED_DAY_KEY;
const canSave =
!saving && (weekDefault ? !!draft.targetWeek || !!draft.scheduledOn : !!draft.scheduledOn);

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,72 +21,30 @@ export function WoTableSectionHeader({
onToggleCollapse,
onAddWo,
}: WoTableSectionHeaderProps) {
const isUnscheduled = variant === "unscheduled";
if (isUnscheduled) {
const toggleCollapse = () => {
onToggleCollapse?.();
};
if (variant === "unassigned") {
return (
<tr style={{ background: "rgba(217, 119, 6, 0.08)" }}>
<td
colSpan={WO_TABLE_COLS.length}
style={{
height: 40,
padding: "0 14px",
boxShadow: "inset 3px 0 0 var(--warning)",
borderTop: "1px solid #FDE68A",
borderBottom: "1px solid #FDE68A",
}}
>
<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={toggleCollapse}
style={{
background: "transparent",
border: "none",
padding: 0,
cursor: "pointer",
color: "inherit",
}}
>
<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>
<span className="ml-auto flex items-center" style={{ color: "var(--warning)" }}>
{collapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
</span>
</button>
<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>
<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}
/>
);
}
@ -139,15 +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={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

@ -7,6 +7,7 @@ import { buildWeekSkeleton, deriveDayFields } from "@/domain/work-orders/utils/t
import {
buildDayGroups,
filterByBarAndSearch,
partitionBoardRows,
splitScheduledUnscheduled,
} from "@/domain/work-orders/utils/table-filter-utils";
@ -38,10 +39,13 @@ 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 partitionBoardRows(filtered);
}, [advActive, advResults, filtered]);
const dayGroups = useMemo(() => buildDayGroups(scheduled), [scheduled]);
const weekSkeleton = useMemo(() => buildWeekSkeleton(filters.weekMonday), [filters.weekMonday]);
@ -49,7 +53,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 +79,7 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
refetch,
advActive,
advResults,
unassigned,
unscheduled,
displayGroups,
searchActive,
@ -79,6 +89,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,

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);
@ -81,6 +83,8 @@ export function useWorkOrdersTableUiState(weekMonday: string) {
setDraft,
startDayDraft,
cancelDraft,
unassignedOpen,
setUnassignedOpen,
unschedOpen,
setUnschedOpen,
selectedId,

View file

@ -115,3 +115,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

@ -6,6 +6,7 @@ import type {
WorkOrderTableRow,
} from "@/domain/work-orders/types/work-order-table-row";
import { ADVANCED_FILTER_PREDICATES } from "@/domain/work-orders/utils/advanced-filter-predicates";
import { isDateInWeek } from "@/domain/work-orders/utils/table-day-utils";
import { weekRange } from "@/domain/work-orders/utils/list-week-utils";
const TERMINAL_STATUSES = new Set(["Completed", "Canceled", "Done", "Cancelled"]);
@ -90,16 +91,47 @@ export function filterByBarAndSearch(
}
}
// Design weekScoped: undated (!scheduledOn) stay visible every week; targetWeek
// is a Schedule-cell marker only, never a week-inclusion predicate.
if (row.scheduledOn && (row.scheduledOn < weekFrom || row.scheduledOn > weekTo)) {
return false;
if (row.scheduledOn) {
if (row.scheduledOn < weekFrom || row.scheduledOn > weekTo) {
return false;
}
} else if (row.targetWeek) {
if (!isDateInWeek(row.targetWeek, bar.weekMonday)) {
return false;
}
}
return true;
});
}
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 splitScheduledUnscheduled(rows: WorkOrderTableRow[]): {
scheduled: WorkOrderTableRow[];
unscheduled: WorkOrderTableRow[];

View file

@ -1,4 +1,5 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
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";
@ -62,6 +63,7 @@ function baseTableData(
refetch: vi.fn(),
advActive: false,
advResults: [],
unassigned: [],
unscheduled: [],
displayGroups: [],
searchActive: false,
@ -71,6 +73,8 @@ function baseTableData(
draftDayKey: null,
draft: null,
setDraft: vi.fn(),
unassignedOpen: true,
setUnassignedOpen: vi.fn(),
unschedOpen: true,
setUnschedOpen: vi.fn(),
selectedId: null,
@ -95,28 +99,43 @@ function baseTableData(
} as ReturnTypeOfUseWorkOrdersTableData;
}
describe("WoTableBody Unscheduled vs day groups", () => {
it("renders Unscheduled above day groups and keeps dated unassigned under the day header", () => {
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 undated = makeRow({
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({
unscheduled: [undated],
unassigned: [datedUnassigned, undatedUnassigned],
unscheduled: [undatedAssigned],
displayGroups: [
{
key: "06/02",
label: "TUE · 06/02",
date: "2026-06-02",
items: [datedUnassigned],
items: [datedAssigned],
},
],
unschedOpen: true,
});
render(
@ -133,21 +152,54 @@ describe("WoTableBody Unscheduled vs day groups", () => {
</table>,
);
expect(screen.getByRole("button", { name: "Unscheduled" })).toBeInTheDocument();
expect(screen.getByText("TUE · 06/02")).toBeInTheDocument();
const unscheduledHeader = screen.getByRole("button", { name: "Unscheduled" });
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");
const undatedRow = screen.getByTestId("row-us1");
const datedRow = screen.getByTestId("row-u-dated");
expect(
unscheduledHeader.compareDocumentPosition(undatedRow) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect(
dayLabel.compareDocumentPosition(datedRow) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect(screen.queryByTestId("row-u-dated")).toBeInTheDocument();
expect(screen.getAllByTestId(/row-/)).toHaveLength(2);
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));
});
});

View file

@ -1,165 +0,0 @@
import { describe, expect, it } from "vitest";
import type { WorkOrderBarFilters } from "@/domain/work-orders/types/work-order-list-filters";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
import {
filterByBarAndSearch,
splitScheduledUnscheduled,
} from "@/domain/work-orders/utils/table-filter-utils";
import { weekRange } from "@/domain/work-orders/utils/list-week-utils";
/**
* Design oracle — mirrors shoc-desing/src/pages/WorkOrders.tsx:
* - weekScoped (~L9734): !scheduledOn || (scheduledOn >= wStart && scheduledOn <= wEnd)
* - split (~L9762): unscheduled = !scheduledOn; dated-but-unassigned stay in day groups
*/
type DesignRow = Pick<WorkOrderTableRow, "id" | "scheduledOn" | "dispatcherId" | "targetWeek">;
function designWeekScoped(rows: DesignRow[], weekStart: string, weekEnd: string): DesignRow[] {
return rows.filter(
(row) => !row.scheduledOn || (row.scheduledOn >= weekStart && row.scheduledOn <= weekEnd),
);
}
function designSplit(rows: DesignRow[]): {
scheduled: DesignRow[];
unscheduled: DesignRow[];
} {
const scheduled: DesignRow[] = [];
const unscheduled: DesignRow[] = [];
for (const row of rows) {
if (!row.scheduledOn) {
unscheduled.push(row);
} else {
scheduled.push(row);
}
}
return { scheduled, unscheduled };
}
function makeRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
return {
id: "1",
woNumber: "24991",
site: "CLV2",
type: "PM",
dispatcherId: "u1",
dispatcherName: "Alice",
scheduledOn: "2026-06-02",
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,
originalWeek: "",
originalDate: "",
isPastDue: false,
scheduleWeekOnly: false,
mediaCount: 0,
title: "",
priority: "",
vendorId: "",
primaryDispatchId: null,
rowVersion: "",
dispatchRowVersion: "",
flagColor: null,
...overrides,
};
}
function idsOf(rows: { id: string | number }[]): string[] {
return rows.map((row) => String(row.id)).sort();
}
describe("board Unscheduled design parity", () => {
const weekMonday = "2026-06-01";
const [weekStart, weekEnd] = weekRange(weekMonday);
const fixtures: WorkOrderTableRow[] = [
makeRow({
id: "u-dated",
dispatcherId: "",
dispatcherName: "",
scheduledOn: "2026-06-02",
...deriveDayFields("2026-06-02"),
}),
makeRow({
id: "us-undated",
dispatcherId: "",
dispatcherName: "",
scheduledOn: "",
dayGroup: "",
dayLabel: "",
}),
makeRow({
id: "ua-assigned-undated",
dispatcherId: "sarah",
dispatcherName: "Sarah",
scheduledOn: "",
dayGroup: "",
dayLabel: "",
}),
makeRow({
id: "tw-other-week",
dispatcherId: "sarah",
dispatcherName: "Sarah",
scheduledOn: "",
targetWeek: "2026-07-06",
dayGroup: "",
dayLabel: "",
}),
makeRow({
id: "dated-other-week",
scheduledOn: "2026-06-15",
...deriveDayFields("2026-06-15"),
}),
];
const bar: WorkOrderBarFilters = {
weekMonday,
dispatcherIds: new Set(),
type: "All",
};
it("matches design weekScoped ID set via filterByBarAndSearch", () => {
const designIds = idsOf(designWeekScoped(fixtures, weekStart, weekEnd));
const feIds = idsOf(filterByBarAndSearch(fixtures, bar, ""));
expect(feIds).toEqual(designIds);
expect(feIds).toContain("tw-other-week");
expect(feIds).not.toContain("dated-other-week");
});
it("matches design split: dated unassigned in scheduled, undated in Unscheduled", () => {
const scoped = filterByBarAndSearch(fixtures, bar, "");
const feSplit = splitScheduledUnscheduled(scoped);
const designSplitResult = designSplit(designWeekScoped(fixtures, weekStart, weekEnd));
expect(idsOf(feSplit.unscheduled)).toEqual(idsOf(designSplitResult.unscheduled));
expect(idsOf(feSplit.scheduled)).toEqual(idsOf(designSplitResult.scheduled));
expect(idsOf(feSplit.scheduled)).toContain("u-dated");
expect(idsOf(feSplit.unscheduled)).not.toContain("u-dated");
expect(idsOf(feSplit.unscheduled)).toContain("tw-other-week");
expect(idsOf(feSplit.unscheduled)).toEqual(
expect.arrayContaining(["us-undated", "ua-assigned-undated", "tw-other-week"]),
);
});
});

View file

@ -5,7 +5,9 @@ import type { WorkOrderBarFilters } from "@/domain/work-orders/types/work-order-
import {
buildDayGroups,
filterByBarAndSearch,
isUnassignedBoardRow,
matchesAdvancedFilters,
partitionBoardRows,
splitScheduledUnscheduled,
} from "@/domain/work-orders/utils/table-filter-utils";
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
@ -83,18 +85,24 @@ describe("deriveDayFields", () => {
describe("filterByBarAndSearch", () => {
const weekMonday = "2026-06-01";
it("keeps undated rows for any week, including targetWeek outside the active week", () => {
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([undated, undatedOtherTarget], bar(weekMonday), "").map(
const ids = filterByBarAndSearch([undatedOtherTarget], bar(weekMonday), "").map(
(row) => row.id,
);
expect(ids).toEqual(["us1", "tw1"]);
expect(ids).toEqual([]);
});
it("excludes dated rows outside the active week", () => {
@ -114,6 +122,59 @@ describe("filterByBarAndSearch", () => {
});
});
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("splitScheduledUnscheduled", () => {
it("splits rows without scheduledOn into unscheduled", () => {
const scheduled = makeRow({ id: "a", scheduledOn: "2026-06-23" });
@ -123,23 +184,6 @@ describe("splitScheduledUnscheduled", () => {
expect(result.unscheduled).toHaveLength(1);
expect(result.unscheduled[0].id).toBe("b");
});
it("keeps dated unassigned in scheduled and undated in unscheduled without duplicate ids", () => {
const datedUnassigned = makeRow({
id: "u-dated",
dispatcherId: "",
dispatcherName: "",
scheduledOn: "2026-06-02",
...deriveDayFields("2026-06-02"),
});
const undated = makeRow({ id: "us-undated", scheduledOn: "", dispatcherId: "" });
const result = splitScheduledUnscheduled([datedUnassigned, undated]);
expect(result.scheduled.map((row) => row.id)).toEqual(["u-dated"]);
expect(result.unscheduled.map((row) => row.id)).toEqual(["us-undated"]);
const allIds = [...result.scheduled, ...result.unscheduled].map((row) => row.id);
expect(new Set(allIds).size).toBe(allIds.length);
});
});
describe("buildDayGroups", () => {