From 6b415f732c7d6660d385b6a98e120e0e68a19d51 Mon Sep 17 00:00:00 2001 From: "arthur.bassi" Date: Mon, 3 Aug 2026 13:43:35 -0300 Subject: [PATCH] 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.