mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-03 04:33:23 +00:00
fix(work-orders): unify closability with sh-114 and pending uplift
This commit is contained in:
parent
f5414e4b14
commit
6b415f732c
7 changed files with 217 additions and 35 deletions
|
|
@ -40,7 +40,19 @@ export function StatusCell({
|
|||
onPastDueAttempt,
|
||||
}: StatusCellProps) {
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(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 (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<WorkOrderTableRow, "upliftSummary" | "hasPendingUplift">;
|
||||
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<WoClosabilityInput, "upliftSummary" | "hasPendingUplift">,
|
||||
): boolean {
|
||||
if (row.hasPendingUplift === true) {
|
||||
return true;
|
||||
}
|
||||
return (row.upliftSummary?.pendingCount ?? 0) > 0;
|
||||
}
|
||||
|
||||
function hasDispatcher(row: Pick<WoClosabilityInput, "dispatcherId" | "dispatcherName">): 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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ function makeRow(overrides: Partial<WorkOrderTableRow> = {}): 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> = {}): WorkOrderTableRow
|
|||
apptEnd: "",
|
||||
apptTime: "",
|
||||
status: "In Progress",
|
||||
docStatus: "No",
|
||||
docStatus: "Yes",
|
||||
pocName: "",
|
||||
pocPhone: "",
|
||||
pocNotes: "",
|
||||
|
|
@ -40,7 +41,7 @@ function makeRow(overrides: Partial<WorkOrderTableRow> = {}): 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();
|
||||
|
|
|
|||
|
|
@ -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> = {}): 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue