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",
+ });
+ });
+});