fix(uplifts): restrict revoke to admins and quiet permission failures (SH-207)

- Gate the Revoke action on the approved queue row and the detail modal
  to admin users; non-admins get a disabled button with a plain
  'Only admins can revoke uplifts' tooltip and the dialog cannot open.
- Stop nesting an h2 inside DialogTitle in the uplift detail modal
  (hydration error) by rendering the title text as a non-heading.
- Handle work-order detail/uplift query 403s quietly in the detail
  modal: sections fall back to 'Unavailable' and the queries suppress
  the global error toast (QueryCache now honors suppressErrorToast).
- Extract UpliftDecisionDialogs and openUpliftEvidence from the page to
  meet the changed-file maintainability gate (complexity 23 -> pass).
This commit is contained in:
Codex Review Integration 2026-09-17 00:13:54 -03:00
parent 6a2b9eb80c
commit 4bffdac175
13 changed files with 412 additions and 79 deletions

View file

@ -0,0 +1,13 @@
import { toast } from "react-toastify";
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
import { upliftsApi } from "@/domain/uplifts/api/uplifts-api";
export function openUpliftEvidence(row: UpliftQueueItem): void {
void upliftsApi
.downloadEvidence(row.id, row.evidenceFileName || "uplift-evidence")
.catch((error: unknown) => {
toast.error(
error instanceof Error ? error.message : "Unable to download evidence right now.",
);
});
}

View file

