mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-02 17:53:12 +00:00
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:
parent
6a2b9eb80c
commit
4bffdac175
13 changed files with 412 additions and 79 deletions
|
|
@ -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.",
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ import { formatDateTime, getWaitTimeTextClass, timeSince, waitTimeColor } from "
|
|||
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";
|
||||
|
||||
function workOrderLabel(row: UpliftQueueItem): string {
|
||||
return row.woNumber || row.dispatchNumber || "—";
|
||||
|
|
@ -119,23 +120,26 @@ function PendingRowActions({
|
|||
|
||||
function ApprovedRowActions({
|
||||
row,
|
||||
canRevoke,
|
||||
onRevoke,
|
||||
isDecisionPending,
|
||||
}: {
|
||||
row: UpliftQueueItem;
|
||||
canRevoke: boolean;
|
||||
onRevoke: (row: UpliftQueueItem) => void;
|
||||
isDecisionPending: boolean;
|
||||
}) {
|
||||
const closed = row.workOrderClosed === true;
|
||||
const tooltip = !canRevoke ? ADMIN_ONLY_REVOKE_TOOLTIP : closed ? CLOSED_WO_TOOLTIP : "";
|
||||
return (
|
||||
<Box sx={{ display: "flex", gap: 1 }} onClick={(e) => e.stopPropagation()}>
|
||||
<Tooltip title={closed ? CLOSED_WO_TOOLTIP : ""}>
|
||||
<Tooltip title={tooltip}>
|
||||
<span>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="error"
|
||||
disabled={isDecisionPending || closed || row.workOrderId == null}
|
||||
disabled={!canRevoke || isDecisionPending || closed || row.workOrderId == null}
|
||||
onClick={() => onRevoke(row)}
|
||||
>
|
||||
Revoke
|
||||
|
|
@ -149,6 +153,7 @@ function ApprovedRowActions({
|
|||
function UpliftApprovalRow({
|
||||
row,
|
||||
tab,
|
||||
canRevoke,
|
||||
onOpenDetail,
|
||||
onApprove,
|
||||
onReject,
|
||||
|
|
@ -158,6 +163,7 @@ function UpliftApprovalRow({
|
|||
}: {
|
||||
row: UpliftQueueItem;
|
||||
tab: UpliftApprovalTab;
|
||||
canRevoke: boolean;
|
||||
onOpenDetail: (row: UpliftQueueItem) => void;
|
||||
onApprove: (row: UpliftQueueItem) => void;
|
||||
onReject: (row: UpliftQueueItem) => void;
|
||||
|
|
@ -242,7 +248,12 @@ function UpliftApprovalRow({
|
|||
isDecisionPending={isDecisionPending}
|
||||
/>
|
||||
) : (
|
||||
<ApprovedRowActions row={row} onRevoke={onRevoke} isDecisionPending={isDecisionPending} />
|
||||
<ApprovedRowActions
|
||||
row={row}
|
||||
canRevoke={canRevoke}
|
||||
onRevoke={onRevoke}
|
||||
isDecisionPending={isDecisionPending}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
|
@ -278,6 +289,7 @@ export function UpliftApprovalsTable({
|
|||
tab,
|
||||
isLoading,
|
||||
rows,
|
||||
canRevoke,
|
||||
onOpenDetail,
|
||||
onApprove,
|
||||
onReject,
|
||||
|
|
@ -288,6 +300,7 @@ export function UpliftApprovalsTable({
|
|||
tab: UpliftApprovalTab;
|
||||
isLoading: boolean;
|
||||
rows: UpliftQueueItem[];
|
||||
canRevoke: boolean;
|
||||
onOpenDetail: (row: UpliftQueueItem) => void;
|
||||
onApprove: (row: UpliftQueueItem) => void;
|
||||
onReject: (row: UpliftQueueItem) => void;
|
||||
|
|
@ -338,6 +351,7 @@ export function UpliftApprovalsTable({
|
|||
key={String(row.id)}
|
||||
row={row}
|
||||
tab={tab}
|
||||
canRevoke={canRevoke}
|
||||
onOpenDetail={onOpenDetail}
|
||||
onApprove={onApprove}
|
||||
onReject={onReject}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -19,6 +19,8 @@ import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
|
|||
import { getWaitTimeTextClass, timeSince, waitTimeColor } from "@/lib/time-utils";
|
||||
|
||||
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 = {
|
||||
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 (
|
||||
<DetailSection title="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>
|
||||
</DetailField>
|
||||
<DetailField label="Status">
|
||||
<Text variant="body">{info?.status || "—"}</Text>
|
||||
<Text variant="body">{info?.status || statusFallback}</Text>
|
||||
</DetailField>
|
||||
<DetailField label="Scheduled">
|
||||
<Text variant="body">{info?.scheduledOn || "Unscheduled"}</Text>
|
||||
<Text variant="body">
|
||||
{info?.scheduledOn || (unavailable ? UNAVAILABLE_LABEL : "Unscheduled")}
|
||||
</Text>
|
||||
</DetailField>
|
||||
<DetailField label="Vendor">
|
||||
<Text variant="body">{item.vendorCompanyName || "—"}</Text>
|
||||
|
|
@ -142,10 +155,12 @@ function ApprovedOnWoBreakdown({
|
|||
auto,
|
||||
admin,
|
||||
total,
|
||||
unavailable,
|
||||
}: {
|
||||
auto: number | null;
|
||||
admin: number | null;
|
||||
total: number | null;
|
||||
unavailable: boolean;
|
||||
}) {
|
||||
const rows: Array<{ label: string; value: number | null; strong: boolean }> = [
|
||||
{ label: "Auto-approved", value: auto, strong: false },
|
||||
|
|
@ -155,7 +170,7 @@ function ApprovedOnWoBreakdown({
|
|||
return (
|
||||
<Box sx={{ display: "grid", gap: 0.5 }}>
|
||||
{rows.map((row) => (
|
||||
<BreakdownRow key={row.label} {...row} />
|
||||
<BreakdownRow key={row.label} unavailable={unavailable} {...row} />
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
|
|
@ -165,10 +180,12 @@ function BreakdownRow({
|
|||
label,
|
||||
value,
|
||||
strong,
|
||||
unavailable,
|
||||
}: {
|
||||
label: string;
|
||||
value: number | null;
|
||||
strong: boolean;
|
||||
unavailable: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between" }}>
|
||||
|
|
@ -183,7 +200,11 @@ function BreakdownRow({
|
|||
variant={strong ? "body" : "description"}
|
||||
className={strong ? "font-medium" : undefined}
|
||||
>
|
||||
{value != null ? formatUpliftMoney(value) : "—"}
|
||||
{value != null
|
||||
? formatUpliftMoney(value)
|
||||
: unavailable
|
||||
? UNAVAILABLE_LABEL
|
||||
: "—"}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
|
@ -236,6 +257,7 @@ function PendingModalActions({
|
|||
function UpliftModalActions({
|
||||
item,
|
||||
closed,
|
||||
canRevoke,
|
||||
approvePending,
|
||||
revokePending,
|
||||
onApprove,
|
||||
|
|
@ -244,12 +266,18 @@ function UpliftModalActions({
|
|||
}: {
|
||||
item: UpliftQueueItem;
|
||||
closed: boolean;
|
||||
canRevoke: boolean;
|
||||
approvePending: boolean;
|
||||
revokePending: boolean;
|
||||
onApprove: (row: UpliftQueueItem) => void;
|
||||
onReject: (row: UpliftQueueItem) => void;
|
||||
onRevoke: (row: UpliftQueueItem) => void;
|
||||
}) {
|
||||
const revokeTooltip = !canRevoke
|
||||
? ADMIN_ONLY_REVOKE_TOOLTIP
|
||||
: closed
|
||||
? CLOSED_WO_TOOLTIP
|
||||
: "";
|
||||
return (
|
||||
<>
|
||||
{item.status === "Pending" && (
|
||||
|
|
@ -261,13 +289,13 @@ function UpliftModalActions({
|
|||
/>
|
||||
)}
|
||||
{item.status === "Approved" && (
|
||||
<Tooltip title={closed ? CLOSED_WO_TOOLTIP : ""}>
|
||||
<Tooltip title={revokeTooltip}>
|
||||
<span>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="error"
|
||||
disabled={closed || item.workOrderId == null || revokePending}
|
||||
disabled={!canRevoke || closed || item.workOrderId == null || revokePending}
|
||||
onClick={() => onRevoke(item)}
|
||||
>
|
||||
Revoke
|
||||
|
|
@ -281,6 +309,7 @@ function UpliftModalActions({
|
|||
|
||||
export function UpliftDetailModal({
|
||||
item,
|
||||
canRevoke,
|
||||
approvePending,
|
||||
revokePending,
|
||||
onClose,
|
||||
|
|
@ -290,15 +319,22 @@ export function UpliftDetailModal({
|
|||
onOpenAttachment,
|
||||
}: {
|
||||
item: UpliftQueueItem | null;
|
||||
canRevoke: boolean;
|
||||
approvePending: boolean;
|
||||
revokePending: boolean;
|
||||
} & ModalCallbacks) {
|
||||
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(
|
||||
item != null && item.workOrderId != null ? item.workOrderId : undefined,
|
||||
true,
|
||||
quietMeta,
|
||||
);
|
||||
const woUpliftsQuery = useWorkOrderUplifts(
|
||||
item != null && item.workOrderId != null ? item.workOrderId : null,
|
||||
quietMeta,
|
||||
);
|
||||
|
||||
const info = boardDetail.data?.info;
|
||||
|
|
@ -337,20 +373,27 @@ export function UpliftDetailModal({
|
|||
aria-label="Uplift request details"
|
||||
>
|
||||
<DialogTitle>
|
||||
<Text variant="title">Uplift request</Text>
|
||||
<Text variant="title" as="div">
|
||||
Uplift request
|
||||
</Text>
|
||||
<Text variant="description" tone="muted">
|
||||
{item.woNumber || item.dispatchNumber || "Work order"} · {item.status}
|
||||
</Text>
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ display: "grid", gap: 3 }}>
|
||||
<WorkOrderSection item={item} info={info} />
|
||||
<WorkOrderSection
|
||||
item={item}
|
||||
info={info}
|
||||
unavailable={boardDetail.isError}
|
||||
/>
|
||||
<RequestSection item={item} onOpenAttachment={onOpenAttachment} />
|
||||
<DetailSection title="Approved on work order">
|
||||
<ApprovedOnWoBreakdown
|
||||
auto={breakdown.auto}
|
||||
admin={breakdown.admin}
|
||||
total={breakdown.total}
|
||||
unavailable={woUpliftsQuery.isError}
|
||||
/>
|
||||
</DetailSection>
|
||||
</Box>
|
||||
|
|
@ -362,6 +405,7 @@ export function UpliftDetailModal({
|
|||
<UpliftModalActions
|
||||
item={item}
|
||||
closed={closed}
|
||||
canRevoke={canRevoke}
|
||||
approvePending={approvePending}
|
||||
revokePending={revokePending}
|
||||
onApprove={onApprove}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,17 @@
|
|||
import { useState } from "react";
|
||||
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 {
|
||||
UpliftApprovalsTable,
|
||||
type UpliftApprovalTab,
|
||||
} 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 { openUpliftEvidence } from "@/app/(protected)/uplifts/_components/open-uplift-evidence";
|
||||
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 { useUpliftsQueue } from "@/domain/uplifts/use-cases/use-uplifts-queue";
|
||||
import { upliftsApi } from "@/domain/uplifts/api/uplifts-api";
|
||||
import { Text } from "@/components/ui/text";
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
|
@ -109,6 +108,9 @@ export default function UpliftQueuePage() {
|
|||
const [pendingPage, setPendingPage] = useState(0);
|
||||
const [approvedPage, setApprovedPage] = useState(0);
|
||||
|
||||
const { user } = useAuthContext();
|
||||
const canRevokeUplifts = isAdminUser(user?.userRoles);
|
||||
|
||||
const pendingQuery = useUpliftsQueue({
|
||||
status: "Pending",
|
||||
page: pendingPage + 1,
|
||||
|
|
@ -120,23 +122,18 @@ export default function UpliftQueuePage() {
|
|||
pageSize: PAGE_SIZE,
|
||||
});
|
||||
|
||||
const controller = useUpliftApprovalController();
|
||||
const {
|
||||
detailItem,
|
||||
rejectTarget,
|
||||
revokeTarget,
|
||||
approvePending,
|
||||
rejectPending,
|
||||
revokePending,
|
||||
handleApprove,
|
||||
handleRejectRequest,
|
||||
handleRevokeRequest,
|
||||
handleRejectConfirm,
|
||||
handleRevokeConfirm,
|
||||
closeDetail,
|
||||
closeReject,
|
||||
closeRevoke,
|
||||
openDetail,
|
||||
} = useUpliftApprovalController();
|
||||
} = controller;
|
||||
|
||||
const activeQuery = tab === "pending" ? pendingQuery : approvedQuery;
|
||||
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 (
|
||||
<Box className="flex flex-col gap-4 p-4">
|
||||
<UpliftApprovalsHeader pendingExposureTotal={pendingExposureTotal} />
|
||||
|
|
@ -168,11 +155,12 @@ export default function UpliftQueuePage() {
|
|||
tab={tab}
|
||||
isLoading={activeQuery.isLoading}
|
||||
rows={rows}
|
||||
canRevoke={canRevokeUplifts}
|
||||
onOpenDetail={openDetail}
|
||||
onApprove={handleApprove}
|
||||
onReject={handleRejectRequest}
|
||||
onRevoke={handleRevokeRequest}
|
||||
onOpenAttachment={handleOpenAttachment}
|
||||
onOpenAttachment={openUpliftEvidence}
|
||||
isDecisionPending={approvePending || rejectPending || revokePending}
|
||||
/>
|
||||
<UpliftApprovalsPagination
|
||||
|
|
@ -186,31 +174,17 @@ export default function UpliftQueuePage() {
|
|||
{detailItem != null && (
|
||||
<UpliftDetailModal
|
||||
item={detailItem}
|
||||
canRevoke={canRevokeUplifts}
|
||||
approvePending={approvePending}
|
||||
revokePending={revokePending}
|
||||
onClose={closeDetail}
|
||||
onApprove={handleApprove}
|
||||
onReject={handleRejectRequest}
|
||||
onRevoke={handleRevokeRequest}
|
||||
onOpenAttachment={handleOpenAttachment}
|
||||
onOpenAttachment={openUpliftEvidence}
|
||||
/>
|
||||
)}
|
||||
<RejectDialog
|
||||
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}
|
||||
/>
|
||||
<UpliftDecisionDialogs controller={controller} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,11 +27,13 @@ export function useWorkOrderDetail(
|
|||
export function useWorkOrderBoardDetail(
|
||||
id: string | number | undefined,
|
||||
enabled = true,
|
||||
meta?: { suppressErrorToast?: boolean },
|
||||
): UseQueryResult<WorkOrderBoardDetail, Error> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.workOrders.boardDetail(id ?? ""),
|
||||
queryFn: () => workOrdersApi.getBoardDetail(requireQueryParam(id, "Work order id")),
|
||||
enabled: enabled && id !== undefined && id !== "",
|
||||
meta,
|
||||
// Do not keepPreviousData — placeholder detail from another WO leaks into the slide-over.
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,12 +24,14 @@ function invalidateUpliftQueries(
|
|||
|
||||
export function useWorkOrderUplifts(
|
||||
workOrderId: string | number | null | undefined,
|
||||
meta?: { suppressErrorToast?: boolean },
|
||||
): UseQueryResult<WorkOrderUplift[], Error> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.workOrders.uplifts(workOrderId ?? ""),
|
||||
queryFn: () =>
|
||||
workOrderUpliftsApi.list(requireQueryParam(workOrderId ?? undefined, "Work order id")),
|
||||
enabled: workOrderId != null && workOrderId !== "",
|
||||
meta,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,33 +1,39 @@
|
|||
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
queryCache: new QueryCache({
|
||||
onError: (error) => {
|
||||
console.error("Query error:", error);
|
||||
const message =
|
||||
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;
|
||||
export function createAppQueryClient(): QueryClient {
|
||||
return new QueryClient({
|
||||
queryCache: new QueryCache({
|
||||
onError: (error, query) => {
|
||||
if (query.meta?.suppressErrorToast) return;
|
||||
|
||||
console.error("Mutation error:", error);
|
||||
const message =
|
||||
error instanceof Error ? error.message : "An error occurred while updating data";
|
||||
toast.error(message);
|
||||
console.error("Query error:", error);
|
||||
const message =
|
||||
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);
|
||||
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,
|
||||
gcTime: 10 * 60 * 1000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export const queryClient = createAppQueryClient();
|
||||
|
||||
export default queryClient;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ const revokeMutate = vi.fn();
|
|||
const queueState = vi.hoisted(() => ({
|
||||
data: null as UpliftsQueueResult | null,
|
||||
}));
|
||||
const authState = vi.hoisted(() => ({
|
||||
userRoles: "Admin",
|
||||
}));
|
||||
|
||||
const approvedItem: UpliftQueueItem = {
|
||||
id: 77,
|
||||
|
|
@ -68,9 +71,15 @@ vi.mock("@/domain/uplifts/use-cases/use-uplift-actions", () => ({
|
|||
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", () => {
|
||||
beforeEach(() => {
|
||||
revokeMutate.mockReset();
|
||||
authState.userRoles = "Admin";
|
||||
queueState.data = {
|
||||
items: [approvedItem],
|
||||
totalCount: 1,
|
||||
|
|
@ -121,6 +130,30 @@ describe("Uplift Approvals approved tab", () => {
|
|||
).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 () => {
|
||||
queueState.data = {
|
||||
items: [],
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -51,7 +51,7 @@ const baseItem: UpliftQueueItem = {
|
|||
workOrderClosed: null,
|
||||
};
|
||||
|
||||
function renderModal(overrides: Partial<UpliftQueueItem> = {}) {
|
||||
function renderModal(overrides: Partial<UpliftQueueItem> = {}, canRevoke = true) {
|
||||
const onApprove = vi.fn();
|
||||
const onReject = vi.fn();
|
||||
const onRevoke = vi.fn();
|
||||
|
|
@ -59,6 +59,7 @@ function renderModal(overrides: Partial<UpliftQueueItem> = {}) {
|
|||
renderWithProviders(
|
||||
<UpliftDetailModal
|
||||
item={{ ...baseItem, ...overrides }}
|
||||
canRevoke={canRevoke}
|
||||
approvePending={false}
|
||||
revokePending={false}
|
||||
onClose={vi.fn()}
|
||||
|
|
@ -133,4 +134,27 @@ describe("UpliftDetailModal", () => {
|
|||
fireEvent.click(revoke);
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -64,6 +64,11 @@ vi.mock("@/domain/uplifts/use-cases/use-uplift-actions", () => ({
|
|||
useRevokeUplift: () => ({ isPending: false, mutate: revokeMutate }),
|
||||
}));
|
||||
|
||||
vi.mock("@/providers/auth-context", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
useAuthContext: () => ({ user: { userRoles: "Admin" } }),
|
||||
}));
|
||||
|
||||
describe("Uplift Approvals decision flow", () => {
|
||||
beforeEach(() => {
|
||||
rejectMutate.mockReset();
|
||||
|
|
|
|||
42
src/test/lib/query/query-client.test.ts
Normal file
42
src/test/lib/query/query-client.test.ts
Normal 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.");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue