mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-06 22:52:06 +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";
|
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}
|
||||||
|
|
|
||||||
|
|
@ -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";
|
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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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: [],
|
||||||
|
|
|
||||||
|
|
@ -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,
|
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([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
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