diff --git a/docs/work-orders/pending-be-contract.md b/docs/work-orders/pending-be-contract.md index c97256aa..0e5af48d 100644 --- a/docs/work-orders/pending-be-contract.md +++ b/docs/work-orders/pending-be-contract.md @@ -26,6 +26,13 @@ Board row `upliftSummary` (`{ hasUplift, pendingCount, primaryStatus?, amount? } Statuses (design): `pending` \| `approved` \| `auto_approved` \| `rejected` \| `cancelled` \| `revoked`. +SH-196 producer invariants (backend #64): + +- One open (`pending`) request per work order at a time — cancel-and-refile to submit another. +- Auto-approval cap is cumulative per WO: `$500`, or `$5,000` for Emergency. Consumed = sum of current `auto_approved` amounts (revoked amounts are freed). +- Amount ≤ remaining allowance → `auto_approved`; otherwise `pending` (admin queue). +- Cancelling a WO withdraws any pending uplift in the same action. + ## Search query params (`GET workorders/board/search`) | Param | Meaning | diff --git a/src/app/(protected)/workorders/_components/uplifts/work-order-uplift-create-form.tsx b/src/app/(protected)/workorders/_components/uplifts/work-order-uplift-create-form.tsx index f9de4392..8e0902e6 100644 --- a/src/app/(protected)/workorders/_components/uplifts/work-order-uplift-create-form.tsx +++ b/src/app/(protected)/workorders/_components/uplifts/work-order-uplift-create-form.tsx @@ -1,14 +1,28 @@ import { useState } from "react"; import { Button, TextField } from "@mui/material"; import { Text } from "@/components/ui/text"; +import { formatUpliftMoney } from "@/domain/work-orders/utils/uplift-display-utils"; type WorkOrderUpliftCreateFormProps = { pending?: boolean; + remainingAllowance: number; onSubmit: (input: { amount: number; notes: string }) => void | Promise; }; +function amountHelperText(parsedAmount: number, remainingAllowance: number): string { + const remaining = `Remaining auto-approval: ${formatUpliftMoney(remainingAllowance)}.`; + if (!Number.isFinite(parsedAmount) || parsedAmount <= 0) { + return remaining; + } + if (parsedAmount <= remainingAllowance) { + return `${remaining} This request will auto-approve.`; + } + return `${remaining} This request will go to the admin approval queue.`; +} + export function WorkOrderUpliftCreateForm({ pending = false, + remainingAllowance, onSubmit, }: WorkOrderUpliftCreateFormProps) { const [amount, setAmount] = useState(""); @@ -39,6 +53,7 @@ export function WorkOrderUpliftCreateForm({ size="small" value={amount} onChange={(event) => setAmount(event.target.value)} + helperText={amountHelperText(parsedAmount, remainingAllowance)} slotProps={{ htmlInput: { min: 0, step: "0.01" } }} /> void; @@ -32,6 +34,7 @@ export function WorkOrderUpliftsDialogContent({ isLoading, error, uplifts, + remainingAllowance, actionPending, createPending, onRetry, @@ -39,6 +42,8 @@ export function WorkOrderUpliftsDialogContent({ onCancelUplift, onRevokeUplift, }: WorkOrderUpliftsDialogContentProps) { + const hasOpenRequest = hasOpenWorkOrderUplift(uplifts); + return ( <> {readOnly && ( @@ -81,8 +86,18 @@ export function WorkOrderUpliftsDialogContent({ /> ))} - {!readOnly && !isLoading && ( - + {!readOnly && !isLoading && hasOpenRequest && ( + + Cancel the pending request to file a new one. + + )} + + {!readOnly && !isLoading && !hasOpenRequest && ( + )} ); diff --git a/src/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog.tsx b/src/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog.tsx index 488df1f7..57de111a 100644 --- a/src/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog.tsx +++ b/src/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog.tsx @@ -11,6 +11,7 @@ import { useWorkOrderUplifts, } from "@/domain/work-orders/use-cases/use-work-order-uplifts"; import { + getRemainingUpliftAllowance, isWorkOrderUpliftsReadOnly, upliftRevokeRequiresReason, } from "@/domain/work-orders/utils/uplift-display-utils"; @@ -69,6 +70,8 @@ export function WorkOrderUpliftsDialog({ row, open, onClose }: WorkOrderUpliftsD setRevokeTarget({ id: upliftId, status: uplift.status }); }; + const remainingAllowance = getRemainingUpliftAllowance(row?.type ?? "", uplifts); + return ( <> @@ -82,6 +85,7 @@ export function WorkOrderUpliftsDialog({ row, open, onClose }: WorkOrderUpliftsD isLoading={isLoading} error={error} uplifts={uplifts} + remainingAllowance={remainingAllowance} actionPending={actionPending} createPending={createUplift.isPending} onRetry={() => void refetch()} diff --git a/src/domain/work-orders/utils/uplift-display-utils.ts b/src/domain/work-orders/utils/uplift-display-utils.ts index 49c9da15..09227f4f 100644 --- a/src/domain/work-orders/utils/uplift-display-utils.ts +++ b/src/domain/work-orders/utils/uplift-display-utils.ts @@ -104,3 +104,28 @@ export function canRevokeWorkOrderUplift( export function upliftRevokeRequiresReason(status: WorkOrderUpliftStatus): boolean { return status === "approved"; } + +export const UPLIFT_STANDARD_ALLOWANCE_USD = 500; +export const UPLIFT_EMERGENCY_ALLOWANCE_USD = 5000; + +export function getUpliftAllowanceCap(workOrderType: string): number { + return workOrderType === "Emergency" + ? UPLIFT_EMERGENCY_ALLOWANCE_USD + : UPLIFT_STANDARD_ALLOWANCE_USD; +} + +export function hasOpenWorkOrderUplift( + uplifts: ReadonlyArray>, +): boolean { + return uplifts.some((uplift) => uplift.status === "pending"); +} + +export function getRemainingUpliftAllowance( + workOrderType: string, + uplifts: ReadonlyArray>, +): number { + const consumed = uplifts + .filter((uplift) => uplift.status === "auto_approved") + .reduce((sum, uplift) => sum + uplift.amount, 0); + return Math.max(0, getUpliftAllowanceCap(workOrderType) - consumed); +} diff --git a/src/test/app/(protected)/workorders/work-order-uplifts-affordances.test.tsx b/src/test/app/(protected)/workorders/work-order-uplifts-affordances.test.tsx index 79e70ad7..9f7f67a8 100644 --- a/src/test/app/(protected)/workorders/work-order-uplifts-affordances.test.tsx +++ b/src/test/app/(protected)/workorders/work-order-uplifts-affordances.test.tsx @@ -3,8 +3,26 @@ import { describe, expect, it, vi } from "vitest"; import { UpliftCell } from "@/app/(protected)/workorders/_components/list/table/cells/uplift-cell"; import { WorkOrderUpliftsDialog } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import type { WorkOrderUplift } from "@/domain/work-orders/types/work-order-uplift"; import { renderWithProviders } from "@/test/test-utils"; +const pendingUplift: WorkOrderUplift = { + id: 1, + status: "pending", + amount: 450, + notes: "Extra work", + requestedAt: "2026-07-20T10:00:00Z", + requestedById: "vendor-1", + requestedByName: "Vendor", + decidedAt: "", + decidedByName: "", + decisionNote: "", +}; + +const mockState = vi.hoisted(() => ({ + uplifts: [] as WorkOrderUplift[], +})); + const baseRow: WorkOrderTableRow = { id: 42, woNumber: "WO-42", @@ -53,20 +71,7 @@ const baseRow: WorkOrderTableRow = { vi.mock("@/domain/work-orders/use-cases/use-work-order-uplifts", () => ({ useWorkOrderUplifts: () => ({ - data: [ - { - id: 1, - status: "pending", - amount: 450, - notes: "Extra work", - requestedAt: "2026-07-20T10:00:00Z", - requestedById: "vendor-1", - requestedByName: "Vendor", - decidedAt: "", - decidedByName: "", - decisionNote: "", - }, - ], + data: mockState.uplifts, isLoading: false, error: null, refetch: vi.fn(), @@ -100,17 +105,33 @@ describe("UpliftCell affordances", () => { }); describe("WorkOrderUpliftsDialog affordances", () => { - it("shows create form for editable work orders", () => { + it("hides create form when a pending request already exists", () => { + mockState.uplifts = [pendingUplift]; + renderWithProviders(, { withAuth: true, }); expect(screen.getByRole("heading", { name: /uplifts — wo wo-42/i })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: /create uplift/i })).toBeInTheDocument(); expect(screen.getByRole("button", { name: /cancel pending/i })).toBeInTheDocument(); + expect(screen.getByText(/cancel the pending request to file a new one/i)).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /create uplift/i })).not.toBeInTheDocument(); + }); + + it("shows create form with remaining allowance when no pending request exists", () => { + mockState.uplifts = []; + + renderWithProviders(, { + withAuth: true, + }); + + expect(screen.getByRole("button", { name: /create uplift/i })).toBeInTheDocument(); + expect(screen.getByText(/remaining auto-approval: \$500/i)).toBeInTheDocument(); }); it("hides create and cancel actions when work order is completed", () => { + mockState.uplifts = [pendingUplift]; + renderWithProviders( , { withAuth: true }, diff --git a/src/test/domain/work-orders/utils/uplift-display-utils.test.ts b/src/test/domain/work-orders/utils/uplift-display-utils.test.ts index c441ec4f..a05580f6 100644 --- a/src/test/domain/work-orders/utils/uplift-display-utils.test.ts +++ b/src/test/domain/work-orders/utils/uplift-display-utils.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest"; import { canRevokeWorkOrderUplift, + getRemainingUpliftAllowance, + hasOpenWorkOrderUplift, upliftRevokeRequiresReason, } from "@/domain/work-orders/utils/uplift-display-utils"; @@ -67,3 +69,29 @@ describe("upliftRevokeRequiresReason", () => { expect(upliftRevokeRequiresReason("auto_approved")).toBe(false); }); }); + +describe("SH-196 allowance helpers", () => { + it("uses a $500 cap for non-emergency work orders", () => { + expect(getRemainingUpliftAllowance("PM", [])).toBe(500); + expect(getRemainingUpliftAllowance("PM", [{ status: "auto_approved", amount: 400 }])).toBe(100); + }); + + it("uses a $5,000 cap for emergency work orders", () => { + expect(getRemainingUpliftAllowance("Emergency", [])).toBe(5000); + }); + + it("ignores revoked and pending amounts when computing remaining allowance", () => { + expect( + getRemainingUpliftAllowance("PM", [ + { status: "auto_approved", amount: 200 }, + { status: "revoked", amount: 300 }, + { status: "pending", amount: 400 }, + ]), + ).toBe(300); + }); + + it("detects an open pending request", () => { + expect(hasOpenWorkOrderUplift([{ status: "pending" }])).toBe(true); + expect(hasOpenWorkOrderUplift([{ status: "auto_approved" }])).toBe(false); + }); +});