Merge pull request #279 from Sea-Haven-Industries/feat/ab/sh-389-rejected-uplift-queue

feat(uplifts): add the Rejected archive tab to Uplift Approvals (SH-389)
This commit is contained in:
Alexandre Brandizzi 2026-09-25 20:08:16 +00:00 • committed by GitHub
commit 85a45e9fc0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 519 additions and 118 deletions

View file

@ -0,0 +1,52 @@
export const UPLIFT_APPROVAL_TABS = ["pending", "approved", "rejected"] as const;
export type UpliftApprovalTab = (typeof UPLIFT_APPROVAL_TABS)[number];
export function isUpliftApprovalTab(value: string): value is UpliftApprovalTab {
return (UPLIFT_APPROVAL_TABS as readonly string[]).includes(value);
}
const PENDING_HEADER_CELLS = [
"Work Order",
"Service / Vendor",
"Amount",
"Approved on WO",
"Notes",
"Attachments",
"Requested By",
"Waiting",
"Actions",
] as const;
const APPROVED_HEADER_CELLS = [
"Work Order",
"Service / Vendor",
"Amount",
"Approved on WO",
"Notes",
"Attachments",
"Requested By",
"Approved By",
"Approved At",
"Actions",
] as const;
// A rejection is terminal, so the archive ends with the decision and its reason
// instead of an Actions column.
const REJECTED_HEADER_CELLS = [
"Work Order",
"Service / Vendor",
"Amount",
"Approved on WO",
"Notes",
"Attachments",
"Requested By",
"Rejected By",
"Rejected At",
"Rejection Reason",
] as const;
export const UPLIFT_APPROVAL_HEADER_CELLS: Record<UpliftApprovalTab, readonly string[]> = {
pending: PENDING_HEADER_CELLS,
approved: APPROVED_HEADER_CELLS,
rejected: REJECTED_HEADER_CELLS,
};

View file

