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 c5857f1e..494c8fef 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,108 +1,126 @@ 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; }; +function rowClosabilityGaps(wo: WorkOrderTableRow) { + return getClosabilityGaps({ + dispatcherId: wo.dispatcherId, + dispatcherName: wo.dispatcherName, + company: wo.company, + tech: wo.tech, + completedDate: wo.completedDate ?? "", + woNumber: wo.woNumber, + pm: wo.pm, + docStatus: wo.docStatus, + mediaCount: wo.mediaCount, + type: wo.type, + upliftSummary: wo.upliftSummary, + hasPendingUplift: wo.hasPendingUplift, + }); +} + +function PastDueStatusCell({ + onOpenSlideOver, + onPastDueAttempt, +}: { + onOpenSlideOver: () => void; + onPastDueAttempt?: () => void; +}) { + const [anchorEl, setAnchorEl] = useState(null); + + return ( + <> + + + setAnchorEl(null)} + anchorOrigin={{ vertical: "bottom", horizontal: "left" }} + onClick={(e) => e.stopPropagation()} + slotProps={{ + paper: { className: "w-[264px] overflow-hidden rounded-lg border border-border p-0" }, + }} + > +
+ +
+ + This work order is past due. Update Schedule On before the status can be updated. + + +
+
+
+ + ); +} + 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 Schedule On before the status can be updated. - - - -
-
-
- + ); } @@ -112,7 +130,6 @@ export function StatusCell({ type="button" onClick={(e) => { e.stopPropagation(); - setAnchorEl(e.currentTarget); }} className="group/cell flex w-full items-center gap-1 transition-opacity hover:opacity-80" @@ -122,7 +139,6 @@ export function StatusCell({ ) : ( — )} - @@ -134,24 +150,38 @@ export function StatusCell({ onClick={(e) => e.stopPropagation()} slotProps={{ paper: { className: "min-w-[168px] rounded-lg border border-border p-1" } }} > - {ALL_WIZARD_STATUSES.map((status) => ( - - ))} + 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 2ae3817c..41ee9924 100644 --- a/src/domain/work-orders/mappers/work-order-table-row-mapper.ts +++ b/src/domain/work-orders/mappers/work-order-table-row-mapper.ts @@ -8,6 +8,7 @@ import { import type { CompletionDocStatus, WorkOrderTableRow, + WorkOrderUpliftSummary, } from "@/domain/work-orders/types/work-order-table-row"; import { parseWorkOrderSeverity } from "@/domain/work-orders/types/work-order-severity"; import type { WOType } from "@/domain/work-orders/types/work-order-wizard"; @@ -215,6 +216,55 @@ function readFlagColor(record: Record): string | null { return null; } +function readUpliftSummary(item: Record): WorkOrderUpliftSummary | undefined { + const nested = asRecord(item.upliftSummary ?? item.UpliftSummary); + const hasNested = Object.keys(nested).length > 0; + const source = hasNested ? nested : item; + + // Board/search/detail contract (SH-188): pendingUpliftCount is authoritative when present. + const boardCount = readNumber(item, "pendingUpliftCount", "PendingUpliftCount"); + const nestedCount = readNumber(source, "pendingCount", "PendingCount"); + const pendingCount = nestedCount ?? boardCount ?? 0; + const hasUplift = + readBool(source, "hasUplift", "HasUplift") || + readBool(item, "hasUplift", "HasUplift") || + pendingCount > 0; + + // No nested summary and no board count → omit (legacy payloads without the contract field). + if (!hasNested && boardCount == null && !hasUplift) { + return undefined; + } + + const summary: WorkOrderUpliftSummary = { + hasUplift: hasUplift || pendingCount > 0, + pendingCount, + }; + const primaryStatus = + readString(nested, "primaryStatus", "PrimaryStatus") || + readString(item, "primaryStatus", "PrimaryStatus"); + if (primaryStatus) { + summary.primaryStatus = primaryStatus; + } + return summary; +} + +function readHasPendingUplift( + item: Record, + upliftSummary: WorkOrderUpliftSummary | undefined, +): boolean | undefined { + if ("hasPendingUplift" in item || "HasPendingUplift" in item) { + return Boolean(item.hasPendingUplift ?? item.HasPendingUplift); + } + const boardCount = readNumber(item, "pendingUpliftCount", "PendingUpliftCount"); + if (boardCount != null) { + return boardCount > 0; + } + if (upliftSummary != null) { + return upliftSummary.pendingCount > 0; + } + return undefined; +} + export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow { const item = asRecord(raw); const scheduledOn = toDateInputValue( @@ -269,6 +319,8 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow { const locationIdRaw = item.locationId ?? item.LocationId; const vendorIdNum = readNumber(item, "vendorId", "VendorId"); const primaryDispatchId = readNumber(item, "primaryDispatchId", "PrimaryDispatchId"); + const upliftSummary = readUpliftSummary(item); + const hasPendingUplift = readHasPendingUplift(item, upliftSummary); const row: WorkOrderTableRow = { id: readId(item), @@ -279,7 +331,9 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow { dispatcherId, dispatcherName, scheduledOn, - completedDate: toDateInputValue(readString(item, "completedDate", "CompletedDate")), + completedDate: toDateInputValue( + readString(item, "completedDate", "CompletedDate", "completedOn", "CompletedOn"), + ), targetWeek: toDateInputValue(readString(item, "targetWeek", "TargetWeek")), dayGroup, dayLabel, @@ -316,6 +370,8 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow { rowVersion: readRowVersion(item, "rowVersion", "RowVersion"), dispatchRowVersion: readRowVersion(item, "dispatchRowVersion", "DispatchRowVersion"), flagColor: readFlagColor(item), + upliftSummary, + hasPendingUplift, }; return row; diff --git a/src/domain/work-orders/types/work-order-board.ts b/src/domain/work-orders/types/work-order-board.ts index 40c6b3d7..3400d4a7 100644 --- a/src/domain/work-orders/types/work-order-board.ts +++ b/src/domain/work-orders/types/work-order-board.ts @@ -179,6 +179,11 @@ export interface WorkOrderBoardRowApi { primaryDispatchId?: number | null; rowVersion?: string | null; dispatchRowVersion?: string | null; + /** + * Authoritative pending-uplift count across all dispatches for this WO (SH-188 / BE board contract). + * Drives the Completed closability gate. + */ + pendingUpliftCount?: number; } export interface WorkOrderBoardCountsApi { diff --git a/src/domain/work-orders/types/work-order-table-row.ts b/src/domain/work-orders/types/work-order-table-row.ts index 9dd1dff3..153b58d1 100644 --- a/src/domain/work-orders/types/work-order-table-row.ts +++ b/src/domain/work-orders/types/work-order-table-row.ts @@ -5,6 +5,12 @@ export type CompletionDocStatus = "Yes" | "No" | "NN" | ""; export const WORK_ORDERS_TABLE_PAGE_SIZE = 100; +export type WorkOrderUpliftSummary = { + hasUplift: boolean; + pendingCount: number; + primaryStatus?: string; +}; + export interface WorkOrderTableRow { id: string | number; woNumber: string; @@ -15,7 +21,7 @@ export interface WorkOrderTableRow { dispatcherId: string; dispatcherName: string; scheduledOn: string; - /** Work completed date (API CompletedDate); distinct from scheduledOn. */ + /** Work completed date (API CompletedDate); used by closability / COMPLETED DATE column. */ completedDate: string; targetWeek: string; dayGroup: string; @@ -59,6 +65,8 @@ export interface WorkOrderTableRow { dispatchRowVersion: string; /** Board flag color (#RRGGBB). Null/empty = no flag. Distinct from dispatcher avatar `color`. */ flagColor: string | null; + upliftSummary?: WorkOrderUpliftSummary | null; + hasPendingUplift?: boolean; } export interface WorkOrderTableDraft { diff --git a/src/domain/work-orders/utils/wo-closability.ts b/src/domain/work-orders/utils/wo-closability.ts new file mode 100644 index 00000000..be377091 --- /dev/null +++ b/src/domain/work-orders/utils/wo-closability.ts @@ -0,0 +1,148 @@ +import type { + CompletionDocStatus, + WorkOrderUpliftSummary, +} from "@/domain/work-orders/types/work-order-table-row"; +import type { WOType, WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard"; + +/** Fields required to evaluate whether a WO may be marked Completed (SH-183 ∪ pending uplift). */ +export type WoClosabilityInput = { + dispatcherId: string; + dispatcherName: string; + /** Company / vendor assignment — required for Completed (SH-183). */ + company: string; + /** Technician is optional for closability; modeled separately from company. */ + tech: string; + /** Work completed date (API CompletedDate). Empty until set. */ + completedDate: string; + woNumber: string; + pm: string; + docStatus: CompletionDocStatus; + mediaCount: number; + type: WOType | ""; + upliftSummary?: WorkOrderUpliftSummary | null; + hasPendingUplift?: boolean; +}; + +export const PENDING_UPLIFT_GAP = "Pending uplift approval"; + +/** SH-188 tooltip when Completed is blocked solely by a pending uplift. */ +export const UPLIFT_PENDING_COMPLETED_TITLE = + "This work order has an uplift pending approval and cannot be completed."; + +/** Real (non-placeholder) WO number — non-empty and not an SH-prefixed draft id. */ +export function isRealWoNumber(woNumber: string): boolean { + const trimmed = woNumber.trim(); + if (!trimmed) { + return false; + } + return !/^SH/i.test(trimmed); +} + +export function hasPendingUplift( + row: Pick, +): boolean { + if (row.hasPendingUplift === true) { + return true; + } + return (row.upliftSummary?.pendingCount ?? 0) > 0; +} + +function hasDispatcher(row: Pick): boolean { + return Boolean(row.dispatcherId.trim() || row.dispatcherName.trim()); +} + +function hasCompany(company: string): boolean { + return Boolean(company.trim()); +} + +function hasService(pm: string): boolean { + return Boolean(pm.trim()); +} + +function hasCompletionDocument(docStatus: CompletionDocStatus): boolean { + return docStatus === "Yes" || docStatus === "NN"; +} + +/** + * Gaps that block Completed, in design hierarchy: + * fields → uplift pending → completion document / photos. + * (Scheduled-date gate is a separate design P1 — not in this module yet.) + */ +export function getClosabilityGaps(row: WoClosabilityInput): string[] { + const gaps: string[] = []; + + if (!hasDispatcher(row)) { + gaps.push("Dispatcher"); + } + if (!hasCompany(row.company)) { + gaps.push("Company"); + } + if (!row.completedDate.trim()) { + gaps.push("Completed Date"); + } + if (!isRealWoNumber(row.woNumber)) { + gaps.push("WO number"); + } + if (!hasService(row.pm)) { + gaps.push("Service"); + } + + // Authoritative pending uplift (board pendingUpliftCount / hasPendingUplift) blocks completion. + // Missing uplift fields mean no pending evidence until the board contract supplies the count. + if (hasPendingUplift(row)) { + gaps.push(PENDING_UPLIFT_GAP); + } + + if (row.type === "PM" || row.type === "Overdue") { + if (!hasCompletionDocument(row.docStatus)) { + gaps.push("Completion document"); + } + } + + if (row.type === "Emergency" || row.type === "Reactive") { + if (row.mediaCount < 1) { + gaps.push("At least 1 photo"); + } + } + + return gaps; +} + +export function formatClosabilityMissingTitle(gaps: string[]): string { + if (gaps.includes(PENDING_UPLIFT_GAP)) { + return UPLIFT_PENDING_COMPLETED_TITLE; + } + return `Missing: ${gaps.join(", ")}`; +} + +export type CompDocGateResult = { allowed: true } | { allowed: false; reason: string }; + +/** + * Gate for completion-document generation. CompDocDialog (not yet on `dev`) should call this + * before opening generate flow and surface `reason` when blocked. + * + * Intentionally omits the "Completion document" closability gap — generate is how that gap is filled. + */ +export function canGenerateCompDoc(row: WoClosabilityInput): CompDocGateResult { + const gaps = getClosabilityGaps(row).filter((gap) => gap !== "Completion document"); + if (gaps.length > 0) { + return { allowed: false, reason: formatClosabilityMissingTitle(gaps) }; + } + return { allowed: true }; +} + +/** Map create-wizard draft onto the shared closability contract. */ +export function wizardDraftToClosabilityInput(draft: WorkOrderWizardDraft): WoClosabilityInput { + return { + dispatcherId: draft.assigneeId, + dispatcherName: draft.assigneeName, + company: draft.vendorName, + tech: "", + completedDate: "", + woNumber: draft.woNumber, + pm: draft.pm, + docStatus: "", + mediaCount: 0, + type: draft.type, + }; +} diff --git a/src/test/app/(protected)/workorders/status-cell-pending-uplift.test.tsx b/src/test/app/(protected)/workorders/status-cell-pending-uplift.test.tsx new file mode 100644 index 00000000..d886232a --- /dev/null +++ b/src/test/app/(protected)/workorders/status-cell-pending-uplift.test.tsx @@ -0,0 +1,160 @@ +import { fireEvent, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { StatusCell } from "@/app/(protected)/workorders/_components/list/table/cells/status-cell"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { renderWithProviders } from "@/test/test-utils"; + +function makeRow(overrides: Partial = {}): WorkOrderTableRow { + return { + id: 1, + woNumber: "1001", + site: "BK5", + type: "PM", + severity: null, + dispatcherId: "u1", + dispatcherName: "Alex", + scheduledOn: "2026-07-15", + completedDate: "2026-07-20", + targetWeek: "2026-07-15", + dayGroup: "07/15", + dayLabel: "Tue", + location: "Lobby", + locationId: "loc-1", + pm: "HVAC", + serviceNotes: "", + extraServices: [], + vendorId: "7", + company: "Acme", + tech: "Sam", + techPhone: "", + apptStart: "", + apptEnd: "", + apptTime: "", + status: "In Progress", + docStatus: "Yes", + pocName: "", + pocPhone: "", + pocNotes: "", + dueDate: "", + rescheduleCount: 0, + carriedOver: 0, + originalWeek: "", + originalDate: "", + isPastDue: false, + scheduleWeekOnly: false, + isAddOn: false, + mediaCount: 1, + title: "", + priority: "", + primaryDispatchId: null, + rowVersion: "", + dispatchRowVersion: "", + flagColor: null, + ...overrides, + }; +} + +describe("StatusCell pending uplift closability", () => { + it("disables Completed with tooltip when uplift is pending", () => { + const onChangeStatus = vi.fn(); + + renderWithProviders( + , + { withAuth: false }, + ); + + fireEvent.click(screen.getByRole("button", { name: /in progress/i })); + + const completed = screen.getByRole("button", { name: /completed/i }); + expect(completed).toBeDisabled(); + expect(completed).toHaveAttribute( + "title", + "This work order has an uplift pending approval and cannot be completed.", + ); + + fireEvent.click(completed); + expect(onChangeStatus).not.toHaveBeenCalled(); + }); + + it("disables Completed with tooltip when board pendingUpliftCount maps to pending", () => { + const onChangeStatus = vi.fn(); + + renderWithProviders( + , + { withAuth: false }, + ); + + fireEvent.click(screen.getByRole("button", { name: /in progress/i })); + + const completed = screen.getByRole("button", { name: /completed/i }); + expect(completed).toBeDisabled(); + expect(completed).toHaveAttribute( + "title", + "This work order has an uplift pending approval and cannot be completed.", + ); + }); + + it("allows Completed when no pending uplift", () => { + const onChangeStatus = vi.fn(); + + renderWithProviders( + , + { withAuth: false }, + ); + + fireEvent.click(screen.getByRole("button", { name: /in progress/i })); + fireEvent.click(screen.getByRole("button", { name: /completed/i })); + + expect(onChangeStatus).toHaveBeenCalledWith("Completed"); + }); + + it("disables Completed when company is missing (SH-183)", () => { + const onChangeStatus = vi.fn(); + + renderWithProviders( + , + { withAuth: false }, + ); + + fireEvent.click(screen.getByRole("button", { name: /in progress/i })); + + const completed = screen.getByRole("button", { name: /completed/i }); + expect(completed).toBeDisabled(); + expect(completed).toHaveAttribute("title", "Missing: Company"); + fireEvent.click(completed); + expect(onChangeStatus).not.toHaveBeenCalled(); + }); + + it("allows Completed when technician is empty if company is set (SH-183)", () => { + const onChangeStatus = vi.fn(); + + renderWithProviders( + , + { withAuth: false }, + ); + + fireEvent.click(screen.getByRole("button", { name: /in progress/i })); + fireEvent.click(screen.getByRole("button", { name: /completed/i })); + + expect(onChangeStatus).toHaveBeenCalledWith("Completed"); + }); +}); diff --git a/src/test/domain/work-orders/mappers/work-order-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-mapper.test.ts index 5251e7fe..107d63f0 100644 --- a/src/test/domain/work-orders/mappers/work-order-mapper.test.ts +++ b/src/test/domain/work-orders/mappers/work-order-mapper.test.ts @@ -174,4 +174,64 @@ describe("mapWorkOrderTableRow (board DTO)", () => { expect(mapWorkOrderTableRow({ id: 1, flagColor: "" }).flagColor).toBeNull(); expect(mapWorkOrderTableRow({ id: 1, FlagColor: "#3B82F6" }).flagColor).toBe("#3B82F6"); }); + + it("maps uplift summary and pending flag from API payload", () => { + const row = mapWorkOrderTableRow({ + id: 303, + upliftSummary: { + hasUplift: true, + pendingCount: 2, + primaryStatus: "pending", + }, + }); + + expect(row.upliftSummary).toEqual({ + hasUplift: true, + pendingCount: 2, + primaryStatus: "pending", + }); + expect(row.hasPendingUplift).toBe(true); + }); + + it("maps board pendingUpliftCount into hasPendingUplift (SH-188 contract)", () => { + const pending = mapWorkOrderTableRow({ + id: 305, + pendingUpliftCount: 1, + }); + expect(pending.hasPendingUplift).toBe(true); + expect(pending.upliftSummary).toEqual({ hasUplift: true, pendingCount: 1 }); + + const clear = mapWorkOrderTableRow({ + id: 306, + PendingUpliftCount: 0, + }); + expect(clear.hasPendingUplift).toBe(false); + expect(clear.upliftSummary).toEqual({ hasUplift: false, pendingCount: 0 }); + }); + + it("does not copy work-order status into uplift primaryStatus on flat board rows", () => { + const row = mapWorkOrderTableRow({ + id: 308, + pendingUpliftCount: 1, + status: "In Progress", + Status: "In Progress", + }); + expect(row.upliftSummary?.primaryStatus).toBeUndefined(); + }); + + it("leaves hasPendingUplift undefined when board count is absent", () => { + const row = mapWorkOrderTableRow({ id: 307 }); + expect(row.hasPendingUplift).toBeUndefined(); + expect(row.upliftSummary).toBeUndefined(); + }); + + it("maps hasPendingUplift from top-level API flag", () => { + const row = mapWorkOrderTableRow({ + id: 304, + hasPendingUplift: true, + }); + + expect(row.hasPendingUplift).toBe(true); + expect(row.upliftSummary).toBeUndefined(); + }); }); diff --git a/src/test/domain/work-orders/utils/wo-closability.test.ts b/src/test/domain/work-orders/utils/wo-closability.test.ts new file mode 100644 index 00000000..8af2724e --- /dev/null +++ b/src/test/domain/work-orders/utils/wo-closability.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from "vitest"; +import type { WoClosabilityInput } from "@/domain/work-orders/utils/wo-closability"; +import { + PENDING_UPLIFT_GAP, + UPLIFT_PENDING_COMPLETED_TITLE, + canGenerateCompDoc, + formatClosabilityMissingTitle, + getClosabilityGaps, + hasPendingUplift, + isRealWoNumber, +} from "@/domain/work-orders/utils/wo-closability"; + +function makeInput(overrides: Partial = {}): WoClosabilityInput { + return { + dispatcherId: "u1", + dispatcherName: "Alex", + company: "Acme HVAC", + tech: "Sam", + completedDate: "2026-07-01", + woNumber: "1001", + pm: "HVAC", + docStatus: "Yes", + mediaCount: 1, + type: "PM", + hasPendingUplift: false, + ...overrides, + }; +} + +describe("isRealWoNumber", () => { + it("rejects empty and SH-prefixed provisional numbers", () => { + expect(isRealWoNumber("")).toBe(false); + expect(isRealWoNumber("SH-9")).toBe(false); + expect(isRealWoNumber("sh123")).toBe(false); + expect(isRealWoNumber("98765")).toBe(true); + }); +}); + +describe("hasPendingUplift", () => { + it("is true when hasPendingUplift flag is set", () => { + expect(hasPendingUplift(makeInput({ hasPendingUplift: true }))).toBe(true); + }); + + it("is true when upliftSummary.pendingCount is positive", () => { + expect( + hasPendingUplift(makeInput({ upliftSummary: { hasUplift: true, pendingCount: 2 } })), + ).toBe(true); + }); + + it("is false when uplift is explicitly not pending", () => { + expect(hasPendingUplift(makeInput())).toBe(false); + expect( + hasPendingUplift(makeInput({ upliftSummary: { hasUplift: false, pendingCount: 0 } })), + ).toBe(false); + }); +}); + +describe("getClosabilityGaps", () => { + it("returns empty when all gates are satisfied", () => { + expect(getClosabilityGaps(makeInput())).toEqual([]); + }); + + it("allows company without technician (SH-183)", () => { + const gaps = getClosabilityGaps(makeInput({ tech: "" })); + expect(gaps).toEqual([]); + expect(gaps).not.toContain("Technician"); + }); + + it("blocks Completed when company is missing even if technician is assigned", () => { + expect(getClosabilityGaps(makeInput({ company: "", tech: "Sam" }))).toEqual(["Company"]); + expect(getClosabilityGaps(makeInput({ company: " ", tech: "Sam" }))).toEqual(["Company"]); + }); + + it("lists field gaps before uplift and document gaps", () => { + expect( + getClosabilityGaps( + makeInput({ + dispatcherId: "", + dispatcherName: "", + company: " ", + tech: " ", + completedDate: "", + woNumber: "SH-1", + pm: "", + hasPendingUplift: true, + docStatus: "No", + type: "PM", + }), + ), + ).toEqual([ + "Dispatcher", + "Company", + "Completed Date", + "WO number", + "Service", + PENDING_UPLIFT_GAP, + "Completion document", + ]); + }); + + it("blocks Completed when uplift is pending via summary", () => { + expect( + getClosabilityGaps(makeInput({ upliftSummary: { hasUplift: true, pendingCount: 1 } })), + ).toEqual([PENDING_UPLIFT_GAP]); + }); + + it("blocks Completed when hasPendingUplift is true", () => { + expect(getClosabilityGaps(makeInput({ hasPendingUplift: true }))).toEqual([PENDING_UPLIFT_GAP]); + }); + + it("allows Completed when uplift status fields are absent (no pending evidence)", () => { + expect( + getClosabilityGaps(makeInput({ hasPendingUplift: undefined, upliftSummary: undefined })), + ).toEqual([]); + }); + + it("requires a photo for Emergency / Reactive", () => { + expect( + getClosabilityGaps(makeInput({ type: "Emergency", mediaCount: 0, docStatus: "No" })), + ).toEqual(["At least 1 photo"]); + }); +}); + +describe("formatClosabilityMissingTitle", () => { + it("uses design Completed title when only uplift is missing", () => { + expect(formatClosabilityMissingTitle([PENDING_UPLIFT_GAP])).toBe( + UPLIFT_PENDING_COMPLETED_TITLE, + ); + }); + + it("uses design Completed title when pending uplift is among mixed gaps", () => { + expect(formatClosabilityMissingTitle(["Dispatcher", PENDING_UPLIFT_GAP])).toBe( + UPLIFT_PENDING_COMPLETED_TITLE, + ); + }); + + it("joins multiple non-uplift gaps with Missing prefix", () => { + expect(formatClosabilityMissingTitle(["Dispatcher", "Company"])).toBe( + "Missing: Dispatcher, Company", + ); + }); +}); + +describe("canGenerateCompDoc", () => { + it("allows generation when no closability gaps", () => { + expect(canGenerateCompDoc(makeInput())).toEqual({ allowed: true }); + }); + + it("allows generation when only the completion document is missing", () => { + expect(canGenerateCompDoc(makeInput({ docStatus: "No" }))).toEqual({ allowed: true }); + }); + + it("blocks generation with design reason when only pending uplift exists", () => { + expect(canGenerateCompDoc(makeInput({ hasPendingUplift: true }))).toEqual({ + allowed: false, + reason: UPLIFT_PENDING_COMPLETED_TITLE, + }); + }); +}); diff --git a/tmp/pr-descriptions/pr4-uplift-pending-close-gate.md b/tmp/pr-descriptions/pr4-uplift-pending-close-gate.md new file mode 100644 index 00000000..13a8b08c --- /dev/null +++ b/tmp/pr-descriptions/pr4-uplift-pending-close-gate.md @@ -0,0 +1,39 @@ +# PR4 — Pending uplift close gate (+ SH-114 closability union) + +## Summary + +Blocks marking a work order **Completed** (and future completion-document generation) when closability gaps remain — **SH-114 field gates ∪ pending uplift** (SH-188). + +- Unifies `wo-closability.ts` / `getClosabilityGaps` with hierarchy: **fields → uplift pending → completion document / photos**. +- Field gates: dispatcher, technician, completed date, real WO# (non-SH), service; then PM/Overdue completion doc; Emergency/Reactive ≥1 photo. +- Pending uplift via `hasPendingUplift` or `upliftSummary.pendingCount`. +- Completed tooltip uses design copy when the **only** gap is pending uplift. +- Maps optional `completedDate` on board rows for the Completed Date gate. + +## Jira + +- [SH-188](https://luby-us.atlassian.net/browse/SH-188) — pending uplift close gate +- Related: [SH-114](https://luby-us.atlassian.net/browse/SH-114) — closability field matrix (pulled into this branch so sister merges do not wipe gates) + +## Comp Doc Generate + +`CompDocDialog` is **not** mounted on `dev` yet. This PR exports `canGenerateCompDoc(row)` so the completion-doc slice can block generate/open with the same reason string. No UI wiring in this PR. + +## Out of scope + +- Full uplift CRUD (PR3) +- Advanced filter facets for uplift +- Scheduled-requires-date gate (design P1) +- COL_HELP tooltips (P1) + +## Test plan + +- [ ] Row with all fields ok + `hasPendingUplift: true` — Completed disabled; design tooltip about pending uplift. +- [ ] Row missing dispatcher / completed date — Completed disabled; `Missing: …` tooltip. +- [ ] Row fully closable — Completed selectable. +- [ ] `canGenerateCompDoc` blocked when gaps exist. +- [ ] Board API `completedDate` / `upliftSummary.pendingCount` map through to the gate. + +## Base + +Branch `feature/wo-uplift-pending-close-gate` from local `dev`. Merge **before** or instead of stacking raw SH-114 alone with an uplift-only closability overwrite.