diff --git a/src/app/(protected)/uplifts/_components/uplift-approvals-table.tsx b/src/app/(protected)/uplifts/_components/uplift-approvals-table.tsx index 3c0f3e20..fb40d7c1 100644 --- a/src/app/(protected)/uplifts/_components/uplift-approvals-table.tsx +++ b/src/app/(protected)/uplifts/_components/uplift-approvals-table.tsx @@ -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 ( e.stopPropagation()}> @@ -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 diff --git a/src/app/(protected)/uplifts/_components/uplift-detail-modal.tsx b/src/app/(protected)/uplifts/_components/uplift-detail-modal.tsx index dbef42c4..55422e2e 100644 --- a/src/app/(protected)/uplifts/_components/uplift-detail-modal.tsx +++ b/src/app/(protected)/uplifts/_components/uplift-detail-modal.tsx @@ -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 ( <> @@ -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 diff --git a/src/domain/uplifts/api/uplifts-api.ts b/src/domain/uplifts/api/uplifts-api.ts index 4d5ea3fb..21937764 100644 --- a/src/domain/uplifts/api/uplifts-api.ts +++ b/src/domain/uplifts/api/uplifts-api.ts @@ -86,6 +86,14 @@ export const upliftsApi = { await apiPost(`${API_PATHS.rest.uplifts}/${id}/reject`, { note }); }, + canApprove: async (tier: number): Promise => { + const data = await apiGet(`${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 => { await apiPost(`${API_PATHS.rest.uplifts}/${id}/request-changes`, { note }); }, diff --git a/src/domain/uplifts/use-cases/use-uplifts-can-approve.ts b/src/domain/uplifts/use-cases/use-uplifts-can-approve.ts new file mode 100644 index 00000000..1b9dd971 --- /dev/null +++ b/src/domain/uplifts/use-cases/use-uplifts-can-approve.ts @@ -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 { + return useQuery({ + queryKey: queryKeys.uplifts.canApprove(tier), + queryFn: () => upliftsApi.canApprove(tier), + staleTime: 5 * 60 * 1000, + retry: 1, + }); +} diff --git a/src/infra/query-key/query-key.ts b/src/infra/query-key/query-key.ts index c4a9e284..d257be12 100644 --- a/src/infra/query-key/query-key.ts +++ b/src/infra/query-key/query-key.ts @@ -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, diff --git a/src/test/app/(protected)/uplifts/uplift-approvals-approved-tab.test.tsx b/src/test/app/(protected)/uplifts/uplift-approvals-approved-tab.test.tsx index f7cde679..7b3456a8 100644 --- a/src/test/app/(protected)/uplifts/uplift-approvals-approved-tab.test.tsx +++ b/src/test/app/(protected)/uplifts/uplift-approvals-approved-tab.test.tsx @@ -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(); diff --git a/src/test/app/(protected)/uplifts/uplift-detail-modal.test.tsx b/src/test/app/(protected)/uplifts/uplift-detail-modal.test.tsx index 88cbe9f7..ea42e5ae 100644 --- a/src/test/app/(protected)/uplifts/uplift-detail-modal.test.tsx +++ b/src/test/app/(protected)/uplifts/uplift-detail-modal.test.tsx @@ -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 = {}, 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 }); diff --git a/src/test/app/(protected)/uplifts/uplift-queue-decision-flow.test.tsx b/src/test/app/(protected)/uplifts/uplift-queue-decision-flow.test.tsx index 089d5d11..23a4d3ec 100644 --- a/src/test/app/(protected)/uplifts/uplift-queue-decision-flow.test.tsx +++ b/src/test/app/(protected)/uplifts/uplift-queue-decision-flow.test.tsx @@ -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(); + + 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(); + + expect(screen.getByRole("button", { name: "Approve" })).toBeEnabled(); + expect(screen.getByRole("button", { name: "Reject" })).toBeEnabled(); + }); }); diff --git a/src/test/domain/uplifts/api/uplifts-api.test.ts b/src/test/domain/uplifts/api/uplifts-api.test.ts index 4c4d2494..f43c33ad 100644 --- a/src/test/domain/uplifts/api/uplifts-api.test.ts +++ b/src/test/domain/uplifts/api/uplifts-api.test.ts @@ -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); + }); });