diff --git a/docs/work-orders/board-search-api.md b/docs/work-orders/board-search-api.md index fde9438b..e476f53c 100644 --- a/docs/work-orders/board-search-api.md +++ b/docs/work-orders/board-search-api.md @@ -118,6 +118,15 @@ 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 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..6f281829 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 @@ -23,52 +23,79 @@ export function WoTableSectionHeader({ const isUnscheduled = variant === "unscheduled"; if (isUnscheduled) { + const toggleCollapse = () => { + onToggleCollapse?.(); + }; + return (
- - - {label} - - - {count} work order{count !== 1 ? "s" : ""} - + + + {label} + + + {count} work order{count !== 1 ? "s" : ""} + + - +
@@ -129,10 +156,7 @@ export function WoTableSectionHeader({ 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(); - }} + onClick={onAddWo} > Add WO diff --git a/src/domain/work-orders/utils/table-filter-utils.ts b/src/domain/work-orders/utils/table-filter-utils.ts index 4ea3c92f..be0b6ded 100644 --- a/src/domain/work-orders/utils/table-filter-utils.ts +++ b/src/domain/work-orders/utils/table-filter-utils.ts @@ -6,7 +6,6 @@ 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"]); @@ -91,14 +90,10 @@ export function filterByBarAndSearch( } } - if (row.scheduledOn) { - if (row.scheduledOn < weekFrom || row.scheduledOn > weekTo) { - return false; - } - } else if (row.targetWeek) { - if (!isDateInWeek(row.targetWeek, bar.weekMonday)) { - return false; - } + // 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; } return true; diff --git a/src/test/app/(protected)/workorders/wo-table-body-unscheduled.test.tsx b/src/test/app/(protected)/workorders/wo-table-body-unscheduled.test.tsx new file mode 100644 index 00000000..82551058 --- /dev/null +++ b/src/test/app/(protected)/workorders/wo-table-body-unscheduled.test.tsx @@ -0,0 +1,153 @@ +import { render, screen } from "@testing-library/react"; +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"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; + +function makeRow(overrides: Partial = {}): WorkOrderTableRow { + return { + id: "1", + woNumber: "24991", + site: "CLV2", + type: "PM", + dispatcherId: "", + dispatcherName: "", + 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 baseTableData( + overrides: Partial = {}, +): ReturnTypeOfUseWorkOrdersTableData { + return { + rows: [], + isLoading: false, + isFetching: false, + error: null, + refetch: vi.fn(), + advActive: false, + advResults: [], + unscheduled: [], + displayGroups: [], + searchActive: false, + noSearchResults: false, + isEmptyWeek: false, + todayKey: null, + draftDayKey: null, + draft: null, + setDraft: 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; +} + +describe("WoTableBody Unscheduled vs day groups", () => { + it("renders Unscheduled above day groups and keeps dated unassigned under the day header", () => { + const datedUnassigned = makeRow({ id: "u-dated", woNumber: "24991" }); + const undated = makeRow({ + id: "us1", + woNumber: "25021", + scheduledOn: "", + dayGroup: "", + dayLabel: "", + }); + + const tableData = baseTableData({ + unscheduled: [undated], + displayGroups: [ + { + key: "06/02", + label: "TUE · 06/02", + date: "2026-06-02", + items: [datedUnassigned], + }, + ], + unschedOpen: true, + }); + + render( + + ( + + + + )} + renderInlineRow={() => null} + /> +
{row.woNumber}
, + ); + + expect(screen.getByRole("button", { name: "Unscheduled" })).toBeInTheDocument(); + expect(screen.getByText("TUE · 06/02")).toBeInTheDocument(); + + const unscheduledHeader = screen.getByRole("button", { name: "Unscheduled" }); + 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); + }); +}); diff --git a/src/test/domain/work-orders/utils/board-unscheduled-design-parity.test.ts b/src/test/domain/work-orders/utils/board-unscheduled-design-parity.test.ts new file mode 100644 index 00000000..4c919a28 --- /dev/null +++ b/src/test/domain/work-orders/utils/board-unscheduled-design-parity.test.ts @@ -0,0 +1,165 @@ +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; + +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 { + 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"]), + ); + }); +}); 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 4667ee78..b059bbd3 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,8 +1,10 @@ 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, + filterByBarAndSearch, matchesAdvancedFilters, splitScheduledUnscheduled, } from "@/domain/work-orders/utils/table-filter-utils"; @@ -57,6 +59,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({ @@ -66,6 +80,40 @@ describe("deriveDayFields", () => { }); }); +describe("filterByBarAndSearch", () => { + const weekMonday = "2026-06-01"; + + it("keeps undated rows for any week, including targetWeek outside the active week", () => { + const undated = makeRow({ id: "us1", scheduledOn: "", targetWeek: "" }); + const undatedOtherTarget = makeRow({ + id: "tw1", + scheduledOn: "", + targetWeek: "2026-07-06", + }); + + const ids = filterByBarAndSearch([undated, undatedOtherTarget], bar(weekMonday), "").map( + (row) => row.id, + ); + expect(ids).toEqual(["us1", "tw1"]); + }); + + 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("splitScheduledUnscheduled", () => { it("splits rows without scheduledOn into unscheduled", () => { const scheduled = makeRow({ id: "a", scheduledOn: "2026-06-23" }); @@ -75,6 +123,23 @@ 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", () => {