@ -20,6 +20,7 @@ import { formatDateTime, getWaitTimeTextClass, timeSince, waitTimeColor } from "
export type UpliftApprovalTab = "pending" | "approved"; export type UpliftApprovalTab = "pending" | "approved";
const CLOSED_WO_TOOLTIP = "This work order is closed. Uplifts can no longer be revoked."; 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";
function workOrderLabel(row: UpliftQueueItem): string { function workOrderLabel(row: UpliftQueueItem): string {
return row.woNumber || row.dispatchNumber || "—"; return row.woNumber || row.dispatchNumber || "—";
@ -119,23 +120,26 @@ function PendingRowActions({
function ApprovedRowActions({ function ApprovedRowActions({
row, row,
canRevoke,
onRevoke, onRevoke,
isDecisionPending, isDecisionPending,
}: { }: {
row: UpliftQueueItem; row: UpliftQueueItem;
canRevoke: boolean;
onRevoke: (row: UpliftQueueItem) => void; onRevoke: (row: UpliftQueueItem) => void;
isDecisionPending: boolean; isDecisionPending: boolean;
}) { }) {
const closed = row.workOrderClosed === true; const closed = row.workOrderClosed === true;
const tooltip = !canRevoke ? ADMIN_ONLY_REVOKE_TOOLTIP : closed ? CLOSED_WO_TOOLTIP : "";
return ( return (
<Box sx={{ display: "flex", gap: 1 }} onClick={(e) => e.stopPropagation()}> <Box sx={{ display: "flex", gap: 1 }} onClick={(e) => e.stopPropagation()}>
<Tooltip title={closed ? CLOSED_WO_TOOLTIP : ""}> <Tooltip title={tooltip}>
<span> <span>
<Button <Button
size="small" size="small"
variant="outlined" variant="outlined"
color="error" color="error"
disabled={isDecisionPending || closed || row.workOrderId == null} disabled={!canRevoke || isDecisionPending || closed || row.workOrderId == null}
onClick={() => onRevoke(row)} onClick={() => onRevoke(row)}
> >
Revoke Revoke
@ -149,6 +153,7 @@ function ApprovedRowActions({
function UpliftApprovalRow({ function UpliftApprovalRow({
row, row,
tab, tab,
canRevoke,
onOpenDetail, onOpenDetail,
onApprove, onApprove,
onReject, onReject,
@ -158,6 +163,7 @@ function UpliftApprovalRow({
}: { }: {
row: UpliftQueueItem; row: UpliftQueueItem;
tab: UpliftApprovalTab; tab: UpliftApprovalTab;
canRevoke: boolean;
onOpenDetail: (row: UpliftQueueItem) => void; onOpenDetail: (row: UpliftQueueItem) => void;
onApprove: (row: UpliftQueueItem) => void; onApprove: (row: UpliftQueueItem) => void;
onReject: (row: UpliftQueueItem) => void; onReject: (row: UpliftQueueItem) => void;
@ -242,7 +248,12 @@ function UpliftApprovalRow({
isDecisionPending={isDecisionPending} isDecisionPending={isDecisionPending}
/> />
) : ( ) : (
<ApprovedRowActions row={row} onRevoke={onRevoke} isDecisionPending={isDecisionPending} /> <ApprovedRowActions
row={row}
canRevoke={canRevoke}
onRevoke={onRevoke}
isDecisionPending={isDecisionPending}
/>
)} )}
</TableCell> </TableCell>
</TableRow> </TableRow>
@ -278,6 +289,7 @@ export function UpliftApprovalsTable({
tab, tab,
isLoading, isLoading,
rows, rows,
canRevoke,
onOpenDetail, onOpenDetail,
onApprove, onApprove,
onReject, onReject,
@ -288,6 +300,7 @@ export function UpliftApprovalsTable({
tab: UpliftApprovalTab; tab: UpliftApprovalTab;
isLoading: boolean; isLoading: boolean;
rows: UpliftQueueItem[]; rows: UpliftQueueItem[];
canRevoke: boolean;
onOpenDetail: (row: UpliftQueueItem) => void; onOpenDetail: (row: UpliftQueueItem) => void;
onApprove: (row: UpliftQueueItem) => void; onApprove: (row: UpliftQueueItem) => void;
onReject: (row: UpliftQueueItem) => void; onReject: (row: UpliftQueueItem) => void;
@ -338,6 +351,7 @@ export function UpliftApprovalsTable({
key={String(row.id)} key={String(row.id)}
row={row} row={row}
tab={tab} tab={tab}
canRevoke={canRevoke}
onOpenDetail={onOpenDetail} onOpenDetail={onOpenDetail}
onApprove={onApprove} onApprove={onApprove}
onReject={onReject} onReject={onReject}

View file

@ -0,0 +1,42 @@
import { RejectDialog } from "@/app/(protected)/uplifts/_components/reject-dialog";
import { RevokeDialog } from "@/app/(protected)/uplifts/_components/revoke-dialog";
import type { useUpliftApprovalController } from "@/domain/uplifts/use-cases/use-uplift-approval-controller";
export type UpliftApprovalController = ReturnType<typeof useUpliftApprovalController>;
function woFallbackNumber(target: UpliftApprovalController["rejectTarget"]): string {
return target?.woNumber || String(target?.workOrderId ?? "");
}
export function UpliftDecisionDialogs({ controller }: { controller: UpliftApprovalController }) {
const {
rejectTarget,
revokeTarget,
rejectPending,
revokePending,
handleRejectConfirm,
handleRevokeConfirm,
closeReject,
closeRevoke,
} = controller;
return (
<>
<RejectDialog
open={rejectTarget != null}
amount={rejectTarget?.requestedNTE}
woNumber={woFallbackNumber(rejectTarget)}
onClose={closeReject}
onConfirm={handleRejectConfirm}
isPending={rejectPending}
/>
<RevokeDialog
open={revokeTarget != null}
amount={revokeTarget?.requestedNTE}
woNumber={woFallbackNumber(revokeTarget)}
onClose={closeRevoke}
onConfirm={handleRevokeConfirm}
isPending={revokePending}
/>
</>
);
}

View file

@ -19,6 +19,8 @@ import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
import { getWaitTimeTextClass, timeSince, waitTimeColor } from "@/lib/time-utils"; import { getWaitTimeTextClass, timeSince, waitTimeColor } from "@/lib/time-utils";
const CLOSED_WO_TOOLTIP = "This work order is closed. Uplifts can no longer be revoked."; 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";
const UNAVAILABLE_LABEL = "Unavailable";
type ModalCallbacks = { type ModalCallbacks = {
onClose: () => void; onClose: () => void;
@ -81,7 +83,16 @@ function UpliftAttachments({
); );
} }
function WorkOrderSection({ item, info }: { item: UpliftQueueItem; info?: WorkOrderTableRow }) { function WorkOrderSection({
item,
info,
unavailable,
}: {
item: UpliftQueueItem;
info?: WorkOrderTableRow;
unavailable: boolean;
}) {
const statusFallback = unavailable ? UNAVAILABLE_LABEL : "—";
return ( return (
<DetailSection title="Work order"> <DetailSection title="Work order">
<DetailField label="Work order #"> <DetailField label="Work order #">
@ -91,10 +102,12 @@ function WorkOrderSection({ item, info }: { item: UpliftQueueItem; info?: WorkOr
<Text variant="body">{info?.site || item.site || "—"}</Text> <Text variant="body">{info?.site || item.site || "—"}</Text>
</DetailField> </DetailField>
<DetailField label="Status"> <DetailField label="Status">
<Text variant="body">{info?.status || "—"}</Text> <Text variant="body">{info?.status || statusFallback}</Text>
</DetailField> </DetailField>
<DetailField label="Scheduled"> <DetailField label="Scheduled">
<Text variant="body">{info?.scheduledOn || "Unscheduled"}</Text> <Text variant="body">
{info?.scheduledOn || (unavailable ? UNAVAILABLE_LABEL : "Unscheduled")}
</Text>
</DetailField> </DetailField>
<DetailField label="Vendor"> <DetailField label="Vendor">
<Text variant="body">{item.vendorCompanyName || "—"}</Text> <Text variant="body">{item.vendorCompanyName || "—"}</Text>
@ -142,10 +155,12 @@ function ApprovedOnWoBreakdown({
auto, auto,
admin, admin,
total, total,
unavailable,
}: { }: {
auto: number | null; auto: number | null;
admin: number | null; admin: number | null;
total: number | null; total: number | null;
unavailable: boolean;
}) { }) {
const rows: Array<{ label: string; value: number | null; strong: boolean }> = [ const rows: Array<{ label: string; value: number | null; strong: boolean }> = [
{ label: "Auto-approved", value: auto, strong: false }, { label: "Auto-approved", value: auto, strong: false },
@ -155,7 +170,7 @@ function ApprovedOnWoBreakdown({
return ( return (
<Box sx={{ display: "grid", gap: 0.5 }}> <Box sx={{ display: "grid", gap: 0.5 }}>
{rows.map((row) => ( {rows.map((row) => (
<BreakdownRow key={row.label} {...row} /> <BreakdownRow key={row.label} unavailable={unavailable} {...row} />
))} ))}
</Box> </Box>
); );
@ -165,10 +180,12 @@ function BreakdownRow({
label, label,
value, value,
strong, strong,
unavailable,
}: { }: {
label: string; label: string;
value: number | null; value: number | null;
strong: boolean; strong: boolean;
unavailable: boolean;
}) { }) {
return ( return (
<Box sx={{ display: "flex", justifyContent: "space-between" }}> <Box sx={{ display: "flex", justifyContent: "space-between" }}>
@ -183,7 +200,11 @@ function BreakdownRow({
variant={strong ? "body" : "description"} variant={strong ? "body" : "description"}
className={strong ? "font-medium" : undefined} className={strong ? "font-medium" : undefined}
> >
{value != null ? formatUpliftMoney(value) : "—"} {value != null
? formatUpliftMoney(value)
: unavailable
? UNAVAILABLE_LABEL
: "—"}
</Text> </Text>
</Box> </Box>
); );
@ -236,6 +257,7 @@ function PendingModalActions({
function UpliftModalActions({ function UpliftModalActions({
item, item,
closed, closed,
canRevoke,
approvePending, approvePending,
revokePending, revokePending,
onApprove, onApprove,
@ -244,12 +266,18 @@ function UpliftModalActions({
}: { }: {
item: UpliftQueueItem; item: UpliftQueueItem;
closed: boolean; closed: boolean;
canRevoke: boolean;
approvePending: boolean; approvePending: boolean;
revokePending: boolean; revokePending: boolean;
onApprove: (row: UpliftQueueItem) => void; onApprove: (row: UpliftQueueItem) => void;
onReject: (row: UpliftQueueItem) => void; onReject: (row: UpliftQueueItem) => void;
onRevoke: (row: UpliftQueueItem) => void; onRevoke: (row: UpliftQueueItem) => void;
}) { }) {
const revokeTooltip = !canRevoke
? ADMIN_ONLY_REVOKE_TOOLTIP
: closed
? CLOSED_WO_TOOLTIP
: "";
return ( return (
<> <>
{item.status === "Pending" && ( {item.status === "Pending" && (
@ -261,13 +289,13 @@ function UpliftModalActions({
/> />
)} )}
{item.status === "Approved" && ( {item.status === "Approved" && (
<Tooltip title={closed ? CLOSED_WO_TOOLTIP : ""}> <Tooltip title={revokeTooltip}>
<span> <span>
<Button <Button
size="small" size="small"
variant="outlined" variant="outlined"
color="error" color="error"
disabled={closed || item.workOrderId == null || revokePending} disabled={!canRevoke || closed || item.workOrderId == null || revokePending}
onClick={() => onRevoke(item)} onClick={() => onRevoke(item)}
> >
Revoke Revoke
@ -281,6 +309,7 @@ function UpliftModalActions({
export function UpliftDetailModal({ export function UpliftDetailModal({
item, item,
canRevoke,
approvePending, approvePending,
revokePending, revokePending,
onClose, onClose,
@ -290,15 +319,22 @@ export function UpliftDetailModal({
onOpenAttachment, onOpenAttachment,
}: { }: {
item: UpliftQueueItem | null; item: UpliftQueueItem | null;
canRevoke: boolean;
approvePending: boolean; approvePending: boolean;
revokePending: boolean; revokePending: boolean;
} & ModalCallbacks) { } & ModalCallbacks) {
const open = item != null; const open = item != null;
// Permission failures (e.g. 403 for account-scoped users) are handled quietly
// in this modal: dependent sections fall back to "Unavailable" instead of toasting.
const quietMeta = { suppressErrorToast: true } as const;
const boardDetail = useWorkOrderBoardDetail( const boardDetail = useWorkOrderBoardDetail(
item != null && item.workOrderId != null ? item.workOrderId : undefined, item != null && item.workOrderId != null ? item.workOrderId : undefined,
true,
quietMeta,
); );
const woUpliftsQuery = useWorkOrderUplifts( const woUpliftsQuery = useWorkOrderUplifts(
item != null && item.workOrderId != null ? item.workOrderId : null, item != null && item.workOrderId != null ? item.workOrderId : null,
quietMeta,
); );
const info = boardDetail.data?.info; const info = boardDetail.data?.info;
@ -337,20 +373,27 @@ export function UpliftDetailModal({
aria-label="Uplift request details" aria-label="Uplift request details"
> >
<DialogTitle> <DialogTitle>
<Text variant="title">Uplift request</Text> <Text variant="title" as="div">
Uplift request
</Text>
<Text variant="description" tone="muted"> <Text variant="description" tone="muted">
{item.woNumber || item.dispatchNumber || "Work order"} · {item.status} {item.woNumber || item.dispatchNumber || "Work order"} · {item.status}
</Text> </Text>
</DialogTitle> </DialogTitle>
<DialogContent> <DialogContent>
<Box sx={{ display: "grid", gap: 3 }}> <Box sx={{ display: "grid", gap: 3 }}>
<WorkOrderSection item={item} info={info} /> <WorkOrderSection
item={item}
info={info}
unavailable={boardDetail.isError}
/>
<RequestSection item={item} onOpenAttachment={onOpenAttachment} /> <RequestSection item={item} onOpenAttachment={onOpenAttachment} />
<DetailSection title="Approved on work order"> <DetailSection title="Approved on work order">
<ApprovedOnWoBreakdown <ApprovedOnWoBreakdown
auto={breakdown.auto} auto={breakdown.auto}
admin={breakdown.admin} admin={breakdown.admin}
total={breakdown.total} total={breakdown.total}
unavailable={woUpliftsQuery.isError}
/> />
</DetailSection> </DetailSection>
</Box> </Box>
@ -362,6 +405,7 @@ export function UpliftDetailModal({
<UpliftModalActions <UpliftModalActions
item={item} item={item}
closed={closed} closed={closed}
canRevoke={canRevoke}
approvePending={approvePending} approvePending={approvePending}
revokePending={revokePending} revokePending={revokePending}
onApprove={onApprove} onApprove={onApprove}

View file

@ -1,18 +1,17 @@
import { useState } from "react"; import { useState } from "react";
import { Alert, Box, Chip, Stack, Tab, Tabs, TablePagination } from "@mui/material"; import { Alert, Box, Chip, Stack, Tab, Tabs, TablePagination } from "@mui/material";
import { toast } from "react-toastify";
import { RejectDialog } from "@/app/(protected)/uplifts/_components/reject-dialog";
import { RevokeDialog } from "@/app/(protected)/uplifts/_components/revoke-dialog";
import { import {
UpliftApprovalsTable, UpliftApprovalsTable,
type UpliftApprovalTab, type UpliftApprovalTab,
} from "@/app/(protected)/uplifts/_components/uplift-approvals-table"; } 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 { UpliftDetailModal } from "@/app/(protected)/uplifts/_components/uplift-detail-modal";
import { openUpliftEvidence } from "@/app/(protected)/uplifts/_components/open-uplift-evidence";
import { formatUpliftMoney } from "@/app/(protected)/uplifts/_components/format-uplift-money"; import { formatUpliftMoney } from "@/app/(protected)/uplifts/_components/format-uplift-money";
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift"; import { isAdminUser } from "@/lib/auth/user-utils";
import { useAuthContext } from "@/providers/auth-context";
import { useUpliftApprovalController } from "@/domain/uplifts/use-cases/use-uplift-approval-controller"; import { useUpliftApprovalController } from "@/domain/uplifts/use-cases/use-uplift-approval-controller";
import { useUpliftsQueue } from "@/domain/uplifts/use-cases/use-uplifts-queue"; import { useUpliftsQueue } from "@/domain/uplifts/use-cases/use-uplifts-queue";
import { upliftsApi } from "@/domain/uplifts/api/uplifts-api";
import { Text } from "@/components/ui/text"; import { Text } from "@/components/ui/text";
const PAGE_SIZE = 25; const PAGE_SIZE = 25;
@ -109,6 +108,9 @@ export default function UpliftQueuePage() {
const [pendingPage, setPendingPage] = useState(0); const [pendingPage, setPendingPage] = useState(0);
const [approvedPage, setApprovedPage] = useState(0); const [approvedPage, setApprovedPage] = useState(0);
const { user } = useAuthContext();
const canRevokeUplifts = isAdminUser(user?.userRoles);
const pendingQuery = useUpliftsQueue({ const pendingQuery = useUpliftsQueue({
status: "Pending", status: "Pending",
page: pendingPage + 1, page: pendingPage + 1,
@ -120,23 +122,18 @@ export default function UpliftQueuePage() {
pageSize: PAGE_SIZE, pageSize: PAGE_SIZE,
}); });
const controller = useUpliftApprovalController();
const { const {
detailItem, detailItem,
rejectTarget,
revokeTarget,
approvePending, approvePending,
rejectPending, rejectPending,
revokePending, revokePending,
handleApprove, handleApprove,
handleRejectRequest, handleRejectRequest,
handleRevokeRequest, handleRevokeRequest,
handleRejectConfirm,
handleRevokeConfirm,
closeDetail, closeDetail,
closeReject,
closeRevoke,
openDetail, openDetail,
} = useUpliftApprovalController(); } = controller;
const activeQuery = tab === "pending" ? pendingQuery : approvedQuery; const activeQuery = tab === "pending" ? pendingQuery : approvedQuery;
const rows = activeQuery.data?.items ?? []; const rows = activeQuery.data?.items ?? [];
@ -149,16 +146,6 @@ export default function UpliftQueuePage() {
} }
}; };
const handleOpenAttachment = (row: UpliftQueueItem) => {
void upliftsApi
.downloadEvidence(row.id, row.evidenceFileName || "uplift-evidence")
.catch((error: unknown) => {
toast.error(
error instanceof Error ? error.message : "Unable to download evidence right now.",
);
});
};
return ( return (
<Box className="flex flex-col gap-4 p-4"> <Box className="flex flex-col gap-4 p-4">
<UpliftApprovalsHeader pendingExposureTotal={pendingExposureTotal} /> <UpliftApprovalsHeader pendingExposureTotal={pendingExposureTotal} />
@ -168,11 +155,12 @@ export default function UpliftQueuePage() {
tab={tab} tab={tab}
isLoading={activeQuery.isLoading} isLoading={activeQuery.isLoading}
rows={rows} rows={rows}
canRevoke={canRevokeUplifts}
onOpenDetail={openDetail} onOpenDetail={openDetail}
onApprove={handleApprove} onApprove={handleApprove}
onReject={handleRejectRequest} onReject={handleRejectRequest}
onRevoke={handleRevokeRequest} onRevoke={handleRevokeRequest}
onOpenAttachment={handleOpenAttachment} onOpenAttachment={openUpliftEvidence}
isDecisionPending={approvePending || rejectPending || revokePending} isDecisionPending={approvePending || rejectPending || revokePending}
/> />
<UpliftApprovalsPagination <UpliftApprovalsPagination
@ -186,31 +174,17 @@ export default function UpliftQueuePage() {
{detailItem != null && ( {detailItem != null && (
<UpliftDetailModal <UpliftDetailModal
item={detailItem} item={detailItem}
canRevoke={canRevokeUplifts}
approvePending={approvePending} approvePending={approvePending}
revokePending={revokePending} revokePending={revokePending}
onClose={closeDetail} onClose={closeDetail}
onApprove={handleApprove} onApprove={handleApprove}
onReject={handleRejectRequest} onReject={handleRejectRequest}
onRevoke={handleRevokeRequest} onRevoke={handleRevokeRequest}
onOpenAttachment={handleOpenAttachment} onOpenAttachment={openUpliftEvidence}
/> />
)} )}
<RejectDialog <UpliftDecisionDialogs controller={controller} />
open={rejectTarget != null}
amount={rejectTarget?.requestedNTE}
woNumber={rejectTarget?.woNumber || String(rejectTarget?.workOrderId ?? "")}
onClose={closeReject}
onConfirm={handleRejectConfirm}
isPending={rejectPending}
/>
<RevokeDialog
open={revokeTarget != null}
amount={revokeTarget?.requestedNTE}
woNumber={revokeTarget?.woNumber || String(revokeTarget?.workOrderId ?? "")}
onClose={closeRevoke}
onConfirm={handleRevokeConfirm}
isPending={revokePending}
/>
</Box> </Box>
); );
} }

View file

@ -27,11 +27,13 @@ export function useWorkOrderDetail(
export function useWorkOrderBoardDetail( export function useWorkOrderBoardDetail(
id: string | number | undefined, id: string | number | undefined,
enabled = true, enabled = true,
meta?: { suppressErrorToast?: boolean },
): UseQueryResult<WorkOrderBoardDetail, Error> { ): UseQueryResult<WorkOrderBoardDetail, Error> {
return useQuery({ return useQuery({
queryKey: queryKeys.workOrders.boardDetail(id ?? ""), queryKey: queryKeys.workOrders.boardDetail(id ?? ""),
queryFn: () => workOrdersApi.getBoardDetail(requireQueryParam(id, "Work order id")), queryFn: () => workOrdersApi.getBoardDetail(requireQueryParam(id, "Work order id")),
enabled: enabled && id !== undefined && id !== "", enabled: enabled && id !== undefined && id !== "",
meta,
// Do not keepPreviousData — placeholder detail from another WO leaks into the slide-over. // Do not keepPreviousData — placeholder detail from another WO leaks into the slide-over.
}); });
} }

View file

@ -24,12 +24,14 @@ function invalidateUpliftQueries(
export function useWorkOrderUplifts( export function useWorkOrderUplifts(
workOrderId: string | number | null | undefined, workOrderId: string | number | null | undefined,
meta?: { suppressErrorToast?: boolean },
): UseQueryResult<WorkOrderUplift[], Error> { ): UseQueryResult<WorkOrderUplift[], Error> {
return useQuery({ return useQuery({
queryKey: queryKeys.workOrders.uplifts(workOrderId ?? ""), queryKey: queryKeys.workOrders.uplifts(workOrderId ?? ""),
queryFn: () => queryFn: () =>
workOrderUpliftsApi.list(requireQueryParam(workOrderId ?? undefined, "Work order id")), workOrderUpliftsApi.list(requireQueryParam(workOrderId ?? undefined, "Work order id")),
enabled: workOrderId != null && workOrderId !== "", enabled: workOrderId != null && workOrderId !== "",
meta,
}); });
} }

View file

@ -1,33 +1,39 @@
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query"; import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
export const queryClient = new QueryClient({ export function createAppQueryClient(): QueryClient {
queryCache: new QueryCache({ return new QueryClient({
onError: (error) => { queryCache: new QueryCache({
console.error("Query error:", error); onError: (error, query) => {
const message = if (query.meta?.suppressErrorToast) return;
error instanceof Error ? error.message : "An error occurred while fetching data";
toast.error(message);
},
}),
mutationCache: new MutationCache({
onError: (error, _variables, _context, mutation) => {
if (mutation.options.meta?.suppressErrorToast) return;
console.error("Mutation error:", error); console.error("Query error:", error);
const message = const message =
error instanceof Error ? error.message : "An error occurred while updating data"; error instanceof Error ? error.message : "An error occurred while fetching data";
toast.error(message); toast.error(message);
},
}),
mutationCache: new MutationCache({
onError: (error, _variables, _context, mutation) => {
if (mutation.options.meta?.suppressErrorToast) return;
console.error("Mutation error:", error);
const message =
error instanceof Error ? error.message : "An error occurred while updating data";
toast.error(message);
},
}),
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
retry: 1,
refetchOnWindowFocus: false,
},
}, },
}), });
defaultOptions: { }
queries: {
staleTime: 5 * 60 * 1000, export const queryClient = createAppQueryClient();
gcTime: 10 * 60 * 1000,
retry: 1,
refetchOnWindowFocus: false,
},
},
});
export default queryClient; export default queryClient;

View file

@ -8,6 +8,9 @@ const revokeMutate = vi.fn();
const queueState = vi.hoisted(() => ({ const queueState = vi.hoisted(() => ({
data: null as UpliftsQueueResult | null, data: null as UpliftsQueueResult | null,
})); }));
const authState = vi.hoisted(() => ({
userRoles: "Admin",
}));
const approvedItem: UpliftQueueItem = { const approvedItem: UpliftQueueItem = {
id: 77, id: 77,
@ -68,9 +71,15 @@ vi.mock("@/domain/uplifts/use-cases/use-uplift-actions", () => ({
useRevokeUplift: () => ({ isPending: false, mutate: revokeMutate }), useRevokeUplift: () => ({ isPending: false, mutate: revokeMutate }),
})); }));
vi.mock("@/providers/auth-context", async (importOriginal) => ({
...(await importOriginal<object>()),
useAuthContext: () => ({ user: { userRoles: authState.userRoles } }),
}));
describe("Uplift Approvals approved tab", () => { describe("Uplift Approvals approved tab", () => {
beforeEach(() => { beforeEach(() => {
revokeMutate.mockReset(); revokeMutate.mockReset();
authState.userRoles = "Admin";
queueState.data = { queueState.data = {
items: [approvedItem], items: [approvedItem],
totalCount: 1, totalCount: 1,
@ -121,6 +130,30 @@ describe("Uplift Approvals approved tab", () => {
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it("disables Revoke with the admin-only tooltip for a Dispatcher and never opens the dialog", async () => {
authState.userRoles = "Dispatcher";
queueState.data = {
items: [approvedOpenItem],
totalCount: 1,
page: 1,
pageSize: 25,
pendingExposureTotal: null,
};
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
renderWithProviders(<UpliftQueuePage />);
fireEvent.click(screen.getByRole("tab", { name: "Approved" }));
const revoke = await screen.findByRole("button", { name: "Revoke" });
expect(revoke).toBeDisabled();
fireEvent.mouseOver(revoke);
expect(await screen.findByText("Only admins can revoke uplifts")).toBeInTheDocument();
fireEvent.click(revoke);
expect(
screen.queryByRole("heading", { name: "Revoke this approval?" }),
).not.toBeInTheDocument();
expect(revokeMutate).not.toHaveBeenCalled();
});
it("shows the approved empty copy when nothing is archived", async () => { it("shows the approved empty copy when nothing is archived", async () => {
queueState.data = { queueState.data = {
items: [], items: [],

View file

@ -0,0 +1,132 @@
import { screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { UpliftDetailModal } from "@/app/(protected)/uplifts/_components/uplift-detail-modal";
import { ApiError } from "@/api/api-error";
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
import { createAppQueryClient } from "@/lib/query/query-client";
import { renderWithProviders } from "@/test/test-utils";
const toastMocks = vi.hoisted(() => ({ error: vi.fn() }));
const boardDetailMock = vi.hoisted(() => ({ calls: 0 }));
const woUpliftsMock = vi.hoisted(() => ({ calls: 0 }));
vi.mock("react-toastify", () => ({
toast: { error: toastMocks.error },
}));
vi.mock("@/domain/work-orders/api/work-orders-api", () => ({
workOrdersApi: {
getBoardDetail: vi.fn(() => {
boardDetailMock.calls += 1;
return Promise.reject(
new ApiError("You do not have permission to perform this action.", 403),
);
}),
},
}));
vi.mock("@/domain/work-orders/api/work-order-uplifts-api", () => ({
workOrderUpliftsApi: {
list: vi.fn(() => {
woUpliftsMock.calls += 1;
return Promise.reject(
new ApiError("You do not have permission to perform this action.", 403),
);
}),
},
}));
vi.mock("@/domain/auth/api/auth-api", async (importOriginal) => ({
...(await importOriginal<object>()),
getSession: vi.fn(() =>
Promise.resolve({
token: "t",
expiration: "",
email: "dispatch@seahaven.test",
userRoles: "Dispatcher",
phoneNumber: "",
fullname: "Dispatch User",
id: "u1",
}),
),
}));
const dispatcherItem: UpliftQueueItem = {
id: 41,
status: "Approved",
currentNTE: 100,
requestedNTE: 250,
delta: 150,
vendorReason: "",
requestedAt: "2026-01-15T10:00:00Z",
requestedByVendorName: "Gateway",
decidedAt: "2026-01-16T10:00:00Z",
decidedByName: "Admin User",
decisionNote: "",
requiredTier: 1,
canDecide: false,
expiresAt: "",
notificationStatus: "",
notificationError: "",
evidenceDocumentId: null,
evidenceFileName: "",
evidenceContentType: "",
evidenceSizeBytes: null,
dispatchNumber: "DSP-41",
poNumber: "PO-41",
vendorCompanyName: "Gateway Plumbing",
workOrderId: 99,
dispatchId: 7,
woNumber: "WO-99",
site: "Site A",
serviceName: "Plumbing repair",
attachmentCount: null,
approvedOnWoAuto: null,
approvedOnWoAdmin: null,
approvedOnWoTotal: null,
workOrderClosed: false,
};
function renderWithGovernedClient() {
const queryClient = createAppQueryClient();
queryClient.setDefaultOptions({ queries: { retry: false } });
return renderWithProviders(
<UpliftDetailModal
item={dispatcherItem}
canRevoke={false}
approvePending={false}
revokePending={false}
onClose={vi.fn()}
onApprove={vi.fn()}
onReject={vi.fn()}
onRevoke={vi.fn()}
onOpenAttachment={vi.fn()}
/>,
{ queryClient },
);
}
describe("UpliftDetailModal permission failures (Dispatcher 403)", () => {
beforeEach(() => {
toastMocks.error.mockReset();
boardDetailMock.calls = 0;
woUpliftsMock.calls = 0;
});
it("shows unavailable sections without an error toast and keeps Revoke admin-only", async () => {
renderWithGovernedClient();
await waitFor(() => {
expect(boardDetailMock.calls).toBeGreaterThan(0);
expect(woUpliftsMock.calls).toBeGreaterThan(0);
});
await waitFor(() => {
expect(screen.getAllByText("Unavailable").length).toBeGreaterThanOrEqual(3);
});
const revoke = screen.getByRole("button", { name: "Revoke" });
expect(revoke).toBeDisabled();
expect(toastMocks.error).not.toHaveBeenCalled();
});
});

View file

@ -51,7 +51,7 @@ const baseItem: UpliftQueueItem = {
workOrderClosed: null, workOrderClosed: null,
}; };
function renderModal(overrides: Partial<UpliftQueueItem> = {}) { function renderModal(overrides: Partial<UpliftQueueItem> = {}, canRevoke = true) {
const onApprove = vi.fn(); const onApprove = vi.fn();
const onReject = vi.fn(); const onReject = vi.fn();
const onRevoke = vi.fn(); const onRevoke = vi.fn();
@ -59,6 +59,7 @@ function renderModal(overrides: Partial<UpliftQueueItem> = {}) {
renderWithProviders( renderWithProviders(
<UpliftDetailModal <UpliftDetailModal
item={{ ...baseItem, ...overrides }} item={{ ...baseItem, ...overrides }}
canRevoke={canRevoke}
approvePending={false} approvePending={false}
revokePending={false} revokePending={false}
onClose={vi.fn()} onClose={vi.fn()}
@ -133,4 +134,27 @@ describe("UpliftDetailModal", () => {
fireEvent.click(revoke); fireEvent.click(revoke);
expect(onRevoke).not.toHaveBeenCalled(); expect(onRevoke).not.toHaveBeenCalled();
}); });
it("disables Revoke with the admin-only tooltip for non-admin users", async () => {
const { onRevoke } = renderModal({ status: "Approved", workOrderClosed: false }, false);
const revoke = screen.getByRole("button", { name: "Revoke" });
expect(revoke).toBeDisabled();
fireEvent.mouseOver(revoke);
expect(await screen.findByText("Only admins can revoke uplifts")).toBeInTheDocument();
fireEvent.click(revoke);
expect(onRevoke).not.toHaveBeenCalled();
});
it("does not render a heading nested inside the dialog title heading", () => {
renderModal();
const dialog = screen.getByRole("dialog");
const headings = dialog.querySelectorAll("h1, h2, h3, h4, h5, h6");
expect(headings.length).toBeGreaterThan(0);
const nestedHeadings = [...headings].filter((heading) =>
heading.querySelector("h1, h2, h3, h4, h5, h6"),
);
expect(nestedHeadings).toEqual([]);
});
}); });

View file

@ -64,6 +64,11 @@ vi.mock("@/domain/uplifts/use-cases/use-uplift-actions", () => ({
useRevokeUplift: () => ({ isPending: false, mutate: revokeMutate }), useRevokeUplift: () => ({ isPending: false, mutate: revokeMutate }),
})); }));
vi.mock("@/providers/auth-context", async (importOriginal) => ({
...(await importOriginal<object>()),
useAuthContext: () => ({ user: { userRoles: "Admin" } }),
}));
describe("Uplift Approvals decision flow", () => { describe("Uplift Approvals decision flow", () => {
beforeEach(() => { beforeEach(() => {
rejectMutate.mockReset(); rejectMutate.mockReset();

View file

@ -0,0 +1,42 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createAppQueryClient } from "@/lib/query/query-client";
const toastMocks = vi.hoisted(() => ({ error: vi.fn() }));
vi.mock("react-toastify", () => ({
toast: { error: toastMocks.error },
}));
describe("createAppQueryClient query cache", () => {
beforeEach(() => {
toastMocks.error.mockReset();
});
it("does not toast for queries that suppress the error toast", async () => {
const queryClient = createAppQueryClient();
await expect(
queryClient.fetchQuery({
queryKey: ["suppressed-403"],
queryFn: () =>
Promise.reject(new Error("You do not have permission to perform this action.")),
meta: { suppressErrorToast: true },
}),
).rejects.toThrow("You do not have permission");
expect(toastMocks.error).not.toHaveBeenCalled();
});
it("still toasts for queries without suppression", async () => {
const queryClient = createAppQueryClient();
await expect(
queryClient.fetchQuery({
queryKey: ["unsuppressed-failure"],
queryFn: () => Promise.reject(new Error("A server error occurred.")),
}),
).rejects.toThrow("A server error occurred.");
expect(toastMocks.error).toHaveBeenCalledWith("A server error occurred.");
});
});