fix(uplifts): gate Approve and Reject on the backend tier permission (SH-207)

Wire the backend's GET uplifts/can-approve tier decision into the
pending row and detail modal actions. Approve/Reject now stay disabled
with the plain 'Requires Tier N role' tooltip unless the backend says
the current user may decide at that tier, so roles the backend refuses
(Dispatcher) never get a clickable action or a refusal toast.
This commit is contained in:
Codex Review Integration 2026-09-17 02:33:29 -03:00
parent dd1b029124
commit 8bc8ff30b6
9 changed files with 112 additions and 6 deletions

View file

@ -14,6 +14,7 @@ import {
} from "@mui/material";
import { formatUpliftMoney } from "@/app/(protected)/uplifts/_components/format-uplift-money";
import { Text } from "@/components/ui/text";
import { useUpliftsCanApprove } from "@/domain/uplifts/use-cases/use-uplifts-can-approve";
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
import { formatDateTime, getWaitTimeTextClass, timeSince, waitTimeColor } from "@/lib/time-utils";
@ -85,7 +86,9 @@ function PendingRowActions({
onReject: (row: UpliftQueueItem) => void;
isDecisionPending: boolean;
}) {
const tooltip = row.canDecide ? "" : `Requires Tier ${row.requiredTier} role`;
const canApproveTier = useUpliftsCanApprove(row.requiredTier);
const canDecide = row.canDecide && canApproveTier.data === true;
const tooltip = canDecide ? "" : `Requires Tier ${row.requiredTier} role`;
return (
<Box sx={{ display: "flex", gap: 1 }} onClick={(e) => e.stopPropagation()}>
<Tooltip title={tooltip}>
@ -94,7 +97,7 @@ function PendingRowActions({
size="small"
variant="contained"
color="success"
disabled={!row.canDecide || isDecisionPending}
disabled={!canDecide || isDecisionPending}
onClick={() => onApprove(row)}
>
Approve
@ -107,7 +110,7 @@ function PendingRowActions({
size="small"
variant="contained"
color="error"
disabled={!row.canDecide || isDecisionPending}
disabled={!canDecide || isDecisionPending}
onClick={() => onReject(row)}
>
Reject

View file

@ -13,6 +13,7 @@ import { formatUpliftMoney } from "@/app/(protected)/uplifts/_components/format-
import { Text } from "@/components/ui/text";
import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
import { useWorkOrderUplifts } from "@/domain/work-orders/use-cases/use-work-order-uplifts";
import { useUpliftsCanApprove } from "@/domain/uplifts/use-cases/use-uplifts-can-approve";
import { isWorkOrderUpliftsReadOnly } from "@/domain/work-orders/utils/uplift-display-utils";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
@ -217,7 +218,9 @@ function PendingModalActions({
onApprove: (row: UpliftQueueItem) => void;
onReject: (row: UpliftQueueItem) => void;
}) {
const tooltip = item.canDecide ? "" : `Requires Tier ${item.requiredTier} role`;
const canApproveTier = useUpliftsCanApprove(item.requiredTier);
const canDecide = item.canDecide && canApproveTier.data === true;
const tooltip = canDecide ? "" : `Requires Tier ${item.requiredTier} role`;
return (
<>
<Tooltip title={tooltip}>
@ -226,7 +229,7 @@ function PendingModalActions({
size="small"
variant="contained"
color="success"
disabled={!item.canDecide || approvePending}
disabled={!canDecide || approvePending}
onClick={() => onApprove(item)}
>
Approve
@ -239,7 +242,7 @@ function PendingModalActions({
size="small"
variant="contained"
color="error"
disabled={!item.canDecide}
disabled={!canDecide}
onClick={() => onReject(item)}
>
Reject

View file

@ -86,6 +86,14 @@ export const upliftsApi = {
await apiPost<unknown>(`${API_PATHS.rest.uplifts}/${id}/reject`, { note });
},
canApprove: async (tier: number): Promise<boolean> => {
const data = await apiGet<unknown>(`${API_PATHS.rest.uplifts}/can-approve`, {
searchParams: { tier },
});
const response = handleApiResponse<{ canApprove?: unknown }>(data);
return response?.canApprove === true;
},
requestChanges: async (id: string | number, note: string): Promise<void> => {
await apiPost<unknown>(`${API_PATHS.rest.uplifts}/${id}/request-changes`, { note });
},

View file

@ -0,0 +1,17 @@
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
import { upliftsApi } from "@/domain/uplifts/api/uplifts-api";
import { queryKeys } from "@/infra/query-key/query-key";
/**
* Backend-authoritative answer to "may the current user decide uplifts at this
* tier?" (GET uplifts/can-approve). Combines with the per-row canDecide signal
* so decision controls stay disabled for roles the backend would refuse.
*/
export function useUpliftsCanApprove(tier: number): UseQueryResult<boolean, Error> {
return useQuery({
queryKey: queryKeys.uplifts.canApprove(tier),
queryFn: () => upliftsApi.canApprove(tier),
staleTime: 5 * 60 * 1000,
retry: 1,
});
}

View file

@ -94,6 +94,7 @@ export const queryKeys = {
forDispatch: (dispatchId: string | number) =>
[...queryKeys.uplifts.all, "dispatch", dispatchId] as const,
evidence: (id: string | number) => [...queryKeys.uplifts.all, "evidence", id] as const,
canApprove: (tier: number) => [...queryKeys.uplifts.all, "can-approve", tier] as const,
},
vendorPos: {
all: ["vendorPos"] as const,

View file

@ -76,6 +76,10 @@ vi.mock("@/providers/auth-context", async (importOriginal) => ({
useAuthContext: () => ({ user: { userRoles: authState.userRoles } }),
}));
vi.mock("@/domain/uplifts/use-cases/use-uplifts-can-approve", () => ({
useUpliftsCanApprove: () => ({ data: true }),
}));
describe("Uplift Approvals approved tab", () => {
beforeEach(() => {
revokeMutate.mockReset();

View file

@ -15,6 +15,12 @@ vi.mock("@/domain/work-orders/use-cases/use-work-order-uplifts", () => ({
useWorkOrderUplifts: () => ({ data: woUplifts.data, isLoading: false, error: null }),
}));
const canApproveState = vi.hoisted(() => ({ data: true as boolean | undefined }));
vi.mock("@/domain/uplifts/use-cases/use-uplifts-can-approve", () => ({
useUpliftsCanApprove: () => ({ data: canApproveState.data }),
}));
const baseItem: UpliftQueueItem = {
id: 41,
status: "Pending",
@ -74,6 +80,7 @@ function renderModal(overrides: Partial<UpliftQueueItem> = {}, canRevoke = true)
describe("UpliftDetailModal", () => {
beforeEach(() => {
canApproveState.data = true;
boardDetail.data = {
info: { woNumber: "WO-99", site: "Site A", status: "Open", dueDate: "" },
};
@ -125,6 +132,22 @@ describe("UpliftDetailModal", () => {
expect(onReject).toHaveBeenCalled();
});
it("disables Approve and Reject in the modal for a Dispatcher the backend refuses", async () => {
canApproveState.data = false;
const { onApprove, onReject } = renderModal();
const approve = screen.getByRole("button", { name: "Approve" });
const reject = screen.getByRole("button", { name: "Reject" });
expect(approve).toBeDisabled();
expect(reject).toBeDisabled();
fireEvent.mouseOver(approve);
expect(await screen.findByText("Requires Tier 1 role")).toBeInTheDocument();
fireEvent.click(approve);
fireEvent.click(reject);
expect(onApprove).not.toHaveBeenCalled();
expect(onReject).not.toHaveBeenCalled();
});
it("offers Revoke for an approved uplift and disables it on a closed work order", () => {
const { onRevoke } = renderModal({ status: "Approved", workOrderClosed: true });

View file

@ -11,6 +11,7 @@ const refetch = vi.fn();
const queueState = vi.hoisted(() => ({
data: null as UpliftsQueueResult | null,
}));
const canApproveState = vi.hoisted(() => ({ data: true as boolean | undefined }));
const pendingItem: UpliftQueueItem = {
id: 41,
@ -69,12 +70,17 @@ vi.mock("@/providers/auth-context", async (importOriginal) => ({
useAuthContext: () => ({ user: { userRoles: "Admin" } }),
}));
vi.mock("@/domain/uplifts/use-cases/use-uplifts-can-approve", () => ({
useUpliftsCanApprove: () => ({ data: canApproveState.data }),
}));
describe("Uplift Approvals decision flow", () => {
beforeEach(() => {
rejectMutate.mockReset();
approveMutate.mockReset();
revokeMutate.mockReset();
refetch.mockReset();
canApproveState.data = true;
queueState.data = {
items: [pendingItem],
totalCount: 1,
@ -127,4 +133,31 @@ describe("Uplift Approvals decision flow", () => {
expect(screen.getByText("No uplifts waiting for approval.")).toBeInTheDocument();
});
it("disables Approve and Reject for a Dispatcher the backend refuses, without dialogs or toasts", async () => {
canApproveState.data = false;
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
renderWithProviders(<UpliftQueuePage />);
const approve = screen.getByRole("button", { name: "Approve" });
const reject = screen.getByRole("button", { name: "Reject" });
expect(approve).toBeDisabled();
expect(reject).toBeDisabled();
fireEvent.mouseOver(approve);
expect(await screen.findByText("Requires Tier 1 role")).toBeInTheDocument();
fireEvent.click(approve);
fireEvent.click(reject);
expect(approveMutate).not.toHaveBeenCalled();
expect(rejectMutate).not.toHaveBeenCalled();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(screen.queryByRole("heading", { name: /reject uplift of/i })).not.toBeInTheDocument();
});
it("keeps Approve and Reject enabled when the backend allows the tier", async () => {
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
renderWithProviders(<UpliftQueuePage />);
expect(screen.getByRole("button", { name: "Approve" })).toBeEnabled();
expect(screen.getByRole("button", { name: "Reject" })).toBeEnabled();
});
});

View file

@ -28,4 +28,18 @@ describe("upliftsApi", () => {
note: "Scope was already covered",
});
});
it("reads the backend tier decision permission", async () => {
apiGet.mockResolvedValueOnce({ status: "Success", data: { canApprove: true } });
await expect(upliftsApi.canApprove(1)).resolves.toBe(true);
expect(apiGet).toHaveBeenCalledWith("uplifts/can-approve", { searchParams: { tier: 1 } });
});
it("treats a missing tier permission as false", async () => {
apiGet.mockResolvedValueOnce({ status: "Success", data: {} });
await expect(upliftsApi.canApprove(2)).resolves.toBe(false);
});
});