fix(uplifts): use admin revoke contract (SH-212)

This commit is contained in:
Codex Review Integration 2026-09-16 20:54:33 -03:00
parent 3f98ac380f
commit 85024b40f3
5 changed files with 215 additions and 108 deletions

View file

@ -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 (
<Box>
<Text variant="title" as="h1">
Uplift Approvals
</Text>
<Text variant="description" tone="muted">
Requests to increase the approved spend on a work order, above its remaining auto-approval
allowance.
</Text>
<Text when={pendingExposureTotal != null} variant="caption" tone="muted">
Pending exposure: {formatUpliftMoney(pendingExposureTotal ?? 0)}
</Text>
</Box>
);
}
function UpliftApprovalsTabs({
tab,
pendingCount,
onChange,
}: {
tab: UpliftApprovalTab;
pendingCount: number;
onChange: (_event: unknown, value: string) => void;
}) {
return (
<Tabs value={tab} onChange={onChange}>
<Tab
value="pending"
label={
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
<span>Pending</span>
<Chip
size="small"
label={pendingCount}
aria-label={`${pendingCount} pending uplifts`}
/>
</Stack>
}
/>
<Tab value="approved" label="Approved" />
</Tabs>
);
}
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 (
<TablePagination
component="div"
count={count}
page={tab === "pending" ? pendingPage : approvedPage}
onPageChange={(_event, nextPage) =>
tab === "pending" ? onPendingPageChange(nextPage) : onApprovedPageChange(nextPage)
}
rowsPerPage={PAGE_SIZE}
rowsPerPageOptions={[PAGE_SIZE]}
/>
);
}
function UpliftQueueError({ error }: { error: unknown }) {
if (!error) return null;
return (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load uplift queue"}
</Alert>
);
}
export default function UpliftQueuePage() {
const [tab, setTab] = useState<UpliftApprovalTab>("pending");
const [pendingPage, setPendingPage] = useState(0);
const [approvedPage, setApprovedPage] = useState(0);
const [detailItem, setDetailItem] = useState<UpliftQueueItem | null>(null);
const [rejectTarget, setRejectTarget] = useState<UpliftQueueItem | null>(null);
const [revokeTarget, setRevokeTarget] = useState<UpliftQueueItem | null>(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 (
<Box className="flex flex-col gap-4 p-4">
<Box>
<Text variant="title" as="h1">
Uplift Approvals
</Text>
<Text variant="description" tone="muted">
Requests to increase the approved spend on a work order, above its remaining auto-approval
allowance.
</Text>
<Text when={pendingExposureTotal != null} variant="caption" tone="muted">
Pending exposure: {formatUpliftMoney(pendingExposureTotal ?? 0)}
</Text>
</Box>
<Tabs value={tab} onChange={handleTabChange}>
<Tab
value="pending"
label={
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
<span>Pending</span>
<Chip
size="small"
label={pendingCount}
aria-label={`${pendingCount} pending uplifts`}
/>
</Stack>
}
/>
<Tab value="approved" label="Approved" />
</Tabs>
{Boolean(activeQuery.error) && (
<Alert severity="error">
{activeQuery.error instanceof Error
? activeQuery.error.message
: "Failed to load uplift queue"}
</Alert>
)}
<UpliftApprovalsHeader pendingExposureTotal={pendingExposureTotal} />
<UpliftApprovalsTabs tab={tab} pendingCount={pendingCount} onChange={handleTabChange} />
<UpliftQueueError error={activeQuery.error} />
<UpliftApprovalsTable
tab={tab}
isLoading={activeQuery.isLoading}
rows={rows}
onOpenDetail={setDetailItem}
onOpenDetail={openDetail}
onApprove={handleApprove}
onReject={handleRejectRequest}
onRevoke={handleRevokeRequest}
/>
<TablePagination
component="div"
<UpliftApprovalsPagination
tab={tab}
count={activeQuery.data?.totalCount ?? 0}
page={tab === "pending" ? pendingPage : approvedPage}
onPageChange={(_event, nextPage) =>
tab === "pending" ? setPendingPage(nextPage) : setApprovedPage(nextPage)
}
rowsPerPage={PAGE_SIZE}
rowsPerPageOptions={[PAGE_SIZE]}
pendingPage={pendingPage}
approvedPage={approvedPage}
onPendingPageChange={setPendingPage}
onApprovedPageChange={setApprovedPage}
/>
{detailItem != null && (
<UpliftDetailModal
item={detailItem}
approvePending={approveUplift.isPending}
revokePending={revokeUplift.isPending}
approvePending={approvePending}
revokePending={revokePending}
onClose={closeDetail}
onApprove={handleApprove}
onReject={handleRejectRequest}
@ -178,7 +195,7 @@ export default function UpliftQueuePage() {
woNumber={rejectTarget?.woNumber || String(rejectTarget?.workOrderId ?? "")}
onClose={closeReject}
onConfirm={handleRejectConfirm}
isPending={rejectUplift.isPending}
isPending={rejectPending}
/>
<RevokeDialog
open={revokeTarget != null}
@ -186,7 +203,7 @@ export default function UpliftQueuePage() {
woNumber={revokeTarget?.woNumber || String(revokeTarget?.workOrderId ?? "")}
onClose={closeRevoke}
onConfirm={handleRevokeConfirm}
isPending={revokeUplift.isPending}
isPending={revokePending}
/>
</Box>
);

View file

@ -90,12 +90,8 @@ export const upliftsApi = {
await apiPost<unknown>(`${API_PATHS.rest.uplifts}/${id}/request-changes`, { note });
},
revoke: async (
workOrderId: string | number,
upliftId: string | number,
reason?: string,
): Promise<void> => {
await apiPost<unknown>(API_PATHS.workOrder.upliftRevoke(workOrderId, upliftId), { reason });
revoke: async (id: string | number, reason: string): Promise<void> => {
await apiPost<unknown>(`${API_PATHS.rest.uplifts}/${id}/revoke`, { reason });
},
downloadEvidence: async (

View file

@ -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<void, Error, RevokeUpliftIn
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ workOrderId, upliftId, reason }: RevokeUpliftInput) =>
upliftsApi.revoke(workOrderId, upliftId, reason),
mutationFn: ({ upliftId, reason }: RevokeUpliftInput) => upliftsApi.revoke(upliftId, reason),
onSuccess: () => {
invalidateWorkOrderUpliftQueries(queryClient);
toast.success("Uplift revoked");

View file

@ -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<UpliftQueueItem | null>(null);
const [rejectTarget, setRejectTarget] = useState<UpliftQueueItem | null>(null);
const [revokeTarget, setRevokeTarget] = useState<UpliftQueueItem | null>(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,
};
}

View file

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