mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 03:23:12 +00:00
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:
commit
85a45e9fc0
9 changed files with 519 additions and 118 deletions
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."),
|
||||
|
|
|
|||
|
|
@ -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" }));
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
40
src/test/domain/uplifts/use-cases/use-uplifts-queue.test.tsx
Normal file
40
src/test/domain/uplifts/use-cases/use-uplifts-queue.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue