mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-04 00:53:14 +00:00
fix(uplifts): use admin revoke contract (SH-212)
This commit is contained in:
parent
3f98ac380f
commit
85024b40f3
5 changed files with 215 additions and 108 deletions
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
31
src/test/domain/uplifts/api/uplifts-api.test.ts
Normal file
31
src/test/domain/uplifts/api/uplifts-api.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue