mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-03 20:13:15 +00:00
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:
parent
65e2741fd0
commit
c721b407b6
7 changed files with 133 additions and 18 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue