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.
This commit is contained in:
Arthur Bassi 2026-08-12 09:55:34 -03:00
parent dd60b1d72e
commit 8a221c8819
5 changed files with 76 additions and 15 deletions

View file

@ -177,23 +177,25 @@ function readFlagColor(record: Record<string, unknown>): string | null {
function readUpliftSummary(item: Record<string, unknown>): 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<string, unknown>,
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;
}

View file

@ -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 {

View file

@ -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);
}

View file

@ -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(
<StatusCell
wo={makeRow({
hasPendingUplift: true,
upliftSummary: { hasUplift: true, pendingCount: 1 },
})}
onChangeStatus={onChangeStatus}
onOpenSlideOver={vi.fn()}
/>,
{ 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();

View file

@ -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,