diff --git a/src/app/(protected)/uplifts/index.tsx b/src/app/(protected)/uplifts/index.tsx index 3c133e7e..8f2f418b 100644 --- a/src/app/(protected)/uplifts/index.tsx +++ b/src/app/(protected)/uplifts/index.tsx @@ -9,11 +9,7 @@ import { import { UpliftDetailModal } from "@/app/(protected)/uplifts/_components/uplift-detail-modal"; import { formatUpliftMoney } from "@/app/(protected)/uplifts/_components/format-uplift-money"; import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift"; -import { - useApproveUplift, - useRejectUplift, - useRevokeUplift, -} from "@/domain/uplifts/use-cases/use-uplift-actions"; +import { useUpliftApprovalController } from "@/domain/uplifts/use-cases/use-uplift-approval-controller"; import { useUpliftsQueue } from "@/domain/uplifts/use-cases/use-uplifts-queue"; import { Text } from "@/components/ui/text"; @@ -31,13 +27,94 @@ function sortRows(rows: UpliftQueueItem[], tab: UpliftApprovalTab): UpliftQueueI return copy; } +function UpliftApprovalsHeader({ pendingExposureTotal }: { pendingExposureTotal: number | null }) { + return ( + + + Uplift Approvals + + + Requests to increase the approved spend on a work order, above its remaining auto-approval + allowance. + + + Pending exposure: {formatUpliftMoney(pendingExposureTotal ?? 0)} + + + ); +} + +function UpliftApprovalsTabs({ + tab, + pendingCount, + onChange, +}: { + tab: UpliftApprovalTab; + pendingCount: number; + onChange: (_event: unknown, value: string) => void; +}) { + return ( + + + Pending + + + } + /> + + + ); +} + +function UpliftApprovalsPagination({ + tab, + count, + pendingPage, + approvedPage, + onPendingPageChange, + onApprovedPageChange, +}: { + tab: UpliftApprovalTab; + count: number; + pendingPage: number; + approvedPage: number; + onPendingPageChange: (page: number) => void; + onApprovedPageChange: (page: number) => void; +}) { + return ( + + tab === "pending" ? onPendingPageChange(nextPage) : onApprovedPageChange(nextPage) + } + rowsPerPage={PAGE_SIZE} + rowsPerPageOptions={[PAGE_SIZE]} + /> + ); +} + +function UpliftQueueError({ error }: { error: unknown }) { + if (!error) return null; + return ( + + {error instanceof Error ? error.message : "Failed to load uplift queue"} + + ); +} + export default function UpliftQueuePage() { const [tab, setTab] = useState("pending"); const [pendingPage, setPendingPage] = useState(0); const [approvedPage, setApprovedPage] = useState(0); - const [detailItem, setDetailItem] = useState(null); - const [rejectTarget, setRejectTarget] = useState(null); - const [revokeTarget, setRevokeTarget] = useState(null); const pendingQuery = useUpliftsQueue({ status: "Pending", @@ -50,55 +127,29 @@ export default function UpliftQueuePage() { pageSize: PAGE_SIZE, }); - const approveUplift = useApproveUplift(); - const rejectUplift = useRejectUplift(); - const revokeUplift = useRevokeUplift(); + const { + detailItem, + rejectTarget, + revokeTarget, + approvePending, + rejectPending, + revokePending, + handleApprove, + handleRejectRequest, + handleRevokeRequest, + handleRejectConfirm, + handleRevokeConfirm, + closeDetail, + closeReject, + closeRevoke, + openDetail, + } = useUpliftApprovalController(); const activeQuery = tab === "pending" ? pendingQuery : approvedQuery; const rows = useMemo(() => sortRows(activeQuery.data?.items ?? [], tab), [activeQuery.data, tab]); const pendingCount = pendingQuery.data?.totalCount ?? 0; const pendingExposureTotal = pendingQuery.data?.pendingExposureTotal ?? null; - const closeDetail = () => setDetailItem(null); - - const handleApprove = (row: UpliftQueueItem) => { - approveUplift.mutate({ id: row.id }); - setDetailItem(null); - }; - - const handleRejectRequest = (row: UpliftQueueItem) => { - setDetailItem(null); - setRejectTarget(row); - }; - - const handleRevokeRequest = (row: UpliftQueueItem) => { - setDetailItem(null); - setRevokeTarget(row); - }; - - const closeReject = () => setRejectTarget(null); - const closeRevoke = () => setRevokeTarget(null); - - const handleRejectConfirm = (reason: string) => { - if (!rejectTarget) return; - rejectUplift.mutate( - { id: rejectTarget.id, note: reason }, - { onSuccess: () => setRejectTarget(null) }, - ); - }; - - const handleRevokeConfirm = (reason: string) => { - if (!revokeTarget?.workOrderId) return; - revokeUplift.mutate( - { - workOrderId: revokeTarget.workOrderId, - upliftId: revokeTarget.id, - reason, - }, - { onSuccess: () => setRevokeTarget(null) }, - ); - }; - const handleTabChange = (_event: unknown, value: string) => { if (value === "pending" || value === "approved") { setTab(value); @@ -107,65 +158,31 @@ export default function UpliftQueuePage() { return ( - - - Uplift Approvals - - - Requests to increase the approved spend on a work order, above its remaining auto-approval - allowance. - - - Pending exposure: {formatUpliftMoney(pendingExposureTotal ?? 0)} - - - - - Pending - - - } - /> - - - {Boolean(activeQuery.error) && ( - - {activeQuery.error instanceof Error - ? activeQuery.error.message - : "Failed to load uplift queue"} - - )} + + + - - tab === "pending" ? setPendingPage(nextPage) : setApprovedPage(nextPage) - } - rowsPerPage={PAGE_SIZE} - rowsPerPageOptions={[PAGE_SIZE]} + pendingPage={pendingPage} + approvedPage={approvedPage} + onPendingPageChange={setPendingPage} + onApprovedPageChange={setApprovedPage} /> {detailItem != null && ( ); diff --git a/src/domain/uplifts/api/uplifts-api.ts b/src/domain/uplifts/api/uplifts-api.ts index 7acd5252..6a93bdf5 100644 --- a/src/domain/uplifts/api/uplifts-api.ts +++ b/src/domain/uplifts/api/uplifts-api.ts @@ -90,12 +90,8 @@ export const upliftsApi = { await apiPost(`${API_PATHS.rest.uplifts}/${id}/request-changes`, { note }); }, - revoke: async ( - workOrderId: string | number, - upliftId: string | number, - reason?: string, - ): Promise => { - await apiPost(API_PATHS.workOrder.upliftRevoke(workOrderId, upliftId), { reason }); + revoke: async (id: string | number, reason: string): Promise => { + await apiPost(`${API_PATHS.rest.uplifts}/${id}/revoke`, { reason }); }, downloadEvidence: async ( diff --git a/src/domain/uplifts/use-cases/use-uplift-actions.ts b/src/domain/uplifts/use-cases/use-uplift-actions.ts index 7ecdc4f1..3ffedc21 100644 --- a/src/domain/uplifts/use-cases/use-uplift-actions.ts +++ b/src/domain/uplifts/use-cases/use-uplift-actions.ts @@ -103,7 +103,6 @@ export function useRequestChangesUplift( } export interface RevokeUpliftInput { - workOrderId: string | number; upliftId: string | number; reason: string; } @@ -112,8 +111,7 @@ export function useRevokeUplift(): UseMutationResult - upliftsApi.revoke(workOrderId, upliftId, reason), + mutationFn: ({ upliftId, reason }: RevokeUpliftInput) => upliftsApi.revoke(upliftId, reason), onSuccess: () => { invalidateWorkOrderUpliftQueries(queryClient); toast.success("Uplift revoked"); diff --git a/src/domain/uplifts/use-cases/use-uplift-approval-controller.ts b/src/domain/uplifts/use-cases/use-uplift-approval-controller.ts new file mode 100644 index 00000000..190ed24f --- /dev/null +++ b/src/domain/uplifts/use-cases/use-uplift-approval-controller.ts @@ -0,0 +1,65 @@ +import { useState } from "react"; +import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift"; +import { + useApproveUplift, + useRejectUplift, + useRevokeUplift, +} from "@/domain/uplifts/use-cases/use-uplift-actions"; + +export function useUpliftApprovalController() { + const [detailItem, setDetailItem] = useState(null); + const [rejectTarget, setRejectTarget] = useState(null); + const [revokeTarget, setRevokeTarget] = useState(null); + const approveUplift = useApproveUplift(); + const rejectUplift = useRejectUplift(); + const revokeUplift = useRevokeUplift(); + + const handleApprove = (row: UpliftQueueItem) => { + approveUplift.mutate({ id: row.id }); + setDetailItem(null); + }; + + const handleRejectRequest = (row: UpliftQueueItem) => { + setDetailItem(null); + setRejectTarget(row); + }; + + const handleRevokeRequest = (row: UpliftQueueItem) => { + setDetailItem(null); + setRevokeTarget(row); + }; + + const handleRejectConfirm = (reason: string) => { + if (!rejectTarget) return; + rejectUplift.mutate( + { id: rejectTarget.id, note: reason }, + { onSuccess: () => setRejectTarget(null) }, + ); + }; + + const handleRevokeConfirm = (reason: string) => { + if (!revokeTarget) return; + revokeUplift.mutate( + { upliftId: revokeTarget.id, reason }, + { onSuccess: () => setRevokeTarget(null) }, + ); + }; + + return { + detailItem, + rejectTarget, + revokeTarget, + approvePending: approveUplift.isPending, + rejectPending: rejectUplift.isPending, + revokePending: revokeUplift.isPending, + handleApprove, + handleRejectRequest, + handleRevokeRequest, + handleRejectConfirm, + handleRevokeConfirm, + closeDetail: () => setDetailItem(null), + closeReject: () => setRejectTarget(null), + closeRevoke: () => setRevokeTarget(null), + openDetail: setDetailItem, + }; +} diff --git a/src/test/domain/uplifts/api/uplifts-api.test.ts b/src/test/domain/uplifts/api/uplifts-api.test.ts new file mode 100644 index 00000000..a1424bc8 --- /dev/null +++ b/src/test/domain/uplifts/api/uplifts-api.test.ts @@ -0,0 +1,31 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const apiGet = vi.fn(); +const apiPost = vi.fn(); +const apiRequestRaw = vi.fn(); + +vi.mock("@/api/api", () => ({ + apiGet: (...args: unknown[]) => apiGet(...args), + apiPost: (...args: unknown[]) => apiPost(...args), + apiRequestRaw: (...args: unknown[]) => apiRequestRaw(...args), +})); + +import { upliftsApi } from "@/domain/uplifts/api/uplifts-api"; + +describe("upliftsApi", () => { + beforeEach(() => { + apiGet.mockReset(); + apiPost.mockReset(); + apiRequestRaw.mockReset(); + }); + + it("posts admin revoke decisions to the uplift decision endpoint", async () => { + apiPost.mockResolvedValueOnce(undefined); + + await upliftsApi.revoke(42, "Scope was already covered"); + + expect(apiPost).toHaveBeenCalledWith("uplifts/42/revoke", { + reason: "Scope was already covered", + }); + }); +});