fix(work-orders): merge dev and gate uplift create on pending and allowance

Hide a second create while a pending request exists, surface remaining auto-approval in the dialog, and refresh the branch onto current origin/dev.
This commit is contained in:
arthur.bassi 2026-08-14 10:36:45 -03:00
parent 65e2741fd0
commit c721b407b6
7 changed files with 133 additions and 18 deletions

View file

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

View file

@ -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<void>;
};
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" } }}
/>
<TextField

View file

@ -7,6 +7,7 @@ import type {
CreateWorkOrderUpliftInput,
WorkOrderUplift,
} from "@/domain/work-orders/types/work-order-uplift";
import { hasOpenWorkOrderUplift } from "@/domain/work-orders/utils/uplift-display-utils";
type WorkOrderUpliftsDialogContentProps = {
readOnly: boolean;
@ -16,6 +17,7 @@ type WorkOrderUpliftsDialogContentProps = {
isLoading: boolean;
error: Error | null;
uplifts: WorkOrderUplift[];
remainingAllowance: number;
actionPending: boolean;
createPending: boolean;
onRetry: () => 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 && (
<WorkOrderUpliftCreateForm pending={createPending} onSubmit={onCreate} />
{!readOnly && !isLoading && hasOpenRequest && (
<Text as="p" variant="description" tone="muted">
Cancel the pending request to file a new one.
</Text>
)}
{!readOnly && !isLoading && !hasOpenRequest && (
<WorkOrderUpliftCreateForm
pending={createPending}
remainingAllowance={remainingAllowance}
onSubmit={onCreate}
/>
)}
</>
);

View file

@ -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 (
<>
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
@ -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()}

View file

@ -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<Pick<WorkOrderUplift, "status">>,
): boolean {
return uplifts.some((uplift) => uplift.status === "pending");
}
export function getRemainingUpliftAllowance(
workOrderType: string,
uplifts: ReadonlyArray<Pick<WorkOrderUplift, "status" | "amount">>,
): number {
const consumed = uplifts
.filter((uplift) => uplift.status === "auto_approved")
.reduce((sum, uplift) => sum + uplift.amount, 0);
return Math.max(0, getUpliftAllowanceCap(workOrderType) - consumed);
}

View file

@ -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(<WorkOrderUpliftsDialog row={baseRow} open onClose={vi.fn()} />, {
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(<WorkOrderUpliftsDialog row={baseRow} open onClose={vi.fn()} />, {
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(
<WorkOrderUpliftsDialog row={{ ...baseRow, status: "Completed" }} open onClose={vi.fn()} />,
{ withAuth: true },

View file

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