mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 03:32:06 +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";
|
} from "@mui/material";
|
||||||
import { formatUpliftMoney } from "@/app/(protected)/uplifts/_components/format-uplift-money";
|
import { formatUpliftMoney } from "@/app/(protected)/uplifts/_components/format-uplift-money";
|
||||||
import { Text } from "@/components/ui/text";
|
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 type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
|
||||||
import { formatDateTime, getWaitTimeTextClass, timeSince, waitTimeColor } from "@/lib/time-utils";
|
import { formatDateTime, getWaitTimeTextClass, timeSince, waitTimeColor } from "@/lib/time-utils";
|
||||||
|
|
||||||
|
|
@ -85,7 +86,9 @@ function PendingRowActions({
|
||||||
onReject: (row: UpliftQueueItem) => void;
|
onReject: (row: UpliftQueueItem) => void;
|
||||||
isDecisionPending: boolean;
|
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 (
|
return (
|
||||||
<Box sx={{ display: "flex", gap: 1 }} onClick={(e) => e.stopPropagation()}>
|
<Box sx={{ display: "flex", gap: 1 }} onClick={(e) => e.stopPropagation()}>
|
||||||
<Tooltip title={tooltip}>
|
<Tooltip title={tooltip}>
|
||||||
|
|
@ -94,7 +97,7 @@ function PendingRowActions({
|
||||||
size="small"
|
size="small"
|
||||||
variant="contained"
|
variant="contained"
|
||||||
color="success"
|
color="success"
|
||||||
disabled={!row.canDecide || isDecisionPending}
|
disabled={!canDecide || isDecisionPending}
|
||||||
onClick={() => onApprove(row)}
|
onClick={() => onApprove(row)}
|
||||||
>
|
>
|
||||||
Approve
|
Approve
|
||||||
|
|
@ -107,7 +110,7 @@ function PendingRowActions({
|
||||||
size="small"
|
size="small"
|
||||||
variant="contained"
|
variant="contained"
|
||||||
color="error"
|
color="error"
|
||||||
disabled={!row.canDecide || isDecisionPending}
|
disabled={!canDecide || isDecisionPending}
|
||||||
onClick={() => onReject(row)}
|
onClick={() => onReject(row)}
|
||||||
>
|
>
|
||||||
Reject
|
Reject
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import { formatUpliftMoney } from "@/app/(protected)/uplifts/_components/format-
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
|
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 { 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 { isWorkOrderUpliftsReadOnly } from "@/domain/work-orders/utils/uplift-display-utils";
|
||||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
|
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
|
||||||
|
|
@ -217,7 +218,9 @@ function PendingModalActions({
|
||||||
onApprove: (row: UpliftQueueItem) => void;
|
onApprove: (row: UpliftQueueItem) => void;
|
||||||
onReject: (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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Tooltip title={tooltip}>
|
<Tooltip title={tooltip}>
|
||||||
|
|
@ -226,7 +229,7 @@ function PendingModalActions({
|
||||||
size="small"
|
size="small"
|
||||||
variant="contained"
|
variant="contained"
|
||||||
color="success"
|
color="success"
|
||||||
disabled={!item.canDecide || approvePending}
|
disabled={!canDecide || approvePending}
|
||||||
onClick={() => onApprove(item)}
|
onClick={() => onApprove(item)}
|
||||||
>
|
>
|
||||||
Approve
|
Approve
|
||||||
|
|
@ -239,7 +242,7 @@ function PendingModalActions({
|
||||||
size="small"
|
size="small"
|
||||||
variant="contained"
|
variant="contained"
|
||||||
color="error"
|
color="error"
|
||||||
disabled={!item.canDecide}
|
disabled={!canDecide}
|
||||||
onClick={() => onReject(item)}
|
onClick={() => onReject(item)}
|
||||||
>
|
>
|
||||||
Reject
|
Reject
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,14 @@ export const upliftsApi = {
|
||||||
await apiPost<unknown>(`${API_PATHS.rest.uplifts}/${id}/reject`, { note });
|
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> => {
|
requestChanges: async (id: string | number, note: string): Promise<void> => {
|
||||||
await apiPost<unknown>(`${API_PATHS.rest.uplifts}/${id}/request-changes`, { note });
|
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) =>
|
forDispatch: (dispatchId: string | number) =>
|
||||||
[...queryKeys.uplifts.all, "dispatch", dispatchId] as const,
|
[...queryKeys.uplifts.all, "dispatch", dispatchId] as const,
|
||||||
evidence: (id: string | number) => [...queryKeys.uplifts.all, "evidence", id] 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: {
|
vendorPos: {
|
||||||
all: ["vendorPos"] as const,
|
all: ["vendorPos"] as const,
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,10 @@ vi.mock("@/providers/auth-context", async (importOriginal) => ({
|
||||||
useAuthContext: () => ({ user: { userRoles: authState.userRoles } }),
|
useAuthContext: () => ({ user: { userRoles: authState.userRoles } }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/domain/uplifts/use-cases/use-uplifts-can-approve", () => ({
|
||||||
|
useUpliftsCanApprove: () => ({ data: true }),
|
||||||
|
}));
|
||||||
|
|
||||||
describe("Uplift Approvals approved tab", () => {
|
describe("Uplift Approvals approved tab", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
revokeMutate.mockReset();
|
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 }),
|
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 = {
|
const baseItem: UpliftQueueItem = {
|
||||||
id: 41,
|
id: 41,
|
||||||
status: "Pending",
|
status: "Pending",
|
||||||
|
|
@ -74,6 +80,7 @@ function renderModal(overrides: Partial<UpliftQueueItem> = {}, canRevoke = true)
|
||||||
|
|
||||||
describe("UpliftDetailModal", () => {
|
describe("UpliftDetailModal", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
canApproveState.data = true;
|
||||||
boardDetail.data = {
|
boardDetail.data = {
|
||||||
info: { woNumber: "WO-99", site: "Site A", status: "Open", dueDate: "" },
|
info: { woNumber: "WO-99", site: "Site A", status: "Open", dueDate: "" },
|
||||||
};
|
};
|
||||||
|
|
@ -125,6 +132,22 @@ describe("UpliftDetailModal", () => {
|
||||||
expect(onReject).toHaveBeenCalled();
|
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", () => {
|
it("offers Revoke for an approved uplift and disables it on a closed work order", () => {
|
||||||
const { onRevoke } = renderModal({ status: "Approved", workOrderClosed: true });
|
const { onRevoke } = renderModal({ status: "Approved", workOrderClosed: true });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ const refetch = vi.fn();
|
||||||
const queueState = vi.hoisted(() => ({
|
const queueState = vi.hoisted(() => ({
|
||||||
data: null as UpliftsQueueResult | null,
|
data: null as UpliftsQueueResult | null,
|
||||||
}));
|
}));
|
||||||
|
const canApproveState = vi.hoisted(() => ({ data: true as boolean | undefined }));
|
||||||
|
|
||||||
const pendingItem: UpliftQueueItem = {
|
const pendingItem: UpliftQueueItem = {
|
||||||
id: 41,
|
id: 41,
|
||||||
|
|
@ -69,12 +70,17 @@ vi.mock("@/providers/auth-context", async (importOriginal) => ({
|
||||||
useAuthContext: () => ({ user: { userRoles: "Admin" } }),
|
useAuthContext: () => ({ user: { userRoles: "Admin" } }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/domain/uplifts/use-cases/use-uplifts-can-approve", () => ({
|
||||||
|
useUpliftsCanApprove: () => ({ data: canApproveState.data }),
|
||||||
|
}));
|
||||||
|
|
||||||
describe("Uplift Approvals decision flow", () => {
|
describe("Uplift Approvals decision flow", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
rejectMutate.mockReset();
|
rejectMutate.mockReset();
|
||||||
approveMutate.mockReset();
|
approveMutate.mockReset();
|
||||||
revokeMutate.mockReset();
|
revokeMutate.mockReset();
|
||||||
refetch.mockReset();
|
refetch.mockReset();
|
||||||
|
canApproveState.data = true;
|
||||||
queueState.data = {
|
queueState.data = {
|
||||||
items: [pendingItem],
|
items: [pendingItem],
|
||||||
totalCount: 1,
|
totalCount: 1,
|
||||||
|
|
@ -127,4 +133,31 @@ describe("Uplift Approvals decision flow", () => {
|
||||||
|
|
||||||
expect(screen.getByText("No uplifts waiting for approval.")).toBeInTheDocument();
|
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",
|
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