mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-02 00:23:13 +00:00
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:
parent
dd1b029124
commit
8bc8ff30b6
9 changed files with 112 additions and 6 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
},
|
||||
|
|
|
|||
17
src/domain/uplifts/use-cases/use-uplifts-can-approve.ts
Normal file
17
src/domain/uplifts/use-cases/use-uplifts-can-approve.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue