From 2ed770bc79f2c838b23e9ffff5bf03057f9a1de2 Mon Sep 17 00:00:00 2001 From: Arthur Bassi <201806768+bassi-arthurr@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:12:13 -0300 Subject: [PATCH 01/19] fix(work-orders): separate Unscheduled header collapse and Add WO controls [recover] remove malicious eslint payload (was ccec7322) --- docs/work-orders/board-search-api.md | 9 + .../list/table/wo-table-section-header.tsx | 74 +++++--- .../work-orders/utils/table-filter-utils.ts | 13 +- .../wo-table-body-unscheduled.test.tsx | 153 ++++++++++++++++ .../board-unscheduled-design-parity.test.ts | 165 ++++++++++++++++++ .../utils/table-filter-utils.test.ts | 65 +++++++ 6 files changed, 445 insertions(+), 34 deletions(-) create mode 100644 src/test/app/(protected)/workorders/wo-table-body-unscheduled.test.tsx create mode 100644 src/test/domain/work-orders/utils/board-unscheduled-design-parity.test.ts 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", () => { From f4cfafd7a611a22ffcdc35610f2d4ce27d3f5460 Mon Sep 17 00:00:00 2001 From: "arthur.bassi" Date: Mon, 10 Aug 2026 19:07:11 -0300 Subject: [PATCH 02/19] fix(work-orders): single Unscheduled disclosure control for a11y Co-authored-by: Cursor --- .../list/table/wo-table-section-header.tsx | 23 ++++--------------- 1 file changed, 5 insertions(+), 18 deletions(-) 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 6f281829..a27feca7 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 @@ -42,9 +42,9 @@ export function WoTableSectionHeader({
-
From f5414e4b1454899715c7bdc6cd4912b5fd4c42d8 Mon Sep 17 00:00:00 2001 From: "arthur.bassi" Date: Mon, 3 Aug 2026 09:51:32 -0300 Subject: [PATCH 03/19] feat(work-orders): block completed when uplift is pending --- .../list/table/cells/status-cell.tsx | 52 +++++++---- .../mappers/work-order-table-row-mapper.ts | 46 ++++++++++ .../work-orders/types/work-order-table-row.ts | 8 ++ .../work-orders/utils/wo-closability.ts | 42 +++++++++ .../status-cell-pending-uplift.test.tsx | 90 +++++++++++++++++++ .../mappers/work-order-mapper.test.ts | 28 ++++++ .../work-orders/utils/wo-closability.test.ts | 74 +++++++++++++++ .../pr4-uplift-pending-close-gate.md | 30 +++++++ 8 files changed, 354 insertions(+), 16 deletions(-) create mode 100644 src/domain/work-orders/utils/wo-closability.ts create mode 100644 src/test/app/(protected)/workorders/status-cell-pending-uplift.test.tsx create mode 100644 src/test/domain/work-orders/utils/wo-closability.test.ts create mode 100644 tmp/pr-descriptions/pr4-uplift-pending-close-gate.md diff --git a/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx index d2e4c78a..7f4866bf 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx @@ -11,6 +11,10 @@ import { } from "@/domain/work-orders/types/work-order-wizard"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { + formatClosabilityMissingTitle, + getClosabilityGaps, +} from "@/domain/work-orders/utils/wo-closability"; import { HoverChevron } from "./hover-chevron"; @@ -36,6 +40,7 @@ export function StatusCell({ onPastDueAttempt, }: StatusCellProps) { const [anchorEl, setAnchorEl] = useState(null); + const closabilityGaps = getClosabilityGaps(wo); if (wo.isPastDue) { return ( @@ -134,24 +139,39 @@ 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) => ( - - ))} + {wo.status === status && ( + + )} + + ); + })} ); 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 a12162c2..cd411b3e 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 @@ -7,6 +7,7 @@ import { import type { CompletionDocStatus, WorkOrderTableRow, + WorkOrderUpliftSummary, } from "@/domain/work-orders/types/work-order-table-row"; import type { WOType } from "@/domain/work-orders/types/work-order-wizard"; import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils"; @@ -174,6 +175,47 @@ function readFlagColor(record: Record): string | null { return null; } +function readUpliftSummary(item: Record): WorkOrderUpliftSummary | undefined { + const nested = asRecord(item.upliftSummary ?? item.UpliftSummary); + const source = Object.keys(nested).length > 0 ? nested : item; + + const pendingCount = + readNumber(source, "pendingCount", "PendingCount") ?? + readNumber(item, "pendingUpliftCount", "PendingUpliftCount") ?? + 0; + const hasUplift = + readBool(source, "hasUplift", "HasUplift") || + readBool(item, "hasUplift", "HasUplift") || + pendingCount > 0; + + if (!hasUplift && pendingCount === 0) { + return undefined; + } + + const summary: WorkOrderUpliftSummary = { + hasUplift, + pendingCount, + }; + const primaryStatus = readString(source, "primaryStatus", "PrimaryStatus", "status", "Status"); + if (primaryStatus) { + summary.primaryStatus = primaryStatus; + } + return summary; +} + +function readHasPendingUplift( + item: Record, + upliftSummary: WorkOrderUpliftSummary | undefined, +): boolean | undefined { + if (readBool(item, "hasPendingUplift", "HasPendingUplift")) { + return true; + } + if ((upliftSummary?.pendingCount ?? 0) > 0) { + return true; + } + return undefined; +} + export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow { const item = asRecord(raw); const scheduledOn = toDateInputValue( @@ -228,6 +270,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), @@ -272,6 +316,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-table-row.ts b/src/domain/work-orders/types/work-order-table-row.ts index 4bcb4b60..7e533a6b 100644 --- a/src/domain/work-orders/types/work-order-table-row.ts +++ b/src/domain/work-orders/types/work-order-table-row.ts @@ -4,6 +4,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; @@ -49,6 +55,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 { 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..31c7d180 --- /dev/null +++ b/src/domain/work-orders/utils/wo-closability.ts @@ -0,0 +1,42 @@ +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; + +const PENDING_UPLIFT_GAP = "Pending uplift approval"; + +/** Fields required to evaluate whether a WO may be marked Completed or generate a comp doc. */ +export type WoClosabilityInput = Pick; + +export function hasPendingUplift(row: WoClosabilityInput): boolean { + if (row.hasPendingUplift === true) { + return true; + } + return (row.upliftSummary?.pendingCount ?? 0) > 0; +} + +/** + * Returns human-readable labels for requirements still missing before Completed is allowed. + * Empty array means the WO may be marked Completed (for closability gates implemented here). + */ +export function getClosabilityGaps(row: WoClosabilityInput): string[] { + if (hasPendingUplift(row)) { + return [PENDING_UPLIFT_GAP]; + } + return []; +} + +export function formatClosabilityMissingTitle(gaps: string[]): string { + 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. + */ +export function canGenerateCompDoc(row: WoClosabilityInput): CompDocGateResult { + const gaps = getClosabilityGaps(row); + if (gaps.length > 0) { + return { allowed: false, reason: formatClosabilityMissingTitle(gaps) }; + } + return { allowed: true }; +} 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..11659ae9 --- /dev/null +++ b/src/test/app/(protected)/workorders/status-cell-pending-uplift.test.tsx @@ -0,0 +1,90 @@ +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", + dispatcherId: "u1", + dispatcherName: "Alex", + scheduledOn: "2026-07-15", + 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: "No", + pocName: "", + pocPhone: "", + pocNotes: "", + dueDate: "", + rescheduleCount: 0, + carriedOver: 0, + originalWeek: "", + originalDate: "", + isPastDue: false, + scheduleWeekOnly: false, + mediaCount: 0, + 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", "Missing: Pending uplift approval"); + + fireEvent.click(completed); + expect(onChangeStatus).not.toHaveBeenCalled(); + }); + + 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"); + }); +}); 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 63a92912..1b3baa4e 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 @@ -135,4 +135,32 @@ 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 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/wo-closability.test.ts b/src/test/domain/work-orders/utils/wo-closability.test.ts new file mode 100644 index 00000000..f3a3a353 --- /dev/null +++ b/src/test/domain/work-orders/utils/wo-closability.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import type { WoClosabilityInput } from "@/domain/work-orders/utils/wo-closability"; +import { + canGenerateCompDoc, + formatClosabilityMissingTitle, + getClosabilityGaps, + hasPendingUplift, +} from "@/domain/work-orders/utils/wo-closability"; + +function makeInput(overrides: Partial = {}): WoClosabilityInput { + return { + upliftSummary: undefined, + hasPendingUplift: undefined, + ...overrides, + }; +} + +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 no uplift signals are present", () => { + expect(hasPendingUplift(makeInput())).toBe(false); + expect( + hasPendingUplift(makeInput({ upliftSummary: { hasUplift: false, pendingCount: 0 } })), + ).toBe(false); + }); +}); + +describe("getClosabilityGaps", () => { + it("returns empty when no pending uplift", () => { + expect(getClosabilityGaps(makeInput())).toEqual([]); + }); + + it("blocks Completed when uplift is pending via summary", () => { + expect( + getClosabilityGaps(makeInput({ upliftSummary: { hasUplift: true, pendingCount: 1 } })), + ).toEqual(["Pending uplift approval"]); + }); + + it("blocks Completed when hasPendingUplift is true", () => { + expect(getClosabilityGaps(makeInput({ hasPendingUplift: true }))).toEqual([ + "Pending uplift approval", + ]); + }); +}); + +describe("formatClosabilityMissingTitle", () => { + it("joins gaps for tooltip copy", () => { + expect(formatClosabilityMissingTitle(["Pending uplift approval"])).toBe( + "Missing: Pending uplift approval", + ); + }); +}); + +describe("canGenerateCompDoc", () => { + it("allows generation when no closability gaps", () => { + expect(canGenerateCompDoc(makeInput())).toEqual({ allowed: true }); + }); + + it("blocks generation with reason when pending uplift exists", () => { + expect(canGenerateCompDoc(makeInput({ hasPendingUplift: true }))).toEqual({ + allowed: false, + reason: "Missing: Pending uplift approval", + }); + }); +}); 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..d9bf1215 --- /dev/null +++ b/tmp/pr-descriptions/pr4-uplift-pending-close-gate.md @@ -0,0 +1,30 @@ +# PR4 — Pending uplift close gate + +## Summary + +Blocks marking a work order **Completed** (and future completion-document generation) while an uplift request is still pending approval. + +- Adds optional `upliftSummary` / `hasPendingUplift` on `WorkOrderTableRow`, mapped from the board API when present. +- Introduces `getClosabilityGaps`, `formatClosabilityMissingTitle`, and `canGenerateCompDoc` in `wo-closability.ts` (pending-uplift scope only on this base). +- Wires the board **Status** popover to disable **Completed** with tooltip `Missing: Pending uplift approval` when a gap exists. + +## 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 +- Full closability matrix (dispatcher, comp doc, photos, etc.) — separate gate work + +## Test plan + +- [ ] Row with `hasPendingUplift: true` — Status menu shows Completed disabled; tooltip explains missing pending uplift approval. +- [ ] Row without pending uplift — Completed remains selectable. +- [ ] `canGenerateCompDoc` returns `{ allowed: false, reason: "Missing: Pending uplift approval" }` when pending uplift is present. +- [ ] Board API payload with `upliftSummary.pendingCount > 0` maps through to closability gate. + +## Base + +Branch `feature/wo-uplift-pending-close-gate` from local `dev`. Does not depend on PR3 branch git-wise; uplift fields are optional stubs until BE/PR3 land. From 6b415f732c7d6660d385b6a98e120e0e68a19d51 Mon Sep 17 00:00:00 2001 From: "arthur.bassi" Date: Mon, 3 Aug 2026 13:43:35 -0300 Subject: [PATCH 04/19] fix(work-orders): unify closability with sh-114 and pending uplift --- .../list/table/cells/status-cell.tsx | 14 ++- .../mappers/work-order-table-row-mapper.ts | 3 + .../work-orders/types/work-order-table-row.ts | 2 + .../work-orders/utils/wo-closability.ts | 115 ++++++++++++++++-- .../status-cell-pending-uplift.test.tsx | 10 +- .../work-orders/utils/wo-closability.test.ts | 77 ++++++++++-- .../pr4-uplift-pending-close-gate.md | 31 +++-- 7 files changed, 217 insertions(+), 35 deletions(-) diff --git a/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx index 7f4866bf..28049593 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx @@ -40,7 +40,19 @@ export function StatusCell({ onPastDueAttempt, }: StatusCellProps) { const [anchorEl, setAnchorEl] = useState(null); - const closabilityGaps = getClosabilityGaps(wo); + const closabilityGaps = getClosabilityGaps({ + dispatcherId: wo.dispatcherId, + dispatcherName: wo.dispatcherName, + 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, + }); if (wo.isPastDue) { return ( 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 cd411b3e..3b9fc66b 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 @@ -281,6 +281,9 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow { dispatcherId, dispatcherName, scheduledOn, + completedDate: toDateInputValue( + readString(item, "completedDate", "CompletedDate", "completedOn", "CompletedOn"), + ), targetWeek: toDateInputValue(readString(item, "targetWeek", "TargetWeek")), dayGroup, dayLabel, 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 7e533a6b..ff18a232 100644 --- a/src/domain/work-orders/types/work-order-table-row.ts +++ b/src/domain/work-orders/types/work-order-table-row.ts @@ -18,6 +18,8 @@ export interface WorkOrderTableRow { dispatcherId: string; dispatcherName: string; scheduledOn: string; + /** Work completed date (API CompletedDate); used by closability / COMPLETED DATE column. */ + completedDate?: string; targetWeek: string; dayGroup: string; dayLabel: string; diff --git a/src/domain/work-orders/utils/wo-closability.ts b/src/domain/work-orders/utils/wo-closability.ts index 31c7d180..5f1c67c6 100644 --- a/src/domain/work-orders/utils/wo-closability.ts +++ b/src/domain/work-orders/utils/wo-closability.ts @@ -1,29 +1,109 @@ -import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +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"; -const PENDING_UPLIFT_GAP = "Pending uplift approval"; +/** Fields required to evaluate whether a WO may be marked Completed (SH-114 ∪ pending uplift). */ +export type WoClosabilityInput = { + dispatcherId: string; + dispatcherName: string; + /** Vendor / technician assignment on the board row. */ + 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; +}; -/** Fields required to evaluate whether a WO may be marked Completed or generate a comp doc. */ -export type WoClosabilityInput = Pick; +export const PENDING_UPLIFT_GAP = "Pending uplift approval"; -export function hasPendingUplift(row: WoClosabilityInput): boolean { +/** Design copy when Completed is blocked solely by a pending uplift. */ +export const UPLIFT_PENDING_COMPLETED_TITLE = + "This work order cannot be completed while an uplift is pending approval."; + +/** 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 hasService(pm: string): boolean { + return Boolean(pm.trim()); +} + +function hasCompletionDocument(docStatus: CompletionDocStatus): boolean { + return docStatus === "Yes" || docStatus === "NN"; +} + /** - * Returns human-readable labels for requirements still missing before Completed is allowed. - * Empty array means the WO may be marked Completed (for closability gates implemented here). + * 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[] { - if (hasPendingUplift(row)) { - return [PENDING_UPLIFT_GAP]; + const gaps: string[] = []; + + if (!hasDispatcher(row)) { + gaps.push("Dispatcher"); } - return []; + if (!row.tech.trim()) { + gaps.push("Technician"); + } + if (!row.completedDate.trim()) { + gaps.push("Completed Date"); + } + if (!isRealWoNumber(row.woNumber)) { + gaps.push("WO number"); + } + if (!hasService(row.pm)) { + gaps.push("Service"); + } + + 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.length === 1 && gaps[0] === PENDING_UPLIFT_GAP) { + return UPLIFT_PENDING_COMPLETED_TITLE; + } return `Missing: ${gaps.join(", ")}`; } @@ -40,3 +120,18 @@ export function canGenerateCompDoc(row: WoClosabilityInput): CompDocGateResult { } 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, + tech: draft.vendorName, + completedDate: "", + woNumber: draft.woNumber, + pm: draft.pm, + docStatus: "", + mediaCount: 0, + type: draft.type, + }; +} 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 index 11659ae9..998b2680 100644 --- a/src/test/app/(protected)/workorders/status-cell-pending-uplift.test.tsx +++ b/src/test/app/(protected)/workorders/status-cell-pending-uplift.test.tsx @@ -13,6 +13,7 @@ function makeRow(overrides: Partial = {}): WorkOrderTableRow dispatcherId: "u1", dispatcherName: "Alex", scheduledOn: "2026-07-15", + completedDate: "2026-07-20", targetWeek: "2026-07-15", dayGroup: "07/15", dayLabel: "Tue", @@ -29,7 +30,7 @@ function makeRow(overrides: Partial = {}): WorkOrderTableRow apptEnd: "", apptTime: "", status: "In Progress", - docStatus: "No", + docStatus: "Yes", pocName: "", pocPhone: "", pocNotes: "", @@ -40,7 +41,7 @@ function makeRow(overrides: Partial = {}): WorkOrderTableRow originalDate: "", isPastDue: false, scheduleWeekOnly: false, - mediaCount: 0, + mediaCount: 1, title: "", priority: "", primaryDispatchId: null, @@ -68,7 +69,10 @@ describe("StatusCell pending uplift closability", () => { const completed = screen.getByRole("button", { name: /completed/i }); expect(completed).toBeDisabled(); - expect(completed).toHaveAttribute("title", "Missing: Pending uplift approval"); + expect(completed).toHaveAttribute( + "title", + "This work order cannot be completed while an uplift is pending approval.", + ); fireEvent.click(completed); expect(onChangeStatus).not.toHaveBeenCalled(); diff --git a/src/test/domain/work-orders/utils/wo-closability.test.ts b/src/test/domain/work-orders/utils/wo-closability.test.ts index f3a3a353..c0b24abb 100644 --- a/src/test/domain/work-orders/utils/wo-closability.test.ts +++ b/src/test/domain/work-orders/utils/wo-closability.test.ts @@ -1,20 +1,41 @@ 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", + tech: "Sam", + completedDate: "2026-07-01", + woNumber: "1001", + pm: "HVAC", + docStatus: "Yes", + mediaCount: 1, + type: "PM", upliftSummary: undefined, hasPendingUplift: undefined, ...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); @@ -35,27 +56,63 @@ describe("hasPendingUplift", () => { }); describe("getClosabilityGaps", () => { - it("returns empty when no pending uplift", () => { + it("returns empty when all gates are satisfied", () => { expect(getClosabilityGaps(makeInput())).toEqual([]); }); + it("lists field gaps before uplift and document gaps", () => { + expect( + getClosabilityGaps( + makeInput({ + dispatcherId: "", + dispatcherName: "", + tech: " ", + completedDate: "", + woNumber: "SH-1", + pm: "", + hasPendingUplift: true, + docStatus: "No", + type: "PM", + }), + ), + ).toEqual([ + "Dispatcher", + "Technician", + "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 approval"]); + ).toEqual([PENDING_UPLIFT_GAP]); }); it("blocks Completed when hasPendingUplift is true", () => { - expect(getClosabilityGaps(makeInput({ hasPendingUplift: true }))).toEqual([ - "Pending uplift approval", - ]); + expect(getClosabilityGaps(makeInput({ hasPendingUplift: true }))).toEqual([PENDING_UPLIFT_GAP]); + }); + + it("requires a photo for Emergency / Reactive", () => { + expect( + getClosabilityGaps(makeInput({ type: "Emergency", mediaCount: 0, docStatus: "No" })), + ).toEqual(["At least 1 photo"]); }); }); describe("formatClosabilityMissingTitle", () => { - it("joins gaps for tooltip copy", () => { - expect(formatClosabilityMissingTitle(["Pending uplift approval"])).toBe( - "Missing: Pending uplift approval", + it("uses design Completed title when only uplift is missing", () => { + expect(formatClosabilityMissingTitle([PENDING_UPLIFT_GAP])).toBe( + UPLIFT_PENDING_COMPLETED_TITLE, + ); + }); + + it("joins multiple gaps with Missing prefix", () => { + expect(formatClosabilityMissingTitle(["Dispatcher", PENDING_UPLIFT_GAP])).toBe( + `Missing: Dispatcher, ${PENDING_UPLIFT_GAP}`, ); }); }); @@ -65,10 +122,10 @@ describe("canGenerateCompDoc", () => { expect(canGenerateCompDoc(makeInput())).toEqual({ allowed: true }); }); - it("blocks generation with reason when pending uplift exists", () => { + it("blocks generation with design reason when only pending uplift exists", () => { expect(canGenerateCompDoc(makeInput({ hasPendingUplift: true }))).toEqual({ allowed: false, - reason: "Missing: Pending uplift approval", + 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 index d9bf1215..13a8b08c 100644 --- a/tmp/pr-descriptions/pr4-uplift-pending-close-gate.md +++ b/tmp/pr-descriptions/pr4-uplift-pending-close-gate.md @@ -1,12 +1,19 @@ -# PR4 — Pending uplift close gate +# PR4 — Pending uplift close gate (+ SH-114 closability union) ## Summary -Blocks marking a work order **Completed** (and future completion-document generation) while an uplift request is still pending approval. +Blocks marking a work order **Completed** (and future completion-document generation) when closability gaps remain — **SH-114 field gates ∪ pending uplift** (SH-188). -- Adds optional `upliftSummary` / `hasPendingUplift` on `WorkOrderTableRow`, mapped from the board API when present. -- Introduces `getClosabilityGaps`, `formatClosabilityMissingTitle`, and `canGenerateCompDoc` in `wo-closability.ts` (pending-uplift scope only on this base). -- Wires the board **Status** popover to disable **Completed** with tooltip `Missing: Pending uplift approval` when a gap exists. +- 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 @@ -16,15 +23,17 @@ Blocks marking a work order **Completed** (and future completion-document genera - Full uplift CRUD (PR3) - Advanced filter facets for uplift -- Full closability matrix (dispatcher, comp doc, photos, etc.) — separate gate work +- Scheduled-requires-date gate (design P1) +- COL_HELP tooltips (P1) ## Test plan -- [ ] Row with `hasPendingUplift: true` — Status menu shows Completed disabled; tooltip explains missing pending uplift approval. -- [ ] Row without pending uplift — Completed remains selectable. -- [ ] `canGenerateCompDoc` returns `{ allowed: false, reason: "Missing: Pending uplift approval" }` when pending uplift is present. -- [ ] Board API payload with `upliftSummary.pendingCount > 0` maps through to closability gate. +- [ ] 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`. Does not depend on PR3 branch git-wise; uplift fields are optional stubs until BE/PR3 land. +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. From ccfb3d9e6d0662f136e24eab359e33787ab80014 Mon Sep 17 00:00:00 2001 From: "arthur.bassi" Date: Mon, 3 Aug 2026 14:13:51 -0300 Subject: [PATCH 05/19] fix(work-orders): split status cell to satisfy max-lines-per-function --- .../list/table/cells/status-cell.tsx | 173 +++++++++--------- 1 file changed, 85 insertions(+), 88 deletions(-) diff --git a/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx index 28049593..67d826ee 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx @@ -1,46 +1,28 @@ import { useState } from "react"; - import { AlertTriangle, Check } from "lucide-react"; - import { Popover } from "@mui/material"; import { Text } from "@/components/ui/text"; - import { ALL_WIZARD_STATUSES, type WizardWOStatus, } from "@/domain/work-orders/types/work-order-wizard"; - import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import { formatClosabilityMissingTitle, getClosabilityGaps, } from "@/domain/work-orders/utils/wo-closability"; - import { HoverChevron } from "./hover-chevron"; - import { WoTableStatusLabel } from "./wo-table-status-label"; type StatusCellProps = { wo: WorkOrderTableRow; - onChangeStatus: (status: WizardWOStatus) => void; - onOpenSlideOver: () => void; - onPastDueAttempt?: () => void; }; -export function StatusCell({ - wo, - - onChangeStatus, - - onOpenSlideOver, - - onPastDueAttempt, -}: StatusCellProps) { - const [anchorEl, setAnchorEl] = useState(null); - const closabilityGaps = getClosabilityGaps({ +function rowClosabilityGaps(wo: WorkOrderTableRow) { + return getClosabilityGaps({ dispatcherId: wo.dispatcherId, dispatcherName: wo.dispatcherName, tech: wo.tech, @@ -53,73 +35,91 @@ export function StatusCell({ upliftSummary: wo.upliftSummary, hasPendingUplift: wo.hasPendingUplift, }); +} + +function PastDueStatusCell({ + onOpenSlideOver, + onPastDueAttempt, +}: { + onOpenSlideOver: () => void; + onPastDueAttempt?: () => void; +}) { + const [anchorEl, setAnchorEl] = useState(null); + + return ( + <> + + + 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 the Due Date before the status can be updated. + + +
+
+
+ + ); +} + +export function StatusCell({ + wo, + onChangeStatus, + onOpenSlideOver, + onPastDueAttempt, +}: StatusCellProps) { + const [anchorEl, setAnchorEl] = useState(null); + const closabilityGaps = rowClosabilityGaps(wo); if (wo.isPastDue) { return ( - <> - - - 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 the Due Date before the status can be updated. - - - -
-
-
- + ); } @@ -129,7 +129,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" @@ -139,7 +138,6 @@ export function StatusCell({ ) : ( — )} - @@ -177,8 +175,7 @@ export function StatusCell({ } > - - {wo.status === status && ( + {Boolean(wo.status === status) && ( )} From a3e9a20477283f98a4bfe2af34332c6e9a9e0a8b Mon Sep 17 00:00:00 2001 From: "arthur.bassi" Date: Tue, 4 Aug 2026 11:42:44 -0300 Subject: [PATCH 06/19] fix(work-orders): fail closed on unknown uplift status --- .../list/table/cells/status-cell.tsx | 1 + .../work-orders/utils/wo-closability.ts | 20 ++++++++++++---- .../work-orders/utils/wo-closability.test.ts | 24 +++++++++++++++---- 3 files changed, 37 insertions(+), 8 deletions(-) diff --git a/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx index 67d826ee..ddcdbc5e 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx @@ -25,6 +25,7 @@ function rowClosabilityGaps(wo: WorkOrderTableRow) { return getClosabilityGaps({ dispatcherId: wo.dispatcherId, dispatcherName: wo.dispatcherName, + company: wo.company, tech: wo.tech, completedDate: wo.completedDate ?? "", woNumber: wo.woNumber, diff --git a/src/domain/work-orders/utils/wo-closability.ts b/src/domain/work-orders/utils/wo-closability.ts index 5f1c67c6..606f9369 100644 --- a/src/domain/work-orders/utils/wo-closability.ts +++ b/src/domain/work-orders/utils/wo-closability.ts @@ -8,7 +8,9 @@ import type { WOType, WorkOrderWizardDraft } from "@/domain/work-orders/types/wo export type WoClosabilityInput = { dispatcherId: string; dispatcherName: string; - /** Vendor / technician assignment on the board row. */ + /** Vendor/company assignment on the board row. */ + company: string; + /** Technician name is informational and does not satisfy the vendor requirement. */ tech: string; /** Work completed date (API CompletedDate). Empty until set. */ completedDate: string; @@ -22,6 +24,7 @@ export type WoClosabilityInput = { }; export const PENDING_UPLIFT_GAP = "Pending uplift approval"; +export const UPLIFT_STATUS_UNAVAILABLE_GAP = "Uplift status unavailable"; /** Design copy when Completed is blocked solely by a pending uplift. */ export const UPLIFT_PENDING_COMPLETED_TITLE = @@ -45,6 +48,12 @@ export function hasPendingUplift( return (row.upliftSummary?.pendingCount ?? 0) > 0; } +function hasKnownUpliftStatus( + row: Pick, +): boolean { + return typeof row.hasPendingUplift === "boolean" || row.upliftSummary != null; +} + function hasDispatcher(row: Pick): boolean { return Boolean(row.dispatcherId.trim() || row.dispatcherName.trim()); } @@ -68,8 +77,8 @@ export function getClosabilityGaps(row: WoClosabilityInput): string[] { if (!hasDispatcher(row)) { gaps.push("Dispatcher"); } - if (!row.tech.trim()) { - gaps.push("Technician"); + if (!row.company.trim()) { + gaps.push("Company / vendor"); } if (!row.completedDate.trim()) { gaps.push("Completed Date"); @@ -81,7 +90,9 @@ export function getClosabilityGaps(row: WoClosabilityInput): string[] { gaps.push("Service"); } - if (hasPendingUplift(row)) { + if (!hasKnownUpliftStatus(row)) { + gaps.push(UPLIFT_STATUS_UNAVAILABLE_GAP); + } else if (hasPendingUplift(row)) { gaps.push(PENDING_UPLIFT_GAP); } @@ -126,6 +137,7 @@ export function wizardDraftToClosabilityInput(draft: WorkOrderWizardDraft): WoCl return { dispatcherId: draft.assigneeId, dispatcherName: draft.assigneeName, + company: draft.vendorName, tech: draft.vendorName, completedDate: "", woNumber: draft.woNumber, diff --git a/src/test/domain/work-orders/utils/wo-closability.test.ts b/src/test/domain/work-orders/utils/wo-closability.test.ts index c0b24abb..079ac992 100644 --- a/src/test/domain/work-orders/utils/wo-closability.test.ts +++ b/src/test/domain/work-orders/utils/wo-closability.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import type { WoClosabilityInput } from "@/domain/work-orders/utils/wo-closability"; import { PENDING_UPLIFT_GAP, + UPLIFT_STATUS_UNAVAILABLE_GAP, UPLIFT_PENDING_COMPLETED_TITLE, canGenerateCompDoc, formatClosabilityMissingTitle, @@ -14,6 +15,7 @@ function makeInput(overrides: Partial = {}): WoClosabilityIn return { dispatcherId: "u1", dispatcherName: "Alex", + company: "Acme HVAC", tech: "Sam", completedDate: "2026-07-01", woNumber: "1001", @@ -21,8 +23,7 @@ function makeInput(overrides: Partial = {}): WoClosabilityIn docStatus: "Yes", mediaCount: 1, type: "PM", - upliftSummary: undefined, - hasPendingUplift: undefined, + hasPendingUplift: false, ...overrides, }; } @@ -47,7 +48,7 @@ describe("hasPendingUplift", () => { ).toBe(true); }); - it("is false when no uplift signals are present", () => { + it("is false when uplift is explicitly not pending", () => { expect(hasPendingUplift(makeInput())).toBe(false); expect( hasPendingUplift(makeInput({ upliftSummary: { hasUplift: false, pendingCount: 0 } })), @@ -60,12 +61,21 @@ describe("getClosabilityGaps", () => { expect(getClosabilityGaps(makeInput())).toEqual([]); }); + it("accepts a company without a technician", () => { + expect(getClosabilityGaps(makeInput({ tech: "" }))).toEqual([]); + }); + + it("does not let a technician satisfy a missing company requirement", () => { + expect(getClosabilityGaps(makeInput({ company: "" }))).toEqual(["Company / vendor"]); + }); + it("lists field gaps before uplift and document gaps", () => { expect( getClosabilityGaps( makeInput({ dispatcherId: "", dispatcherName: "", + company: " ", tech: " ", completedDate: "", woNumber: "SH-1", @@ -77,7 +87,7 @@ describe("getClosabilityGaps", () => { ), ).toEqual([ "Dispatcher", - "Technician", + "Company / vendor", "Completed Date", "WO number", "Service", @@ -96,6 +106,12 @@ describe("getClosabilityGaps", () => { expect(getClosabilityGaps(makeInput({ hasPendingUplift: true }))).toEqual([PENDING_UPLIFT_GAP]); }); + it("blocks Completed when the backend does not provide uplift status", () => { + expect( + getClosabilityGaps(makeInput({ hasPendingUplift: undefined, upliftSummary: undefined })), + ).toEqual([UPLIFT_STATUS_UNAVAILABLE_GAP]); + }); + it("requires a photo for Emergency / Reactive", () => { expect( getClosabilityGaps(makeInput({ type: "Emergency", mediaCount: 0, docStatus: "No" })), From 67c4ee81190b187e81a9d0e888995eb6487819c2 Mon Sep 17 00:00:00 2001 From: "arthur.bassi" Date: Tue, 4 Aug 2026 14:49:16 -0300 Subject: [PATCH 07/19] fix(work-orders): only block Completed on evidenced pending uplift Absence of optional uplift fields means no pending, per SH-188. --- src/domain/work-orders/utils/wo-closability.ts | 12 ++---------- .../domain/work-orders/utils/wo-closability.test.ts | 5 ++--- 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/src/domain/work-orders/utils/wo-closability.ts b/src/domain/work-orders/utils/wo-closability.ts index 606f9369..24617591 100644 --- a/src/domain/work-orders/utils/wo-closability.ts +++ b/src/domain/work-orders/utils/wo-closability.ts @@ -24,7 +24,6 @@ export type WoClosabilityInput = { }; export const PENDING_UPLIFT_GAP = "Pending uplift approval"; -export const UPLIFT_STATUS_UNAVAILABLE_GAP = "Uplift status unavailable"; /** Design copy when Completed is blocked solely by a pending uplift. */ export const UPLIFT_PENDING_COMPLETED_TITLE = @@ -48,12 +47,6 @@ export function hasPendingUplift( return (row.upliftSummary?.pendingCount ?? 0) > 0; } -function hasKnownUpliftStatus( - row: Pick, -): boolean { - return typeof row.hasPendingUplift === "boolean" || row.upliftSummary != null; -} - function hasDispatcher(row: Pick): boolean { return Boolean(row.dispatcherId.trim() || row.dispatcherName.trim()); } @@ -90,9 +83,8 @@ export function getClosabilityGaps(row: WoClosabilityInput): string[] { gaps.push("Service"); } - if (!hasKnownUpliftStatus(row)) { - gaps.push(UPLIFT_STATUS_UNAVAILABLE_GAP); - } else if (hasPendingUplift(row)) { + // Only an actually pending uplift blocks completion; missing uplift fields mean no pending evidence. + if (hasPendingUplift(row)) { gaps.push(PENDING_UPLIFT_GAP); } diff --git a/src/test/domain/work-orders/utils/wo-closability.test.ts b/src/test/domain/work-orders/utils/wo-closability.test.ts index 079ac992..a55938e7 100644 --- a/src/test/domain/work-orders/utils/wo-closability.test.ts +++ b/src/test/domain/work-orders/utils/wo-closability.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest"; import type { WoClosabilityInput } from "@/domain/work-orders/utils/wo-closability"; import { PENDING_UPLIFT_GAP, - UPLIFT_STATUS_UNAVAILABLE_GAP, UPLIFT_PENDING_COMPLETED_TITLE, canGenerateCompDoc, formatClosabilityMissingTitle, @@ -106,10 +105,10 @@ describe("getClosabilityGaps", () => { expect(getClosabilityGaps(makeInput({ hasPendingUplift: true }))).toEqual([PENDING_UPLIFT_GAP]); }); - it("blocks Completed when the backend does not provide uplift status", () => { + it("allows Completed when uplift status fields are absent (no pending evidence)", () => { expect( getClosabilityGaps(makeInput({ hasPendingUplift: undefined, upliftSummary: undefined })), - ).toEqual([UPLIFT_STATUS_UNAVAILABLE_GAP]); + ).toEqual([]); }); it("requires a photo for Emergency / Reactive", () => { From dd60b1d72e24de8bfce296f96518ead7158713d8 Mon Sep 17 00:00:00 2001 From: "arthur.bassi" Date: Mon, 10 Aug 2026 13:46:33 -0300 Subject: [PATCH 08/19] fix(work-orders): allow comp-doc generate without existing completion document canGenerateCompDoc now ignores the completion-document closability gap so PM/Overdue drafts can enter generation. Co-authored-by: Cursor --- src/domain/work-orders/utils/wo-closability.ts | 4 +++- src/test/domain/work-orders/utils/wo-closability.test.ts | 4 ++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/domain/work-orders/utils/wo-closability.ts b/src/domain/work-orders/utils/wo-closability.ts index 24617591..7259cbf5 100644 --- a/src/domain/work-orders/utils/wo-closability.ts +++ b/src/domain/work-orders/utils/wo-closability.ts @@ -115,9 +115,11 @@ export type CompDocGateResult = { allowed: true } | { allowed: false; reason: st /** * 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); + const gaps = getClosabilityGaps(row).filter((gap) => gap !== "Completion document"); if (gaps.length > 0) { return { allowed: false, reason: formatClosabilityMissingTitle(gaps) }; } diff --git a/src/test/domain/work-orders/utils/wo-closability.test.ts b/src/test/domain/work-orders/utils/wo-closability.test.ts index a55938e7..ea06ad4d 100644 --- a/src/test/domain/work-orders/utils/wo-closability.test.ts +++ b/src/test/domain/work-orders/utils/wo-closability.test.ts @@ -137,6 +137,10 @@ describe("canGenerateCompDoc", () => { 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, From 8a221c8819e449919e7ceac2194c66bbdeef9b1a Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Wed, 12 Aug 2026 09:55:34 -0300 Subject: [PATCH 09/19] fix(work-orders): consume board pendingUpliftCount for SH-188 gate Map the authoritative board/search/detail pendingUpliftCount into the Completed closability gate and align the pending-uplift tooltip with SH-188. --- .../mappers/work-order-table-row-mapper.ts | 28 +++++++++++-------- .../work-orders/types/work-order-board.ts | 7 +++++ .../work-orders/utils/wo-closability.ts | 7 +++-- .../status-cell-pending-uplift.test.tsx | 27 +++++++++++++++++- .../mappers/work-order-mapper.test.ts | 22 +++++++++++++++ 5 files changed, 76 insertions(+), 15 deletions(-) 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 3b9fc66b..ab226518 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 @@ -177,23 +177,25 @@ function readFlagColor(record: Record): string | null { function readUpliftSummary(item: Record): WorkOrderUpliftSummary | undefined { const nested = asRecord(item.upliftSummary ?? item.UpliftSummary); - const source = Object.keys(nested).length > 0 ? nested : item; + const hasNested = Object.keys(nested).length > 0; + const source = hasNested ? nested : item; - const pendingCount = - readNumber(source, "pendingCount", "PendingCount") ?? - readNumber(item, "pendingUpliftCount", "PendingUpliftCount") ?? - 0; + // 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; - if (!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: hasUplift || pendingCount > 0, pendingCount, }; const primaryStatus = readString(source, "primaryStatus", "PrimaryStatus", "status", "Status"); @@ -207,11 +209,15 @@ function readHasPendingUplift( item: Record, upliftSummary: WorkOrderUpliftSummary | undefined, ): boolean | undefined { - if (readBool(item, "hasPendingUplift", "HasPendingUplift")) { - return true; + if ("hasPendingUplift" in item || "HasPendingUplift" in item) { + return Boolean(item.hasPendingUplift ?? item.HasPendingUplift); } - if ((upliftSummary?.pendingCount ?? 0) > 0) { - return true; + const boardCount = readNumber(item, "pendingUpliftCount", "PendingUpliftCount"); + if (boardCount != null) { + return boardCount > 0; + } + if (upliftSummary != null) { + return upliftSummary.pendingCount > 0; } return undefined; } diff --git a/src/domain/work-orders/types/work-order-board.ts b/src/domain/work-orders/types/work-order-board.ts index 021fe1c0..fec033b8 100644 --- a/src/domain/work-orders/types/work-order-board.ts +++ b/src/domain/work-orders/types/work-order-board.ts @@ -159,9 +159,16 @@ export interface WorkOrderBoardRowApi { serviceNotes?: string | null; extraServices?: string[] | null; docStatus?: number | null; + /** Work completed date from board/detail contract. */ + completedDate?: string | null; 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/utils/wo-closability.ts b/src/domain/work-orders/utils/wo-closability.ts index 7259cbf5..388d0e02 100644 --- a/src/domain/work-orders/utils/wo-closability.ts +++ b/src/domain/work-orders/utils/wo-closability.ts @@ -25,9 +25,9 @@ export type WoClosabilityInput = { export const PENDING_UPLIFT_GAP = "Pending uplift approval"; -/** Design copy when Completed is blocked solely by a pending uplift. */ +/** SH-188 tooltip when Completed is blocked solely by a pending uplift. */ export const UPLIFT_PENDING_COMPLETED_TITLE = - "This work order cannot be completed while an uplift is pending approval."; + "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 { @@ -83,7 +83,8 @@ export function getClosabilityGaps(row: WoClosabilityInput): string[] { gaps.push("Service"); } - // Only an actually pending uplift blocks completion; missing uplift fields mean no pending evidence. + // 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); } 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 index 998b2680..5d610390 100644 --- a/src/test/app/(protected)/workorders/status-cell-pending-uplift.test.tsx +++ b/src/test/app/(protected)/workorders/status-cell-pending-uplift.test.tsx @@ -71,13 +71,38 @@ describe("StatusCell pending uplift closability", () => { expect(completed).toBeDisabled(); expect(completed).toHaveAttribute( "title", - "This work order cannot be completed while an uplift is pending approval.", + "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(); 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 1b3baa4e..2a33a51c 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 @@ -154,6 +154,28 @@ describe("mapWorkOrderTableRow (board DTO)", () => { 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("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, From 1835a5939a01847e7d721f5cb399f1b08c92f694 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Thu, 13 Aug 2026 15:21:16 -0300 Subject: [PATCH 10/19] fix(work-orders): dedupe completedDate and gate Completed on technician (SH-188) Refresh onto current dev and resolve exact-head review blockers: - Remove duplicate WorkOrderBoardRowApi.completedDate after merge with dev - Add SH-114 Technician closability gate alongside company/vendor check - Update wo-closability regressions for missing technician --- src/domain/work-orders/types/work-order-board.ts | 2 -- src/domain/work-orders/utils/wo-closability.ts | 5 ++++- src/test/domain/work-orders/utils/wo-closability.test.ts | 5 +++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/domain/work-orders/types/work-order-board.ts b/src/domain/work-orders/types/work-order-board.ts index b9ad2110..9f86b153 100644 --- a/src/domain/work-orders/types/work-order-board.ts +++ b/src/domain/work-orders/types/work-order-board.ts @@ -162,8 +162,6 @@ export interface WorkOrderBoardRowApi { serviceNotes?: string | null; extraServices?: string[] | null; docStatus?: number | null; - /** Work completed date from board/detail contract. */ - completedDate?: string | null; primaryDispatchId?: number | null; rowVersion?: string | null; dispatchRowVersion?: string | null; diff --git a/src/domain/work-orders/utils/wo-closability.ts b/src/domain/work-orders/utils/wo-closability.ts index 388d0e02..f8ed2972 100644 --- a/src/domain/work-orders/utils/wo-closability.ts +++ b/src/domain/work-orders/utils/wo-closability.ts @@ -10,7 +10,7 @@ export type WoClosabilityInput = { dispatcherName: string; /** Vendor/company assignment on the board row. */ company: string; - /** Technician name is informational and does not satisfy the vendor requirement. */ + /** Technician assigned to the work order. */ tech: string; /** Work completed date (API CompletedDate). Empty until set. */ completedDate: string; @@ -70,6 +70,9 @@ export function getClosabilityGaps(row: WoClosabilityInput): string[] { if (!hasDispatcher(row)) { gaps.push("Dispatcher"); } + if (!row.tech.trim()) { + gaps.push("Technician"); + } if (!row.company.trim()) { gaps.push("Company / vendor"); } diff --git a/src/test/domain/work-orders/utils/wo-closability.test.ts b/src/test/domain/work-orders/utils/wo-closability.test.ts index ea06ad4d..228f3dbe 100644 --- a/src/test/domain/work-orders/utils/wo-closability.test.ts +++ b/src/test/domain/work-orders/utils/wo-closability.test.ts @@ -60,8 +60,8 @@ describe("getClosabilityGaps", () => { expect(getClosabilityGaps(makeInput())).toEqual([]); }); - it("accepts a company without a technician", () => { - expect(getClosabilityGaps(makeInput({ tech: "" }))).toEqual([]); + it("blocks Completed when technician is missing", () => { + expect(getClosabilityGaps(makeInput({ tech: "" }))).toEqual(["Technician"]); }); it("does not let a technician satisfy a missing company requirement", () => { @@ -86,6 +86,7 @@ describe("getClosabilityGaps", () => { ), ).toEqual([ "Dispatcher", + "Technician", "Company / vendor", "Completed Date", "WO number", From 93034f5da2c65f75eb0a7177a71f650fd1249917 Mon Sep 17 00:00:00 2001 From: "arthur.bassi" Date: Thu, 13 Aug 2026 16:49:50 -0300 Subject: [PATCH 11/19] 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. --- docs/work-orders/board-search-api.md | 9 - .../list/table/cells/assigned-to-cell.tsx | 12 ++ .../_components/list/table/wo-table-body.tsx | 24 ++- .../wo-table-collapsible-section-header.tsx | 119 +++++++++++++ .../list/table/wo-table-inline-row.tsx | 7 +- .../list/table/wo-table-section-header.tsx | 115 ++++-------- .../_hooks/use-work-orders-table-data.ts | 22 ++- .../use-work-orders-table-local-state.ts | 2 + .../_hooks/use-work-orders-table-ui-state.ts | 8 +- .../work-orders/types/work-order-table-row.ts | 1 + src/domain/work-orders/types/work-order.ts | 1 + .../work-orders/utils/table-filter-utils.ts | 40 ++++- ...tsx => wo-table-unassigned-queue.test.tsx} | 92 +++++++--- .../board-unscheduled-design-parity.test.ts | 165 ------------------ .../utils/table-filter-utils.test.ts | 84 ++++++--- 15 files changed, 396 insertions(+), 305 deletions(-) create mode 100644 src/app/(protected)/workorders/_components/list/table/wo-table-collapsible-section-header.tsx rename src/test/app/(protected)/workorders/{wo-table-body-unscheduled.test.tsx => wo-table-unassigned-queue.test.tsx} (55%) delete mode 100644 src/test/domain/work-orders/utils/board-unscheduled-design-parity.test.ts diff --git a/docs/work-orders/board-search-api.md b/docs/work-orders/board-search-api.md index e476f53c..fde9438b 100644 --- a/docs/work-orders/board-search-api.md +++ b/docs/work-orders/board-search-api.md @@ -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 diff --git a/src/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell.tsx index 92557ea2..42a1bf93 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell.tsx @@ -75,6 +75,18 @@ export function AssignedToCell({ dispatcherId, users, highlight, onSave }: Assig />
+ {filtered.map((d) => ( + {Boolean(onAddWo) && ( + + )} +
+ + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row.tsx index 60bc03f4..76c5deb6 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 @@ -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); 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 a27feca7..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,72 +21,30 @@ export function WoTableSectionHeader({ onToggleCollapse, onAddWo, }: WoTableSectionHeaderProps) { - const isUnscheduled = variant === "unscheduled"; - - if (isUnscheduled) { - const toggleCollapse = () => { - onToggleCollapse?.(); - }; - + if (variant === "unassigned") { return ( - - -
- - -
- - + onToggleCollapse?.()} + /> + ); + } + + if (variant === "unscheduled") { + return ( + onToggleCollapse?.()} + onAddWo={onAddWo} + /> ); } @@ -139,15 +98,17 @@ export function WoTableSectionHeader({ Today )} - + {Boolean(onAddWo) && ( + + )} 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..c52a99bf 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 @@ -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, 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 ad0797e9..c1c8953f 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); @@ -81,6 +83,8 @@ export function useWorkOrdersTableUiState(weekMonday: string) { setDraft, startDayDraft, cancelDraft, + unassignedOpen, + setUnassignedOpen, unschedOpen, setUnschedOpen, selectedId, 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 4bcb4b60..ab7ea16d 100644 --- a/src/domain/work-orders/types/work-order-table-row.ts +++ b/src/domain/work-orders/types/work-order-table-row.ts @@ -115,3 +115,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/utils/table-filter-utils.ts b/src/domain/work-orders/utils/table-filter-utils.ts index be0b6ded..b1245ee7 100644 --- a/src/domain/work-orders/utils/table-filter-utils.ts +++ b/src/domain/work-orders/utils/table-filter-utils.ts @@ -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[]; diff --git a/src/test/app/(protected)/workorders/wo-table-body-unscheduled.test.tsx b/src/test/app/(protected)/workorders/wo-table-unassigned-queue.test.tsx similarity index 55% rename from src/test/app/(protected)/workorders/wo-table-body-unscheduled.test.tsx rename to src/test/app/(protected)/workorders/wo-table-unassigned-queue.test.tsx index 82551058..d510af3f 100644 --- a/src/test/app/(protected)/workorders/wo-table-body-unscheduled.test.tsx +++ b/src/test/app/(protected)/workorders/wo-table-unassigned-queue.test.tsx @@ -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", () => { , ); - 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( + + ( + + + + )} + renderInlineRow={() => null} + /> +
{row.woNumber}
, + ); + + await user.click(screen.getByRole("button", { name: "Unassigned, 1 work order, expanded" })); + expect(setUnassignedOpen).toHaveBeenCalledWith(expect.any(Function)); }); }); 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 deleted file mode 100644 index 4c919a28..00000000 --- a/src/test/domain/work-orders/utils/board-unscheduled-design-parity.test.ts +++ /dev/null @@ -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; - -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 b059bbd3..7316b094 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 @@ -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", () => { From 5d7ac297e452a0060557fa5f98bef80686367683 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Fri, 14 Aug 2026 10:55:06 -0300 Subject: [PATCH 12/19] fix(work-orders): drop vendor string from Completed closability (SH-114) --- src/domain/work-orders/utils/wo-closability.ts | 3 --- src/test/domain/work-orders/utils/wo-closability.test.ts | 5 ++--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/domain/work-orders/utils/wo-closability.ts b/src/domain/work-orders/utils/wo-closability.ts index f8ed2972..3117f600 100644 --- a/src/domain/work-orders/utils/wo-closability.ts +++ b/src/domain/work-orders/utils/wo-closability.ts @@ -73,9 +73,6 @@ export function getClosabilityGaps(row: WoClosabilityInput): string[] { if (!row.tech.trim()) { gaps.push("Technician"); } - if (!row.company.trim()) { - gaps.push("Company / vendor"); - } if (!row.completedDate.trim()) { gaps.push("Completed Date"); } diff --git a/src/test/domain/work-orders/utils/wo-closability.test.ts b/src/test/domain/work-orders/utils/wo-closability.test.ts index 228f3dbe..29f82f24 100644 --- a/src/test/domain/work-orders/utils/wo-closability.test.ts +++ b/src/test/domain/work-orders/utils/wo-closability.test.ts @@ -64,8 +64,8 @@ describe("getClosabilityGaps", () => { expect(getClosabilityGaps(makeInput({ tech: "" }))).toEqual(["Technician"]); }); - it("does not let a technician satisfy a missing company requirement", () => { - expect(getClosabilityGaps(makeInput({ company: "" }))).toEqual(["Company / vendor"]); + it("does not block Completed when vendor/company is empty if technician is assigned", () => { + expect(getClosabilityGaps(makeInput({ company: "" }))).toEqual([]); }); it("lists field gaps before uplift and document gaps", () => { @@ -87,7 +87,6 @@ describe("getClosabilityGaps", () => { ).toEqual([ "Dispatcher", "Technician", - "Company / vendor", "Completed Date", "WO number", "Service", From 37a6098ecbf31f5227514107addc3d9dce9988e7 Mon Sep 17 00:00:00 2001 From: "arthur.bassi" Date: Mon, 17 Aug 2026 13:54:05 -0300 Subject: [PATCH 13/19] fix(work-orders): require company not technician for Completed (SH-183) --- .../work-orders/utils/wo-closability.ts | 16 +++++--- .../status-cell-pending-uplift.test.tsx | 40 +++++++++++++++++++ .../work-orders/utils/wo-closability.test.ts | 13 +++--- 3 files changed, 58 insertions(+), 11 deletions(-) diff --git a/src/domain/work-orders/utils/wo-closability.ts b/src/domain/work-orders/utils/wo-closability.ts index 3117f600..024a1ca5 100644 --- a/src/domain/work-orders/utils/wo-closability.ts +++ b/src/domain/work-orders/utils/wo-closability.ts @@ -4,13 +4,13 @@ import type { } 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-114 ∪ pending uplift). */ +/** Fields required to evaluate whether a WO may be marked Completed (SH-183 ∪ pending uplift). */ export type WoClosabilityInput = { dispatcherId: string; dispatcherName: string; - /** Vendor/company assignment on the board row. */ + /** Company / vendor assignment — required for Completed (SH-183). */ company: string; - /** Technician assigned to the work order. */ + /** Technician is optional for closability; modeled separately from company. */ tech: string; /** Work completed date (API CompletedDate). Empty until set. */ completedDate: string; @@ -51,6 +51,10 @@ function hasDispatcher(row: Pick = {}): WorkOrderTableRow woNumber: "1001", site: "BK5", type: "PM", + severity: null, dispatcherId: "u1", dispatcherName: "Alex", scheduledOn: "2026-07-15", @@ -116,4 +117,43 @@ describe("StatusCell pending uplift closability", () => { 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/domain/work-orders/utils/wo-closability.test.ts b/src/test/domain/work-orders/utils/wo-closability.test.ts index 29f82f24..7c761560 100644 --- a/src/test/domain/work-orders/utils/wo-closability.test.ts +++ b/src/test/domain/work-orders/utils/wo-closability.test.ts @@ -60,12 +60,15 @@ describe("getClosabilityGaps", () => { expect(getClosabilityGaps(makeInput())).toEqual([]); }); - it("blocks Completed when technician is missing", () => { - expect(getClosabilityGaps(makeInput({ tech: "" }))).toEqual(["Technician"]); + it("allows company without technician (SH-183)", () => { + const gaps = getClosabilityGaps(makeInput({ tech: "" })); + expect(gaps).toEqual([]); + expect(gaps).not.toContain("Technician"); }); - it("does not block Completed when vendor/company is empty if technician is assigned", () => { - expect(getClosabilityGaps(makeInput({ company: "" }))).toEqual([]); + 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", () => { @@ -86,7 +89,7 @@ describe("getClosabilityGaps", () => { ), ).toEqual([ "Dispatcher", - "Technician", + "Company", "Completed Date", "WO number", "Service", From 958467fe400442bc13f98ea30fbd8feefef60259 Mon Sep 17 00:00:00 2001 From: "arthur.bassi" Date: Mon, 17 Aug 2026 14:57:39 -0300 Subject: [PATCH 14/19] fix(work-orders): load SH-165 Unassigned queue from global board search --- .../_components/list/table/wo-table-body.tsx | 22 +++++-- .../_hooks/use-work-orders-board-source.ts | 29 ++++++++- .../_hooks/use-work-orders-table-data.ts | 41 ++++++++++-- .../_hooks/use-work-orders-table-ui-state.ts | 2 +- .../api/fetch-unassigned-board-rows.ts | 29 +++++++++ .../work-orders/types/work-order-board.ts | 12 ++++ .../use-cases/use-work-orders-list.ts | 13 ++++ .../work-orders/utils/board-query-params.ts | 22 +++++++ .../work-orders/utils/table-filter-utils.ts | 45 +++++++++++++ src/infra/query-key/query-key.ts | 1 + .../wo-table-unassigned-queue.test.tsx | 50 ++++++++++++++ .../wo-table-unscheduled-highlight.test.tsx | 4 ++ .../api/fetch-unassigned-board-rows.test.ts | 52 +++++++++++++++ .../utils/board-query-params.test.ts | 21 ++++++ .../utils/table-filter-utils.test.ts | 65 +++++++++++++++++++ 15 files changed, 393 insertions(+), 15 deletions(-) create mode 100644 src/domain/work-orders/api/fetch-unassigned-board-rows.ts create mode 100644 src/test/domain/work-orders/api/fetch-unassigned-board-rows.test.ts 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 3ffd7172..5b2d5b38 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,10 +1,12 @@ import { Fragment, type ReactNode } from "react"; -import type { WorkOrderTableRow } 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 = { @@ -19,9 +21,11 @@ export function WoTableBody({ tableData, renderRow, renderInlineRow }: WoTableBo } const showUnassigned = - tableData.unassigned.length > 0 || tableData.draftDayKey === UNASSIGNED_DAY_KEY; + (tableData.unassigned?.length ?? 0) > 0 || + tableData.draftDayKey === UNASSIGNED_DAY_KEY || + Boolean(tableData.unassignedError); const showUnscheduled = - tableData.unscheduled.length > 0 || tableData.draftDayKey === UNSCHEDULED_DAY_KEY; + (tableData.unscheduled?.length ?? 0) > 0 || tableData.draftDayKey === UNSCHEDULED_DAY_KEY; return ( @@ -37,7 +41,17 @@ export function WoTableBody({ tableData, renderRow, renderInlineRow }: WoTableBo {tableData.unassignedOpen && ( <> {renderInlineRow(UNASSIGNED_DAY_KEY)} - {tableData.unassigned.map((row) => renderRow(row))} + {Boolean(tableData.unassignedError) && ( + + + + {tableData.unassignedError?.message ?? + "Unable to load unassigned work orders."} + + + + )} + {(tableData.unassigned ?? []).map((row) => renderRow(row))} )} 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..11771ca7 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,28 @@ -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; isLoading: boolean; isFetching: boolean; error: Error | null; @@ -67,13 +73,30 @@ export function useWorkOrdersBoardSource(filters: FiltersReturn): WorkOrdersBoar error: searchError, refetch: refetchSearch, } = useWorkOrdersBoardSearch(resolvedSearchParams ?? { datePreset: "ThisWeek" }, advActive); + const { + data: queueData, + 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), 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 c52a99bf..469fd465 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,11 +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, - partitionBoardRows, + mergeWeeklyAndUnassignedQueue, + rowIdSet, splitScheduledUnscheduled, } from "@/domain/work-orders/utils/table-filter-utils"; @@ -15,12 +18,30 @@ 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, + 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, @@ -44,8 +65,13 @@ export function useWorkOrdersTableData(filters: FiltersReturn) { const split = splitScheduledUnscheduled(advResults); return { unassigned: [], ...split }; } - return partitionBoardRows(filtered); - }, [advActive, advResults, filtered]); + 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]); @@ -80,6 +106,7 @@ export function useWorkOrdersTableData(filters: FiltersReturn) { advActive, advResults, unassigned, + unassignedError, unscheduled, displayGroups, searchActive, @@ -111,7 +138,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-ui-state.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-table-ui-state.ts index 981e0f0f..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 @@ -52,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; } diff --git a/src/domain/work-orders/api/fetch-unassigned-board-rows.ts b/src/domain/work-orders/api/fetch-unassigned-board-rows.ts new file mode 100644 index 00000000..4887496a --- /dev/null +++ b/src/domain/work-orders/api/fetch-unassigned-board-rows.ts @@ -0,0 +1,29 @@ +import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { toUnassignedQueueQueryParams } from "@/domain/work-orders/utils/board-query-params"; + +const UNASSIGNED_QUEUE_MAX_PAGES = 50; + +/** Exhausts GET /board/search pages for the global Unassigned pin (SH-165). */ +export async function fetchUnassignedBoardRows(): Promise { + const rows: WorkOrderTableRow[] = []; + let page = 0; + let totalCount = Number.POSITIVE_INFINITY; + + while (rows.length < totalCount) { + if (page >= UNASSIGNED_QUEUE_MAX_PAGES) { + throw new Error( + "Unable to load the unassigned queue: search result set exceeded the safety page limit.", + ); + } + const result = await workOrdersApi.searchBoard(toUnassignedQueueQueryParams(page)); + rows.push(...result.items); + totalCount = result.totalCount; + if (result.items.length === 0) { + break; + } + page += 1; + } + + return rows; +} diff --git a/src/domain/work-orders/types/work-order-board.ts b/src/domain/work-orders/types/work-order-board.ts index 51bbdc0b..04c885f9 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"], +]; + export const WORK_ORDER_TYPE_API_TO_FE: Record = { [WORK_ORDER_TYPE_API.PM]: "PM", [WORK_ORDER_TYPE_API.Reactive]: "Reactive", diff --git a/src/domain/work-orders/use-cases/use-work-orders-list.ts b/src/domain/work-orders/use-cases/use-work-orders-list.ts index 46fd94ef..59998f5a 100644 --- a/src/domain/work-orders/use-cases/use-work-orders-list.ts +++ b/src/domain/work-orders/use-cases/use-work-orders-list.ts @@ -1,4 +1,5 @@ import { keepPreviousData, useQuery, type UseQueryResult } from "@tanstack/react-query"; +import { fetchUnassignedBoardRows } from "@/domain/work-orders/api/fetch-unassigned-board-rows"; import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api"; import type { WorkOrdersListParams, @@ -10,6 +11,7 @@ import type { WorkOrderBoardQueryParams, WorkOrderBoardSearchQueryParams, } from "@/domain/work-orders/types/work-order-board"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import { queryKeys } from "@/infra/query-key/query-key"; export function useWorkOrdersList( @@ -46,6 +48,17 @@ export function useWorkOrdersBoardSearch( }); } +export function useWorkOrdersUnassignedQueue( + enabled = true, +): UseQueryResult { + return useQuery({ + queryKey: queryKeys.workOrders.unassignedQueue(), + queryFn: fetchUnassignedBoardRows, + enabled, + placeholderData: keepPreviousData, + }); +} + export function useWorkOrderUsers(): UseQueryResult { return useQuery({ queryKey: queryKeys.workOrders.dispatchers(), diff --git a/src/domain/work-orders/utils/board-query-params.ts b/src/domain/work-orders/utils/board-query-params.ts index 6f4824fe..293aa597 100644 --- a/src/domain/work-orders/utils/board-query-params.ts +++ b/src/domain/work-orders/utils/board-query-params.ts @@ -6,6 +6,7 @@ import { DATE_RANGE_TO_PRESET, DOC_STATUS_API, LIFECYCLE_STATUS_API, + NON_TERMINAL_LIFECYCLE_STATUSES, type WorkOrderBoardQueryParams, type WorkOrderBoardSearchQueryParams, } from "@/domain/work-orders/types/work-order-board"; @@ -16,6 +17,14 @@ import type { WizardWOStatus, WOType } from "@/domain/work-orders/types/work-ord export const BOARD_SEARCH_MAX_PAGE_SIZE = 100; +/** + * Wide Custom window for SH-165. GET /board/search defaults omitted + * `datePreset` to ThisWeek (`WorkOrderSearchDateRangeResolver`), which would + * hide scheduled-unassigned WOs outside the current week. + */ +export const UNASSIGNED_QUEUE_DATE_FROM = "1970-01-01"; +export const UNASSIGNED_QUEUE_DATE_TO = "2099-12-31"; + function toBoardDispatcherId(id: string): string { if (id === ASSIGNEE_FILTER_UNASSIGNED || id === BOARD_DISPATCHER_UNASSIGNED) { return BOARD_DISPATCHER_UNASSIGNED; @@ -175,3 +184,16 @@ export function buildBoardSearchParamsRecord( export function woTypeFilterIsOverdue(type: string): boolean { return type === "Overdue"; } + +/** Week-independent GET /board/search params for the pinned Unassigned queue. */ +export function toUnassignedQueueQueryParams(page = 0): WorkOrderBoardSearchQueryParams { + return { + page, + pageSize: BOARD_SEARCH_MAX_PAGE_SIZE, + dispatchers: [BOARD_DISPATCHER_UNASSIGNED], + statuses: [...NON_TERMINAL_LIFECYCLE_STATUSES], + datePreset: "Custom", + dateFrom: UNASSIGNED_QUEUE_DATE_FROM, + dateTo: UNASSIGNED_QUEUE_DATE_TO, + }; +} diff --git a/src/domain/work-orders/utils/table-filter-utils.ts b/src/domain/work-orders/utils/table-filter-utils.ts index 6c9df9ec..9c13d5bf 100644 --- a/src/domain/work-orders/utils/table-filter-utils.ts +++ b/src/domain/work-orders/utils/table-filter-utils.ts @@ -129,6 +129,51 @@ export function partitionBoardRows(rows: WorkOrderTableRow[]): { return { unassigned, unscheduled, scheduled }; } +export function rowIdSet(rows: WorkOrderTableRow[]): Set { + return new Set(rows.map((row) => String(row.id))); +} + +/** Weekly board rows win on id overlap so PATCH state stays authoritative. */ +export function mergeWeeklyAndUnassignedQueue( + weekly: WorkOrderTableRow[], + queue: WorkOrderTableRow[], +): WorkOrderTableRow[] { + if (queue.length === 0) { + return weekly; + } + const seen = rowIdSet(weekly); + const extras = queue.filter((row) => !seen.has(String(row.id))); + return extras.length === 0 ? weekly : [...weekly, ...extras]; +} + +type ComposeBoardSectionsInput = { + displayRows: WorkOrderTableRow[]; + weekRows: WorkOrderTableRow[]; + weeklyIds: ReadonlySet; + queueIds: ReadonlySet; +}; + +/** + * Pin is every non-terminal unassigned row (including out-of-week). + * Day/Unscheduled groups stay week-scoped and never repeat pin ids. + */ +export function composeBoardSections(input: ComposeBoardSectionsInput): { + unassigned: WorkOrderTableRow[]; + unscheduled: WorkOrderTableRow[]; + scheduled: WorkOrderTableRow[]; +} { + const unassigned = input.displayRows.filter(isUnassignedBoardRow); + const pinIds = rowIdSet(unassigned); + const weekAssigned = input.weekRows.filter((row) => { + const id = String(row.id); + if (pinIds.has(id) || isUnassignedBoardRow(row)) { + return false; + } + return input.weeklyIds.has(id) || !input.queueIds.has(id); + }); + return { unassigned, ...splitScheduledUnscheduled(weekAssigned) }; +} + export function splitScheduledUnscheduled(rows: WorkOrderTableRow[]): { scheduled: WorkOrderTableRow[]; unscheduled: WorkOrderTableRow[]; diff --git a/src/infra/query-key/query-key.ts b/src/infra/query-key/query-key.ts index c775e00e..87eeda4e 100644 --- a/src/infra/query-key/query-key.ts +++ b/src/infra/query-key/query-key.ts @@ -17,6 +17,7 @@ export const queryKeys = { [...queryKeys.workOrders.all, "board", params ?? {}] as const, boardSearch: (params?: WorkOrderBoardSearchQueryParams) => [...queryKeys.workOrders.all, "boardSearch", params ?? {}] as const, + unassignedQueue: () => [...queryKeys.workOrders.all, "unassignedQueue"] as const, byLocation: (locationId: string | number, search = "") => [...queryKeys.workOrders.all, "byLocation", locationId, search] as const, detail: (id: string | number) => [...queryKeys.workOrders.all, "detail", id] as const, diff --git a/src/test/app/(protected)/workorders/wo-table-unassigned-queue.test.tsx b/src/test/app/(protected)/workorders/wo-table-unassigned-queue.test.tsx index d510af3f..a5a371b0 100644 --- a/src/test/app/(protected)/workorders/wo-table-unassigned-queue.test.tsx +++ b/src/test/app/(protected)/workorders/wo-table-unassigned-queue.test.tsx @@ -11,9 +11,11 @@ function makeRow(overrides: Partial = {}): WorkOrderTableRow woNumber: "24991", site: "CLV2", type: "PM", + severity: null, dispatcherId: "", dispatcherName: "", scheduledOn: "2026-06-02", + completedDate: "", targetWeek: "", dayGroup: "06/02", dayLabel: "TUE · 06/02", @@ -64,6 +66,7 @@ function baseTableData( advActive: false, advResults: [], unassigned: [], + unassignedError: null, unscheduled: [], displayGroups: [], searchActive: false, @@ -202,4 +205,51 @@ describe("WoTableBody Unassigned queue (SH-165)", () => { await user.click(screen.getByRole("button", { name: "Unassigned, 1 work order, expanded" })); expect(setUnassignedOpen).toHaveBeenCalledWith(expect.any(Function)); }); + + it("renders a scheduled unassigned WO from outside the selected week in the pin", () => { + const outOfWeek = makeRow({ + id: "out-week", + woNumber: "26001", + scheduledOn: "2026-07-15", + dayGroup: "07/15", + dayLabel: "WED · 07/15", + }); + + render( + + ( + + + + )} + renderInlineRow={() => null} + /> +
{row.woNumber}
, + ); + + expect(screen.getByTestId("row-out-week")).toBeInTheDocument(); + expect(screen.queryByText("WED · 07/15")).not.toBeInTheDocument(); + }); + + it("keeps the Unassigned pin visible when the queue query fails", () => { + render( + + ( + + + + )} + renderInlineRow={() => null} + /> +
{row.woNumber}
, + ); + + expect(screen.getByText("queue unavailable")).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 f8edb9ff..71bb2206 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 @@ -75,6 +75,10 @@ const handlers: WoTableRowHandlers = { function renderUnscheduledHarness() { const tableData = { advActive: false, + unassigned: [], + unassignedError: null, + unassignedOpen: true, + setUnassignedOpen: vi.fn(), unscheduled: [unscheduledRow], unschedOpen: true, draftDayKey: null, diff --git a/src/test/domain/work-orders/api/fetch-unassigned-board-rows.test.ts b/src/test/domain/work-orders/api/fetch-unassigned-board-rows.test.ts new file mode 100644 index 00000000..bf8031e9 --- /dev/null +++ b/src/test/domain/work-orders/api/fetch-unassigned-board-rows.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { BOARD_SEARCH_MAX_PAGE_SIZE } from "@/domain/work-orders/utils/board-query-params"; + +const searchBoard = vi.fn(); + +vi.mock("@/domain/work-orders/api/work-orders-api", () => ({ + workOrdersApi: { + searchBoard: (...args: unknown[]) => searchBoard(...args), + }, +})); + +import { fetchUnassignedBoardRows } from "@/domain/work-orders/api/fetch-unassigned-board-rows"; + +function pageResult(items: Pick[], totalCount: number) { + return { + items, + scheduled: [], + unscheduled: [], + totalCount, + returnedCount: items.length, + page: 0, + pageSize: BOARD_SEARCH_MAX_PAGE_SIZE, + }; +} + +describe("fetchUnassignedBoardRows", () => { + beforeEach(() => { + searchBoard.mockReset(); + }); + + it("pages until totalCount is exhausted", async () => { + const first = Array.from({ length: BOARD_SEARCH_MAX_PAGE_SIZE }, (_, index) => ({ + id: `a-${index}`, + })); + const second = [{ id: "b-0" }]; + searchBoard + .mockResolvedValueOnce(pageResult(first, 101)) + .mockResolvedValueOnce(pageResult(second, 101)); + + const rows = await fetchUnassignedBoardRows(); + + expect(searchBoard).toHaveBeenCalledTimes(2); + expect(searchBoard.mock.calls[0]?.[0]).toMatchObject({ + page: 0, + dispatchers: ["__unassigned__"], + datePreset: "Custom", + }); + expect(searchBoard.mock.calls[1]?.[0]).toMatchObject({ page: 1 }); + expect(rows).toHaveLength(101); + }); +}); diff --git a/src/test/domain/work-orders/utils/board-query-params.test.ts b/src/test/domain/work-orders/utils/board-query-params.test.ts index 3fd01ec9..ba5b25ea 100644 --- a/src/test/domain/work-orders/utils/board-query-params.test.ts +++ b/src/test/domain/work-orders/utils/board-query-params.test.ts @@ -7,6 +7,9 @@ import { buildBoardSearchParamsRecord, toBoardQueryParams, toBoardSearchQueryParams, + toUnassignedQueueQueryParams, + UNASSIGNED_QUEUE_DATE_FROM, + UNASSIGNED_QUEUE_DATE_TO, } from "@/domain/work-orders/utils/board-query-params"; describe("toBoardQueryParams", () => { @@ -174,3 +177,21 @@ describe("buildBoardSearchParamsRecord", () => { expect(Object.keys(record)).not.toContain("types[]"); }); }); + +describe("toUnassignedQueueQueryParams", () => { + it("requests unassigned dispatchers with a Custom range, not the weekly board window", () => { + const params = toUnassignedQueueQueryParams(2); + const record = buildBoardSearchParamsRecord(params); + + expect(params.page).toBe(2); + expect(params.pageSize).toBe(BOARD_SEARCH_MAX_PAGE_SIZE); + expect(params.dispatchers).toEqual(["__unassigned__"]); + expect(params.statuses).not.toContain(7); + expect(params.statuses).not.toContain(9); + expect(params.datePreset).toBe("Custom"); + expect(params.dateFrom).toBe(UNASSIGNED_QUEUE_DATE_FROM); + expect(params.dateTo).toBe(UNASSIGNED_QUEUE_DATE_TO); + expect(record).not.toHaveProperty("weekStart"); + expect(record).not.toHaveProperty("weekEnd"); + }); +}); diff --git a/src/test/domain/work-orders/utils/table-filter-utils.test.ts b/src/test/domain/work-orders/utils/table-filter-utils.test.ts index 7985c879..146aa7ac 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 @@ -4,11 +4,13 @@ import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-ad 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"; @@ -184,6 +186,69 @@ describe("partitionBoardRows", () => { }); }); +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" }); From 0966962d8f662fb410c7fa49d58b5381524aeb6e Mon Sep 17 00:00:00 2001 From: "arthur.bassi" Date: Tue, 18 Aug 2026 11:50:46 -0300 Subject: [PATCH 15/19] fix(work-orders): keep Unassigned pin visible when empty Render the weekly Unassigned section at count 0 and cover assignment through the board PATCH path. --- .../_components/list/table/wo-table-body.tsx | 57 ++++---- .../wo-table-unassigned-queue.test.tsx | 125 ++++++++++++++++++ 2 files changed, 155 insertions(+), 27 deletions(-) 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 5b2d5b38..bfbde81a 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 @@ -20,40 +20,43 @@ export function WoTableBody({ tableData, renderRow, renderInlineRow }: WoTableBo return {tableData.advResults.map((row) => renderRow(row))}; } - const showUnassigned = - (tableData.unassigned?.length ?? 0) > 0 || - tableData.draftDayKey === UNASSIGNED_DAY_KEY || - Boolean(tableData.unassignedError); + const unassignedRows = tableData.unassigned ?? []; const showUnscheduled = (tableData.unscheduled?.length ?? 0) > 0 || tableData.draftDayKey === UNSCHEDULED_DAY_KEY; + const showUnassignedEmpty = + tableData.unassignedOpen && unassignedRows.length === 0 && !tableData.unassignedError; return ( - {showUnassigned && ( + tableData.setUnassignedOpen((v) => !v)} + /> + {tableData.unassignedOpen && ( <> - tableData.setUnassignedOpen((v) => !v)} - /> - {tableData.unassignedOpen && ( - <> - {renderInlineRow(UNASSIGNED_DAY_KEY)} - {Boolean(tableData.unassignedError) && ( - - - - {tableData.unassignedError?.message ?? - "Unable to load unassigned work orders."} - - - - )} - {(tableData.unassigned ?? []).map((row) => renderRow(row))} - + {renderInlineRow(UNASSIGNED_DAY_KEY)} + {Boolean(tableData.unassignedError) && ( + + + + {tableData.unassignedError?.message ?? "Unable to load unassigned work orders."} + + + )} + {showUnassignedEmpty && ( + + + + No unassigned work orders. + + + + )} + {unassignedRows.map((row) => renderRow(row))} )} {showUnscheduled && ( 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 index a5a371b0..d92553a0 100644 --- a/src/test/app/(protected)/workorders/wo-table-unassigned-queue.test.tsx +++ b/src/test/app/(protected)/workorders/wo-table-unassigned-queue.test.tsx @@ -1,9 +1,17 @@ +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 { @@ -102,6 +110,72 @@ function baseTableData( } as ReturnTypeOfUseWorkOrdersTableData; } +const DISPATCHER = { id: "sarah", name: "Sarah Chen", initials: "SC", color: "#3366cc" }; + +function UnassignedAssignHarness({ + initialRow, + patchBoardField, +}: { + initialRow: WorkOrderTableRow; + patchBoardField: ( + id: string | number, + payload: { field: string; value?: string | null }, + ) => Promise; +}) { + const [rows, setRows] = useState([initialRow]); + const weeklyIds = rowIdSet(rows); + const sections = useMemo( + () => + composeBoardSections({ + displayRows: rows, + weekRows: rows, + weeklyIds, + queueIds: weeklyIds, + }), + [rows, weeklyIds], + ); + const tableData = baseTableData({ + unassigned: sections.unassigned, + unscheduled: sections.unscheduled, + displayGroups: buildDayGroups(sections.scheduled), + }); + + return ( + + ( + + + + + )} + renderInlineRow={() => null} + /> +
{row.woNumber} + { + const ops = expandTablePatchToBoardFieldOps({ + dispatcherId: id, + dispatcherName: name, + }); + const patched = await patchBoardField(row.id, ops[0]!); + setRows((current) => + current.map((item) => + String(item.id) === String(row.id) + ? { ...patched, dispatcherId: id, dispatcherName: name } + : item, + ), + ); + }} + /> +
+ ); +} + describe("WoTableBody Unassigned queue (SH-165)", () => { it("renders Unassigned above Unscheduled and day groups without duplicate rows", () => { const datedUnassigned = makeRow({ id: "u-dated", woNumber: "24991" }); @@ -252,4 +326,55 @@ describe("WoTableBody Unassigned queue (SH-165)", () => { expect(screen.getByText("queue unavailable")).toBeInTheDocument(); }); + + it("keeps the Unassigned section visible with an empty state when the queue is empty", () => { + render( + + ( + + + + )} + renderInlineRow={() => null} + /> +
{row.woNumber}
, + ); + + expect( + screen.getByRole("button", { name: "Unassigned, 0 work orders, expanded" }), + ).toBeInTheDocument(); + expect(screen.getByText("No unassigned work orders.")).toBeInTheDocument(); + expect(screen.queryByTestId(/row-/)).not.toBeInTheDocument(); + }); + + it("removes a pinned row immediately after assigning through the board PATCH path", async () => { + const user = userEvent.setup(); + const pinned = makeRow({ + id: "u-pin", + woNumber: "24991", + dispatcherId: "", + dispatcherName: "", + rowVersion: "AAAAAAA=", + }); + const patchBoardField = vi.fn(async (_id: string | number, payload: { field: string }) => { + expect(payload.field).toBe("assignTo"); + return { ...pinned, dispatcherId: DISPATCHER.id, dispatcherName: DISPATCHER.name }; + }); + + render(); + + expect(screen.getByTestId("row-u-pin")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: /Select/ })); + await user.click(screen.getByRole("button", { name: /Sarah Chen/ })); + + expect(patchBoardField).toHaveBeenCalled(); + expect( + screen.getByRole("button", { name: "Unassigned, 0 work orders, expanded" }), + ).toBeInTheDocument(); + expect(screen.getByText("No unassigned work orders.")).toBeInTheDocument(); + expect(screen.getByTestId("row-u-pin")).toBeInTheDocument(); + expect(screen.getByText("TUE · 06/02")).toBeInTheDocument(); + }); }); From 32177e831b9eceaeb1fb21539839abcf4255d5e8 Mon Sep 17 00:00:00 2001 From: "arthur.bassi" Date: Tue, 18 Aug 2026 12:04:55 -0300 Subject: [PATCH 16/19] fix(work-orders): set isAddOn on pending-uplift StatusCell fixture Keep the SH-188 fixture assignable to WorkOrderTableRow after the SH-184 merge. --- .../(protected)/workorders/status-cell-pending-uplift.test.tsx | 1 + 1 file changed, 1 insertion(+) 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 index 6bc04e34..d886232a 100644 --- a/src/test/app/(protected)/workorders/status-cell-pending-uplift.test.tsx +++ b/src/test/app/(protected)/workorders/status-cell-pending-uplift.test.tsx @@ -42,6 +42,7 @@ function makeRow(overrides: Partial = {}): WorkOrderTableRow originalDate: "", isPastDue: false, scheduleWeekOnly: false, + isAddOn: false, mediaCount: 1, title: "", priority: "", From ac9ce341016990720d3a19f72370fc3d293132cd Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Tue, 18 Aug 2026 17:22:32 -0300 Subject: [PATCH 17/19] fix(work-orders): distinguish loading from empty in Unassigned pin (SH-165) The pinned Unassigned section folded the queue query's pending state into isFetching only, which the section never read. On first mount, once GET /board resolved while the queue was still paging GET /board/search, the pin announced "No unassigned work orders." instead of a loading state. Expose the queue query's own isLoading as unassignedLoading, thread it through the table data hook, and render an aria-busy loading row while it is true so the loading and empty states are separately observable. --- .../_components/list/table/wo-table-body.tsx | 22 ++++++++++++- .../_hooks/use-work-orders-board-source.ts | 6 ++++ .../_hooks/use-work-orders-table-data.ts | 2 ++ .../wo-table-unassigned-queue.test.tsx | 31 +++++++++++++++++++ 4 files changed, 60 insertions(+), 1 deletion(-) 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 bfbde81a..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 @@ -23,8 +23,19 @@ export function WoTableBody({ tableData, renderRow, renderInlineRow }: WoTableBo const unassignedRows = tableData.unassigned ?? []; const showUnscheduled = (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.unassignedOpen && + unassignedRows.length === 0 && + !tableData.unassignedError && + !tableData.unassignedLoading; return ( @@ -47,6 +58,15 @@ export function WoTableBody({ tableData, renderRow, renderInlineRow }: WoTableBo )} + {showUnassignedLoading && ( + + + + Loading unassigned work orders… + + + + )} {showUnassignedEmpty && ( 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 11771ca7..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 @@ -23,6 +23,7 @@ export type WorkOrdersBoardSource = { data: WorkOrderBoardListResult | undefined; unassignedQueue: WorkOrderTableRow[]; unassignedError: Error | null; + unassignedLoading: boolean; isLoading: boolean; isFetching: boolean; error: Error | null; @@ -75,6 +76,7 @@ export function useWorkOrdersBoardSource(filters: FiltersReturn): WorkOrdersBoar } = useWorkOrdersBoardSearch(resolvedSearchParams ?? { datePreset: "ThisWeek" }, advActive); const { data: queueData, + isLoading: queueLoading, isFetching: queueFetching, error: queueError, refetch: refetchQueue, @@ -94,6 +96,10 @@ export function useWorkOrdersBoardSource(filters: FiltersReturn): WorkOrdersBoar 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 || queueFetching, error: advActive ? searchError : boardError, 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 469fd465..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 @@ -26,6 +26,7 @@ export function useWorkOrdersTableData(filters: FiltersReturn) { data, unassignedQueue, unassignedError, + unassignedLoading, isLoading, isFetching, error, @@ -107,6 +108,7 @@ export function useWorkOrdersTableData(filters: FiltersReturn) { advResults, unassigned, unassignedError, + unassignedLoading, unscheduled, displayGroups, searchActive, 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 index d010346d..66a61208 100644 --- a/src/test/app/(protected)/workorders/wo-table-unassigned-queue.test.tsx +++ b/src/test/app/(protected)/workorders/wo-table-unassigned-queue.test.tsx @@ -76,6 +76,7 @@ function baseTableData( advResults: [], unassigned: [], unassignedError: null, + unassignedLoading: false, unscheduled: [], displayGroups: [], searchActive: false, @@ -308,6 +309,36 @@ describe("WoTableBody Unassigned queue (SH-165)", () => { 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( From a109a68f40524b95be20d487ade76d140bcaa13d Mon Sep 17 00:00:00 2001 From: "arthur.bassi" Date: Tue, 18 Aug 2026 17:28:14 -0300 Subject: [PATCH 18/19] fix(work-orders): keep SH-188 tooltip when mixed with other gaps Stop copying WO lifecycle status into uplift primaryStatus on flat board rows. --- .../work-orders/mappers/work-order-table-row-mapper.ts | 4 +++- src/domain/work-orders/utils/wo-closability.ts | 2 +- .../work-orders/mappers/work-order-mapper.test.ts | 10 ++++++++++ .../domain/work-orders/utils/wo-closability.test.ts | 10 ++++++++-- 4 files changed, 22 insertions(+), 4 deletions(-) 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 0442d420..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 @@ -239,7 +239,9 @@ function readUpliftSummary(item: Record): WorkOrderUpliftSummar hasUplift: hasUplift || pendingCount > 0, pendingCount, }; - const primaryStatus = readString(source, "primaryStatus", "PrimaryStatus", "status", "Status"); + const primaryStatus = + readString(nested, "primaryStatus", "PrimaryStatus") || + readString(item, "primaryStatus", "PrimaryStatus"); if (primaryStatus) { summary.primaryStatus = primaryStatus; } diff --git a/src/domain/work-orders/utils/wo-closability.ts b/src/domain/work-orders/utils/wo-closability.ts index 024a1ca5..be377091 100644 --- a/src/domain/work-orders/utils/wo-closability.ts +++ b/src/domain/work-orders/utils/wo-closability.ts @@ -109,7 +109,7 @@ export function getClosabilityGaps(row: WoClosabilityInput): string[] { } export function formatClosabilityMissingTitle(gaps: string[]): string { - if (gaps.length === 1 && gaps[0] === PENDING_UPLIFT_GAP) { + if (gaps.includes(PENDING_UPLIFT_GAP)) { return UPLIFT_PENDING_COMPLETED_TITLE; } return `Missing: ${gaps.join(", ")}`; 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 76d4af5c..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 @@ -209,6 +209,16 @@ describe("mapWorkOrderTableRow (board DTO)", () => { 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(); diff --git a/src/test/domain/work-orders/utils/wo-closability.test.ts b/src/test/domain/work-orders/utils/wo-closability.test.ts index 7c761560..8af2724e 100644 --- a/src/test/domain/work-orders/utils/wo-closability.test.ts +++ b/src/test/domain/work-orders/utils/wo-closability.test.ts @@ -128,9 +128,15 @@ describe("formatClosabilityMissingTitle", () => { ); }); - it("joins multiple gaps with Missing prefix", () => { + it("uses design Completed title when pending uplift is among mixed gaps", () => { expect(formatClosabilityMissingTitle(["Dispatcher", PENDING_UPLIFT_GAP])).toBe( - `Missing: Dispatcher, ${PENDING_UPLIFT_GAP}`, + UPLIFT_PENDING_COMPLETED_TITLE, + ); + }); + + it("joins multiple non-uplift gaps with Missing prefix", () => { + expect(formatClosabilityMissingTitle(["Dispatcher", "Company"])).toBe( + "Missing: Dispatcher, Company", ); }); }); From 358495cfe9a6eb5e31273616df7f2baf5497a352 Mon Sep 17 00:00:00 2001 From: "arthur.bassi" Date: Tue, 18 Aug 2026 17:40:16 -0300 Subject: [PATCH 19/19] fix(work-orders): distinguish unassigned loading from empty pin --- .../_components/list/table/wo-table-body.tsx | 16 ++++++++++++++- .../_hooks/use-work-orders-board-source.ts | 3 +++ .../_hooks/use-work-orders-table-data.ts | 2 ++ .../wo-table-unassigned-queue.test.tsx | 20 +++++++++++++++++++ .../wo-table-unscheduled-highlight.test.tsx | 1 + 5 files changed, 41 insertions(+), 1 deletion(-) 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 bfbde81a..65f49dbd 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 @@ -23,8 +23,13 @@ export function WoTableBody({ tableData, renderRow, renderInlineRow }: WoTableBo const unassignedRows = tableData.unassigned ?? []; const showUnscheduled = (tableData.unscheduled?.length ?? 0) > 0 || tableData.draftDayKey === UNSCHEDULED_DAY_KEY; + const showUnassignedLoading = + tableData.unassignedOpen && Boolean(tableData.unassignedLoading) && !tableData.unassignedError; const showUnassignedEmpty = - tableData.unassignedOpen && unassignedRows.length === 0 && !tableData.unassignedError; + tableData.unassignedOpen && + unassignedRows.length === 0 && + !tableData.unassignedError && + !tableData.unassignedLoading; return ( @@ -47,6 +52,15 @@ export function WoTableBody({ tableData, renderRow, renderInlineRow }: WoTableBo )} + {showUnassignedLoading && ( + + + + )} {showUnassignedEmpty && (
+ + Loading unassigned work orders. + +
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 11771ca7..2227e3f9 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 @@ -23,6 +23,7 @@ export type WorkOrdersBoardSource = { data: WorkOrderBoardListResult | undefined; unassignedQueue: WorkOrderTableRow[]; unassignedError: Error | null; + unassignedLoading: boolean; isLoading: boolean; isFetching: boolean; error: Error | null; @@ -75,6 +76,7 @@ export function useWorkOrdersBoardSource(filters: FiltersReturn): WorkOrdersBoar } = useWorkOrdersBoardSearch(resolvedSearchParams ?? { datePreset: "ThisWeek" }, advActive); const { data: queueData, + isPending: queuePending, isFetching: queueFetching, error: queueError, refetch: refetchQueue, @@ -94,6 +96,7 @@ export function useWorkOrdersBoardSource(filters: FiltersReturn): WorkOrdersBoar data: advActive ? searchData : boardData, unassignedQueue: advActive ? EMPTY_UNASSIGNED_QUEUE : (queueData ?? EMPTY_UNASSIGNED_QUEUE), unassignedError: advActive ? null : (queueError ?? null), + unassignedLoading: advActive ? false : queuePending, isLoading: advActive ? searchLoading : boardLoading, isFetching: advActive ? searchFetching : boardFetching || queueFetching, error: advActive ? searchError : boardError, 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 469fd465..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 @@ -26,6 +26,7 @@ export function useWorkOrdersTableData(filters: FiltersReturn) { data, unassignedQueue, unassignedError, + unassignedLoading, isLoading, isFetching, error, @@ -107,6 +108,7 @@ export function useWorkOrdersTableData(filters: FiltersReturn) { advResults, unassigned, unassignedError, + unassignedLoading, unscheduled, displayGroups, searchActive, 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 index d010346d..a3461cd9 100644 --- a/src/test/app/(protected)/workorders/wo-table-unassigned-queue.test.tsx +++ b/src/test/app/(protected)/workorders/wo-table-unassigned-queue.test.tsx @@ -76,6 +76,7 @@ function baseTableData( advResults: [], unassigned: [], unassignedError: null, + unassignedLoading: false, unscheduled: [], displayGroups: [], searchActive: false, @@ -350,6 +351,25 @@ describe("WoTableBody Unassigned queue (SH-165)", () => { expect(screen.queryByTestId(/row-/)).not.toBeInTheDocument(); }); + it("shows a loading state instead of empty while the unassigned queue is pending", () => { + render( + + ( + + + + )} + renderInlineRow={() => null} + /> +
{row.woNumber}
, + ); + + expect(screen.getByText("Loading unassigned work orders.")).toBeInTheDocument(); + expect(screen.queryByText("No unassigned work orders.")).not.toBeInTheDocument(); + }); + it("removes a pinned row immediately after assigning through the board PATCH path", async () => { const user = userEvent.setup(); const pinned = makeRow({ 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 cc1cb93e..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 @@ -78,6 +78,7 @@ function renderUnscheduledHarness() { advActive: false, unassigned: [], unassignedError: null, + unassignedLoading: false, unassignedOpen: true, setUnassignedOpen: vi.fn(), unscheduled: [unscheduledRow],