@ -16,14 +16,20 @@ import {
formatUpliftMoney,
isGrantedAmount,
} from "@/app/(protected)/uplifts/_components/format-uplift-money";
import {
UPLIFT_APPROVAL_HEADER_CELLS,
type UpliftApprovalTab,
} from "@/app/(protected)/uplifts/_components/uplift-approval-tabs";
import {
RejectedEmptyState,
RejectedRowCells,
} from "@/app/(protected)/uplifts/_components/uplift-rejected-cells";
import { Text } from "@/components/ui/text";
import { useUpliftsCanApprove } from "@/domain/uplifts/use-cases/use-uplifts-can-approve";
import { upliftAmount } from "@/domain/uplifts/utils/uplift-amount";
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
import { formatDateTime, getWaitTimeTextClass, timeSince, waitTimeColor } from "@/lib/time-utils";
export type UpliftApprovalTab = "pending" | "approved";
const CLOSED_WO_TOOLTIP = "This work order is closed. Uplifts can no longer be revoked.";
const ADMIN_ONLY_REVOKE_TOOLTIP = "Only admins can revoke uplifts";
@ -236,65 +242,84 @@ function UpliftApprovalRow({
<AttachmentsCell row={row} onOpenAttachment={onOpenAttachment} />
</TableCell>
<TableCell>{row.requestedByVendorName || "—"}</TableCell>
{tab === "pending" ? (
<TableCell>
<WaitingCell row={row} />
</TableCell>
) : (
{tab === "pending" && (
<>
<TableCell>
<WaitingCell row={row} />
</TableCell>
<TableCell>
<PendingRowActions
row={row}
onApprove={onApprove}
onReject={onReject}
isDecisionPending={isDecisionPending}
/>
</TableCell>
</>
)}
{tab === "approved" && (
<>
<TableCell>{row.decidedByName || "—"}</TableCell>
<TableCell>{row.decidedAt ? formatDateTime(row.decidedAt) : "—"}</TableCell>
<TableCell>
<ApprovedRowActions
row={row}
canRevoke={canRevoke}
onRevoke={onRevoke}
isDecisionPending={isDecisionPending}
/>
</TableCell>
</>
)}
<TableCell>
{tab === "pending" ? (
<PendingRowActions
row={row}
onApprove={onApprove}
onReject={onReject}
isDecisionPending={isDecisionPending}
/>
) : (
<ApprovedRowActions
row={row}
canRevoke={canRevoke}
onRevoke={onRevoke}
isDecisionPending={isDecisionPending}
/>
)}
{tab === "rejected" && <RejectedRowCells row={row} />}
</TableRow>
);
}
function UpliftApprovalsEmptyState({ tab }: { tab: UpliftApprovalTab }) {
if (tab === "rejected") return <RejectedEmptyState />;
if (tab === "approved") {
return (
<Box>
<Text variant="description">No approved uplifts yet</Text>
<Text variant="caption" tone="muted">
Uplifts you approve are archived here for reference.
</Text>
</Box>
);
}
return (
<Text variant="description" tone="muted">
No uplifts waiting for approval.
</Text>
);
}
// A failed queue is not an empty one: the page's error alert explains it, so the
// empty-state copy must not also claim the list has nothing in it.
function UpliftApprovalsEmptyRow({
tab,
colSpan,
hasError,
}: {
tab: UpliftApprovalTab;
colSpan: number;
hasError: boolean;
}) {
if (hasError) return null;
return (
<TableRow>
<TableCell colSpan={colSpan} align="center" className="py-10">
<UpliftApprovalsEmptyState tab={tab} />
</TableCell>
</TableRow>
);
}
const PENDING_HEADER_CELLS = [
"Work Order",
"Service / Vendor",
"Amount",
"Approved on WO",
"Notes",
"Attachments",
"Requested By",
"Waiting",
"Actions",
] as const;
const APPROVED_HEADER_CELLS = [
"Work Order",
"Service / Vendor",
"Amount",
"Approved on WO",
"Notes",
"Attachments",
"Requested By",
"Approved By",
"Approved At",
"Actions",
] as const;
export function UpliftApprovalsTable({
tab,
isLoading,
hasError,
rows,
canRevoke,
onOpenDetail,
@ -306,6 +331,7 @@ export function UpliftApprovalsTable({
}: {
tab: UpliftApprovalTab;
isLoading: boolean;
hasError: boolean;
rows: UpliftQueueItem[];
canRevoke: boolean;
onOpenDetail: (row: UpliftQueueItem) => void;
@ -315,7 +341,7 @@ export function UpliftApprovalsTable({
onOpenAttachment: (row: UpliftQueueItem) => void;
isDecisionPending: boolean;
}) {
const headerCells = tab === "pending" ? PENDING_HEADER_CELLS : APPROVED_HEADER_CELLS;
const headerCells = UPLIFT_APPROVAL_HEADER_CELLS[tab];
return (
<TableContainer component={Paper} variant="outlined">
<Table size="small" aria-label={`${tab} uplift requests`}>
@ -336,22 +362,7 @@ export function UpliftApprovalsTable({
</TableCell>
</TableRow>
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={headerCells.length} align="center" className="py-10">
{tab === "pending" ? (
<Text variant="description" tone="muted">
No uplifts waiting for approval.
</Text>
) : (
<Box>
<Text variant="description">No approved uplifts yet</Text>
<Text variant="caption" tone="muted">
Uplifts you approve are archived here for reference.
</Text>
</Box>
)}
</TableCell>
</TableRow>
<UpliftApprovalsEmptyRow tab={tab} colSpan={headerCells.length} hasError={hasError} />
) : (
rows.map((row) => (
<UpliftApprovalRow

View file

@ -0,0 +1,28 @@
import HighlightOffOutlinedIcon from "@mui/icons-material/HighlightOffOutlined";
import { Box, TableCell } from "@mui/material";
import { Text } from "@/components/ui/text";
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
import { formatDateTime } from "@/lib/time-utils";
export function RejectedRowCells({ row }: { row: UpliftQueueItem }) {
return (
<>
<TableCell>{row.decidedByName || "—"}</TableCell>
<TableCell>{row.decidedAt ? formatDateTime(row.decidedAt) : "—"}</TableCell>
<TableCell sx={{ maxWidth: 260 }}>
<Text variant="caption" sx={{ whiteSpace: "pre-wrap" }}>
{row.decisionNote || "—"}
</Text>
</TableCell>
</>
);
}
export function RejectedEmptyState() {
return (
<Box className="flex flex-col items-center gap-2">
<HighlightOffOutlinedIcon color="disabled" aria-hidden />
<Text variant="description">No rejected uplifts.</Text>
</Box>
);
}

View file

@ -1,9 +1,11 @@
import { useState } from "react";
import { Alert, Box, Chip, Stack, Tab, Tabs, TablePagination } from "@mui/material";
import {
UpliftApprovalsTable,
UPLIFT_APPROVAL_TABS,
isUpliftApprovalTab,
type UpliftApprovalTab,
} from "@/app/(protected)/uplifts/_components/uplift-approvals-table";
} from "@/app/(protected)/uplifts/_components/uplift-approval-tabs";
import { UpliftApprovalsTable } from "@/app/(protected)/uplifts/_components/uplift-approvals-table";
import { UpliftDecisionDialogs } from "@/app/(protected)/uplifts/_components/uplift-decision-dialogs";
import { UpliftDetailModal } from "@/app/(protected)/uplifts/_components/uplift-detail-modal";
import {
@ -16,6 +18,7 @@ import { useAuthContext } from "@/providers/auth-context";
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";
import type { UpliftsQueueResult } from "@/domain/uplifts/types/uplift";
const PAGE_SIZE = 25;
@ -39,58 +42,71 @@ function UpliftApprovalsHeader({ pendingExposureTotal }: { pendingExposureTotal:
);
}
const TAB_LABELS: Record<UpliftApprovalTab, string> = {
pending: "Pending",
approved: "Approved",
rejected: "Rejected",
};
// A tab's total is only known once its queue has loaded at least once; until then the
// pill and pager must not claim a count (a loading or failed queue is not an empty one).
// A failed refetch keeps the last loaded page, so its total stays usable.
function knownTotal(query: { data?: UpliftsQueueResult | undefined }): number | null {
return query.data ? query.data.totalCount : null;
}
function TabCountPill({ tab, count }: { tab: UpliftApprovalTab; count: number | null }) {
return (
<Chip
size="small"
label={count ?? "—"}
aria-label={count == null ? `${tab} uplift count unavailable` : `${count} ${tab} uplifts`}
/>
);
}
function UpliftApprovalsTabs({
tab,
pendingCount,
counts,
onChange,
}: {
tab: UpliftApprovalTab;
pendingCount: number;
counts: Record<UpliftApprovalTab, number | null>;
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" />
{UPLIFT_APPROVAL_TABS.map((value) => (
<Tab
key={value}
value={value}
label={
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
<span>{TAB_LABELS[value]}</span>
<TabCountPill tab={value} count={counts[value]} />
</Stack>
}
/>
))}
</Tabs>
);
}
function UpliftApprovalsPagination({
tab,
count,
pendingPage,
approvedPage,
onPendingPageChange,
onApprovedPageChange,
page,
onPageChange,
}: {
tab: UpliftApprovalTab;
count: number;
pendingPage: number;
approvedPage: number;
onPendingPageChange: (page: number) => void;
onApprovedPageChange: (page: number) => void;
page: number;
onPageChange: (page: number) => void;
}) {
return (
<TablePagination
component="div"
count={count}
page={tab === "pending" ? pendingPage : approvedPage}
onPageChange={(_event, nextPage) =>
tab === "pending" ? onPendingPageChange(nextPage) : onApprovedPageChange(nextPage)
}
page={page}
onPageChange={(_event, nextPage) => onPageChange(nextPage)}
rowsPerPage={PAGE_SIZE}
rowsPerPageOptions={[PAGE_SIZE]}
/>
@ -108,22 +124,31 @@ function UpliftQueueError({ error }: { error: unknown }) {
export default function UpliftQueuePage() {
const [tab, setTab] = useState<UpliftApprovalTab>("pending");
const [pendingPage, setPendingPage] = useState(0);
const [approvedPage, setApprovedPage] = useState(0);
const [pages, setPages] = useState<Record<UpliftApprovalTab, number>>({
pending: 0,
approved: 0,
rejected: 0,
});
const { user } = useAuthContext();
const canRevokeUplifts = isAdminUser(user?.userRoles);
const pendingQuery = useUpliftsQueue({
status: "Pending",
page: pendingPage + 1,
page: pages.pending + 1,
pageSize: PAGE_SIZE,
});
const approvedQuery = useUpliftsQueue({
status: "Approved",
page: approvedPage + 1,
page: pages.approved + 1,
pageSize: PAGE_SIZE,
});
const rejectedQuery = useUpliftsQueue({
status: "Rejected",
page: pages.rejected + 1,
pageSize: PAGE_SIZE,
});
const queries = { pending: pendingQuery, approved: approvedQuery, rejected: rejectedQuery };
const controller = useUpliftApprovalController();
const {
@ -138,13 +163,18 @@ export default function UpliftQueuePage() {
openDetail,
} = controller;
const activeQuery = tab === "pending" ? pendingQuery : approvedQuery;
const activeQuery = queries[tab];
const rows = activeQuery.data?.items ?? [];
const pendingCount = pendingQuery.data?.totalCount ?? 0;
const counts = {
pending: knownTotal(pendingQuery),
approved: knownTotal(approvedQuery),
rejected: knownTotal(rejectedQuery),
};
const activeTotal = counts[tab];
const pendingExposureTotal = pendingQuery.data?.pendingExposureTotal ?? null;
const handleTabChange = (_event: unknown, value: string) => {
if (value === "pending" || value === "approved") {
if (isUpliftApprovalTab(value)) {
setTab(value);
}
};
@ -152,11 +182,12 @@ export default function UpliftQueuePage() {
return (
<Box className="flex flex-col gap-4 p-4">
<UpliftApprovalsHeader pendingExposureTotal={pendingExposureTotal} />
<UpliftApprovalsTabs tab={tab} pendingCount={pendingCount} onChange={handleTabChange} />
<UpliftApprovalsTabs tab={tab} counts={counts} onChange={handleTabChange} />
<UpliftQueueError error={activeQuery.error} />
<UpliftApprovalsTable
tab={tab}
isLoading={activeQuery.isLoading}
hasError={Boolean(activeQuery.error)}
rows={rows}
canRevoke={canRevokeUplifts}
onOpenDetail={openDetail}
@ -166,14 +197,13 @@ export default function UpliftQueuePage() {
onOpenAttachment={openUpliftEvidence}
isDecisionPending={approvePending || rejectPending || revokePending}
/>
<UpliftApprovalsPagination
tab={tab}
count={activeQuery.data?.totalCount ?? 0}
pendingPage={pendingPage}
approvedPage={approvedPage}
onPendingPageChange={setPendingPage}
onApprovedPageChange={setApprovedPage}
/>
{activeTotal != null && (
<UpliftApprovalsPagination
count={activeTotal}
page={pages[tab]}
onPageChange={(page) => setPages((current) => ({ ...current, [tab]: page }))}
/>
)}
{detailItem != null && (
<UpliftDetailModal
item={detailItem}

View file

@ -10,5 +10,8 @@ export function useUpliftsQueue(
queryKey: queryKeys.uplifts.queue(params as Record<string, unknown>),
queryFn: () => upliftsApi.list(params),
placeholderData: keepPreviousData,
// The Uplift Approvals page reports a failed queue inline, so the global toast
// would announce the same failure twice.
meta: { suppressErrorToast: true },
});
}

View file

@ -100,7 +100,7 @@ describe("Uplift Approvals approved tab", () => {
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
renderWithProviders(<UpliftQueuePage />);
fireEvent.click(screen.getByRole("tab", { name: "Approved" }));
fireEvent.click(screen.getByRole("tab", { name: /^Approved/ }));
expect(screen.getByRole("columnheader", { name: "Approved By" })).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: "Approved At" })).toBeInTheDocument();
expect(screen.queryByRole("columnheader", { name: "Waiting" })).not.toBeInTheDocument();
@ -125,7 +125,7 @@ describe("Uplift Approvals approved tab", () => {
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
renderWithProviders(<UpliftQueuePage />);
fireEvent.click(screen.getByRole("tab", { name: "Approved" }));
fireEvent.click(screen.getByRole("tab", { name: /^Approved/ }));
const revoke = await screen.findByRole("button", { name: "Revoke" });
expect(revoke).toBeEnabled();
fireEvent.click(revoke);
@ -149,7 +149,7 @@ describe("Uplift Approvals approved tab", () => {
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
renderWithProviders(<UpliftQueuePage />);
fireEvent.click(screen.getByRole("tab", { name: "Approved" }));
fireEvent.click(screen.getByRole("tab", { name: /^Approved/ }));
const revoke = await screen.findByRole("button", { name: "Revoke" });
expect(revoke).toBeDisabled();
fireEvent.mouseOver(revoke);
@ -172,7 +172,7 @@ describe("Uplift Approvals approved tab", () => {
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
renderWithProviders(<UpliftQueuePage />);
fireEvent.click(screen.getByRole("tab", { name: "Approved" }));
fireEvent.click(screen.getByRole("tab", { name: /^Approved/ }));
expect(await screen.findByText("No approved uplifts yet")).toBeInTheDocument();
expect(
screen.getByText("Uplifts you approve are archived here for reference."),

View file

@ -167,7 +167,7 @@ describe("Uplift Approvals shows one amount per request", () => {
queueState.data = queueOf(approvedItem);
await renderQueue();
fireEvent.click(screen.getByRole("tab", { name: "Approved" }));
fireEvent.click(screen.getByRole("tab", { name: /^Approved/ }));
expect(amountCell()).toHaveTextContent(UPLIFT);
fireEvent.click(screen.getByRole("button", { name: "Revoke" }));

View file

@ -0,0 +1,237 @@
import { fireEvent, screen, within } from "@testing-library/react";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type {
UpliftQueueItem,
UpliftsQueueParams,
UpliftsQueueResult,
} from "@/domain/uplifts/types/uplift";
import { formatDateTime } from "@/lib/time-utils";
import { renderWithProviders } from "@/test/test-utils";
const queueState = vi.hoisted(() => ({
byStatus: {} as Record<string, UpliftsQueueResult | undefined>,
errorByStatus: {} as Record<string, Error | undefined>,
requestedStatuses: [] as Array<string | undefined>,
}));
const baseItem: UpliftQueueItem = {
id: 1,
status: "Pending",
currentNTE: 500,
requestedNTE: 750,
delta: 250,
vendorReason: "Second hydro-jetting pass on the adjoining line",
requestedAt: "2026-06-21T10:05:00Z",
requestedByVendorName: "Alyssa Dispatcher",
decidedAt: "",
decidedByName: "",
decisionNote: "",
requiredTier: 1,
canDecide: true,
expiresAt: "",
notificationStatus: "",
notificationError: "",
evidenceDocumentId: "doc-9",
evidenceFileName: "quote.pdf",
evidenceContentType: "application/pdf",
evidenceSizeBytes: 1024,
dispatchNumber: "DSP-1",
poNumber: "PO-1",
vendorCompanyName: "Drain Cleaning Bros",
workOrderId: 10,
dispatchId: 20,
woNumber: "WO-25603",
site: "CHI2",
serviceName: "Drains",
technicianName: "",
workOrderDispatcherName: "",
workOrderScheduledDate: "",
attachmentCount: 1,
approvedOnWoAuto: null,
approvedOnWoAdmin: null,
approvedOnWoTotal: null,
workOrderClosed: false,
};
const newestRejection: UpliftQueueItem = {
...baseItem,
id: 31,
status: "Rejected",
woNumber: "WO-31",
decidedAt: "2026-06-21T16:40:00Z",
decidedByName: "Adam Moussa",
decisionNote: "Outside this work order's scope — raise a separate reactive WO.",
canDecide: false,
};
const olderRejection: UpliftQueueItem = {
...baseItem,
id: 30,
status: "Rejected",
woNumber: "WO-30",
requestedByVendorName: "Derek Dispatcher",
decidedAt: "2026-05-26T15:05:00Z",
decidedByName: "Grace Admin",
decisionNote: "Quote is above market for this scope.",
canDecide: false,
};
const pendingItem: UpliftQueueItem = { ...baseItem, id: 40, woNumber: "WO-PENDING" };
const approvedItem: UpliftQueueItem = {
...baseItem,
id: 50,
status: "Approved",
woNumber: "WO-APPROVED",
decidedAt: "2026-06-01T09:00:00Z",
decidedByName: "Adam Moussa",
};
function page(items: UpliftQueueItem[], totalCount = items.length): UpliftsQueueResult {
return { items, totalCount, page: 1, pageSize: 25, pendingExposureTotal: null };
}
vi.mock("@/domain/uplifts/use-cases/use-uplifts-queue", () => ({
useUpliftsQueue: (params: UpliftsQueueParams) => {
queueState.requestedStatuses.push(params.status);
const data = queueState.byStatus[params.status ?? ""];
const error = queueState.errorByStatus[params.status ?? ""] ?? null;
return {
data,
isLoading: data === undefined && error === null,
isFetching: false,
error,
refetch: vi.fn(),
};
},
}));
vi.mock("@/domain/uplifts/use-cases/use-uplift-actions", () => ({
useApproveUplift: () => ({ isPending: false, mutate: vi.fn() }),
useRejectUplift: () => ({ isPending: false, mutate: vi.fn() }),
useRevokeUplift: () => ({ isPending: false, mutate: vi.fn() }),
}));
vi.mock("@/providers/auth-context", async (importOriginal) => ({
...(await importOriginal<object>()),
useAuthContext: () => ({ user: { userRoles: "Admin" } }),
}));
vi.mock("@/domain/uplifts/use-cases/use-uplifts-can-approve", () => ({
useUpliftsCanApprove: () => ({ data: true }),
}));
async function openRejectedTab() {
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
renderWithProviders(<UpliftQueuePage />);
fireEvent.click(screen.getByRole("tab", { name: /^Rejected/ }));
return screen.getByRole("table", { name: "rejected uplift requests" });
}
describe("Uplift Approvals rejected tab", () => {
// Load the page module once up front so the first test's budget is not spent on a
// cold import of the whole route.
beforeAll(async () => {
await import("@/app/(protected)/uplifts/index");
}, 60_000);
beforeEach(() => {
queueState.requestedStatuses = [];
queueState.errorByStatus = {};
queueState.byStatus = {
Pending: page([pendingItem]),
Approved: page([approvedItem]),
Rejected: page([newestRejection, olderRejection], 12),
};
});
it("shows the rejected total in the tab count pill next to the other tabs' pills", async () => {
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
renderWithProviders(<UpliftQueuePage />);
const rejectedTab = screen.getByRole("tab", { name: /^Rejected/ });
expect(within(rejectedTab).getByLabelText("12 rejected uplifts")).toHaveTextContent("12");
expect(screen.getByLabelText("1 pending uplifts")).toBeInTheDocument();
expect(screen.getByLabelText("1 approved uplifts")).toBeInTheDocument();
expect(queueState.requestedStatuses).toContain("Rejected");
});
it("never claims a count for a queue that is still loading or failed to load", async () => {
queueState.byStatus.Rejected = undefined;
queueState.errorByStatus.Rejected = new Error("Forbidden");
queueState.byStatus.Approved = undefined;
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
renderWithProviders(<UpliftQueuePage />);
const rejectedPill = screen.getByLabelText("rejected uplift count unavailable");
expect(rejectedPill).toHaveTextContent("—");
expect(screen.getByLabelText("approved uplift count unavailable")).toHaveTextContent("—");
expect(screen.queryByLabelText("0 rejected uplifts")).not.toBeInTheDocument();
expect(screen.queryByLabelText("0 approved uplifts")).not.toBeInTheDocument();
expect(screen.getByLabelText("1 pending uplifts")).toBeInTheDocument();
});
it("keeps the count and pager for kept rows when a rejected refetch fails", async () => {
queueState.errorByStatus.Rejected = new Error("Failed to load uplift queue");
const table = await openRejectedTab();
expect(screen.getByRole("alert")).toHaveTextContent("Failed to load uplift queue");
expect(within(table).getAllByRole("row")).toHaveLength(3);
expect(screen.getByLabelText("12 rejected uplifts")).toHaveTextContent("12");
expect(screen.getByText(/of 12/)).toBeInTheDocument();
});
it("shows the load error instead of an empty archive when the rejected queue fails", async () => {
queueState.byStatus.Rejected = undefined;
queueState.errorByStatus.Rejected = new Error("Failed to load uplift queue");
const table = await openRejectedTab();
expect(screen.getByRole("alert")).toHaveTextContent("Failed to load uplift queue");
expect(within(table).queryByText("No rejected uplifts.")).not.toBeInTheDocument();
expect(within(table).queryByTestId("HighlightOffOutlinedIcon")).not.toBeInTheDocument();
expect(screen.queryByText(/of 0/)).not.toBeInTheDocument();
});
it("lists only rejected uplifts, in the server's most-recently-rejected order", async () => {
const table = await openRejectedTab();
const dataRows = within(table).getAllByRole("row").slice(1);
expect(dataRows).toHaveLength(2);
expect(dataRows[0]).toHaveTextContent("WO-31");
expect(dataRows[1]).toHaveTextContent("WO-30");
expect(within(table).queryByText("WO-PENDING")).not.toBeInTheDocument();
expect(within(table).queryByText("WO-APPROVED")).not.toBeInTheDocument();
});
it("shows who requested, who rejected, when, and the rejection reason inline", async () => {
const table = await openRejectedTab();
for (const header of ["Requested By", "Rejected By", "Rejected At", "Rejection Reason"]) {
expect(within(table).getByRole("columnheader", { name: header })).toBeInTheDocument();
}
const [, firstRow] = within(table).getAllByRole("row");
expect(firstRow).toHaveTextContent("Alyssa Dispatcher");
expect(firstRow).toHaveTextContent("Adam Moussa");
expect(firstRow).toHaveTextContent(formatDateTime(newestRejection.decidedAt));
expect(
within(firstRow).getByText("Outside this work order's scope — raise a separate reactive WO."),
).toBeVisible();
});
it("offers no action on a rejected uplift", async () => {
const table = await openRejectedTab();
expect(within(table).queryByRole("columnheader", { name: "Actions" })).not.toBeInTheDocument();
for (const name of [/^Approve/, /^Reject$/, /^Revoke/]) {
expect(within(table).queryByRole("button", { name })).not.toBeInTheDocument();
}
});
it("shows an icon and the empty copy when nothing has been rejected", async () => {
queueState.byStatus.Rejected = page([]);
const table = await openRejectedTab();
expect(within(table).getByText("No rejected uplifts.")).toBeInTheDocument();
expect(within(table).getByTestId("HighlightOffOutlinedIcon")).toBeInTheDocument();
expect(screen.getByLabelText("0 rejected uplifts")).toBeInTheDocument();
});
});

View file

@ -0,0 +1,40 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { renderHook, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { upliftsApi } from "@/domain/uplifts/api/uplifts-api";
import { useUpliftsQueue } from "@/domain/uplifts/use-cases/use-uplifts-queue";
import { createAppQueryClient } from "@/lib/query/query-client";
const toastMocks = vi.hoisted(() => ({ error: vi.fn() }));
vi.mock("react-toastify", () => ({
toast: { error: toastMocks.error, success: vi.fn() },
}));
afterEach(() => {
vi.restoreAllMocks();
toastMocks.error.mockReset();
});
describe("useUpliftsQueue", () => {
it("does not show the global error toast, since the approvals page reports the failure inline", async () => {
vi.spyOn(upliftsApi, "list").mockRejectedValue(new Error("Failed to load uplift queue"));
// The real app query client runs the QueryCache's global onError handler, which
// toasts unless the query opts out.
const queryClient = createAppQueryClient();
queryClient.setDefaultOptions({ queries: { retry: false } });
const { result } = renderHook(
() => useUpliftsQueue({ status: "Rejected", page: 1, pageSize: 25 }),
{
wrapper: ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
),
},
);
await waitFor(() => expect(result.current.error).toBeInstanceOf(Error));
expect(toastMocks.error).not.toHaveBeenCalled();
});
});