mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 06:53:12 +00:00
Merge branch 'dev' into fix/ab/sh-364-inline-technician-registration
This commit is contained in:
commit
65d55d7c9f
24 changed files with 2153 additions and 523 deletions
110
e2e/vendors/vendor-uplift-workflow.spec.ts
vendored
110
e2e/vendors/vendor-uplift-workflow.spec.ts
vendored
|
|
@ -268,7 +268,7 @@ test("vendor revises a changes-requested uplift using its existing evidence", as
|
|||
});
|
||||
});
|
||||
|
||||
test("internal approver can approve and request changes with audited notes", async ({ page }) => {
|
||||
test("internal approver can approve, reject, and revoke with audited notes", async ({ page }) => {
|
||||
const decisions: Array<{ path: string; body: unknown }> = [];
|
||||
const statuses = new Map<number, string>([
|
||||
[41, "Pending"],
|
||||
|
|
@ -284,73 +284,95 @@ test("internal approver can approve and request changes with audited notes", asy
|
|||
await page.route(/\/api\/uplifts(?:[/?]|$)/, async (route) => {
|
||||
const request = route.request();
|
||||
const path = new URL(request.url()).pathname;
|
||||
const action = path.match(/\/uplifts\/(41|42)\/(approve|request-changes)$/);
|
||||
const action = path.match(/\/uplifts\/(41|42)\/(approve|reject|revoke)$/);
|
||||
if (action && request.method() === "POST") {
|
||||
const id = Number(action[1]);
|
||||
statuses.set(id, action[2] === "approve" ? "Approved" : "ChangesRequested");
|
||||
statuses.set(
|
||||
id,
|
||||
action[2] === "approve" ? "Approved" : action[2] === "revoke" ? "Revoked" : "Rejected",
|
||||
);
|
||||
decisions.push({ path, body: request.postDataJSON() });
|
||||
await fulfillJson(route, { id, status: statuses.get(id) });
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith("/uplifts/can-approve")) {
|
||||
await fulfillJson(route, { canApprove: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const requestedStatus = new URL(request.url()).searchParams.get("status");
|
||||
const items = [
|
||||
{
|
||||
id: 41,
|
||||
dispatchId: 7,
|
||||
workOrderId: 501,
|
||||
dispatchNumber: "DSP-41",
|
||||
poNumber: "PO-41",
|
||||
vendorCompanyName: "Gateway Plumbing",
|
||||
currentNTE: 100,
|
||||
requestedNTE: 125,
|
||||
delta: 25,
|
||||
requiredTier: 1,
|
||||
status: statuses.get(41),
|
||||
canDecide: true,
|
||||
evidenceDocumentId: 22,
|
||||
evidenceFileName: "estimate.pdf",
|
||||
evidenceContentType: "application/pdf",
|
||||
evidenceSizeBytes: 2048,
|
||||
},
|
||||
{
|
||||
id: 42,
|
||||
dispatchId: 8,
|
||||
workOrderId: 502,
|
||||
dispatchNumber: "DSP-42",
|
||||
poNumber: "PO-42",
|
||||
vendorCompanyName: "Metro HVAC",
|
||||
currentNTE: 200,
|
||||
requestedNTE: 260,
|
||||
delta: 60,
|
||||
requiredTier: 2,
|
||||
status: statuses.get(42),
|
||||
canDecide: true,
|
||||
evidenceDocumentId: 23,
|
||||
evidenceFileName: "quote.pdf",
|
||||
},
|
||||
].filter((item) => requestedStatus == null || item.status === requestedStatus);
|
||||
|
||||
await fulfillJson(route, {
|
||||
total: 2,
|
||||
items: [
|
||||
{
|
||||
id: 41,
|
||||
dispatchId: 7,
|
||||
dispatchNumber: "DSP-41",
|
||||
poNumber: "PO-41",
|
||||
vendorCompanyName: "Gateway Plumbing",
|
||||
currentNTE: 100,
|
||||
requestedNTE: 125,
|
||||
delta: 25,
|
||||
requiredTier: 1,
|
||||
status: statuses.get(41),
|
||||
canDecide: true,
|
||||
evidenceDocumentId: 22,
|
||||
evidenceFileName: "estimate.pdf",
|
||||
evidenceContentType: "application/pdf",
|
||||
evidenceSizeBytes: 2048,
|
||||
},
|
||||
{
|
||||
id: 42,
|
||||
dispatchId: 8,
|
||||
dispatchNumber: "DSP-42",
|
||||
poNumber: "PO-42",
|
||||
vendorCompanyName: "Metro HVAC",
|
||||
currentNTE: 200,
|
||||
requestedNTE: 260,
|
||||
delta: 60,
|
||||
requiredTier: 2,
|
||||
status: statuses.get(42),
|
||||
canDecide: true,
|
||||
evidenceDocumentId: 23,
|
||||
evidenceFileName: "quote.pdf",
|
||||
},
|
||||
],
|
||||
total: items.length,
|
||||
items,
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/uplifts");
|
||||
await expect(page.getByRole("heading", { name: "Uplift Queue" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Uplift Approvals" })).toBeVisible();
|
||||
|
||||
const approvalRow = page.getByRole("row").filter({ hasText: "DSP-41" });
|
||||
await approvalRow.getByRole("button", { name: "Approve" }).click();
|
||||
await page.getByRole("button", { name: "Confirm" }).click();
|
||||
|
||||
const changesRow = page.getByRole("row").filter({ hasText: "DSP-42" });
|
||||
await changesRow.getByRole("button", { name: "Request Changes" }).click();
|
||||
await page.getByLabel("Note (required)").fill("Please itemize labor and parts.");
|
||||
await page.getByRole("button", { name: "Confirm" }).click();
|
||||
await changesRow.getByRole("button", { name: "Reject" }).click();
|
||||
await page.getByLabel("Reason for rejection").fill("Please itemize labor and parts.");
|
||||
await page.getByRole("button", { name: "Reject uplift" }).click();
|
||||
|
||||
await page.getByRole("tab", { name: "Approved" }).click();
|
||||
const approvedRow = page.getByRole("row").filter({ hasText: "DSP-41" });
|
||||
await approvedRow.getByRole("button", { name: "Revoke" }).click();
|
||||
await page.getByLabel("Reason for revoking").fill("Approval was made in error.");
|
||||
await page.getByRole("button", { name: "Revoke $125" }).click();
|
||||
|
||||
await expect
|
||||
.poll(() => decisions)
|
||||
.toEqual([
|
||||
{ path: "/api/uplifts/41/approve", body: {} },
|
||||
{
|
||||
path: "/api/uplifts/42/request-changes",
|
||||
path: "/api/uplifts/42/reject",
|
||||
body: { note: "Please itemize labor and parts." },
|
||||
},
|
||||
{
|
||||
path: "/api/uplifts/41/revoke",
|
||||
body: { note: "Approval was made in error." },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,371 @@
|
|||
import {
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Paper,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Tooltip,
|
||||
} from "@mui/material";
|
||||
import { formatUpliftMoney } from "@/app/(protected)/uplifts/_components/format-uplift-money";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { useUpliftsCanApprove } from "@/domain/uplifts/use-cases/use-uplifts-can-approve";
|
||||
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
|
||||
import { formatDateTime, getWaitTimeTextClass, timeSince, waitTimeColor } from "@/lib/time-utils";
|
||||
|
||||
export type UpliftApprovalTab = "pending" | "approved";
|
||||
|
||||
const CLOSED_WO_TOOLTIP = "This work order is closed. Uplifts can no longer be revoked.";
|
||||
const ADMIN_ONLY_REVOKE_TOOLTIP = "Only admins can revoke uplifts";
|
||||
|
||||
function workOrderLabel(row: UpliftQueueItem): string {
|
||||
return row.woNumber || row.dispatchNumber || "—";
|
||||
}
|
||||
|
||||
function WaitingCell({ row }: { row: UpliftQueueItem }) {
|
||||
if (!row.requestedAt) {
|
||||
return (
|
||||
<Text variant="caption" tone="muted">
|
||||
—
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
const color = waitTimeColor(row.requestedAt);
|
||||
const className = getWaitTimeTextClass(color);
|
||||
return (
|
||||
<Text variant="caption" className={className}>
|
||||
{timeSince(row.requestedAt)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
function AttachmentsCell({
|
||||
row,
|
||||
onOpenAttachment,
|
||||
}: {
|
||||
row: UpliftQueueItem;
|
||||
onOpenAttachment: (row: UpliftQueueItem) => void;
|
||||
}) {
|
||||
if (row.evidenceDocumentId == null && !row.evidenceFileName) {
|
||||
return (
|
||||
<Text variant="caption" tone="muted">
|
||||
—
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
const extraCount =
|
||||
typeof row.attachmentCount === "number" && row.attachmentCount > 1
|
||||
? row.attachmentCount - 1
|
||||
: 0;
|
||||
return (
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
|
||||
<Chip
|
||||
size="small"
|
||||
label={row.evidenceFileName || "Attachment"}
|
||||
onClick={row.evidenceDocumentId != null ? () => onOpenAttachment(row) : undefined}
|
||||
clickable={row.evidenceDocumentId != null}
|
||||
/>
|
||||
{extraCount > 0 && <Chip size="small" label={`+${extraCount}`} />}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function PendingRowActions({
|
||||
row,
|
||||
onApprove,
|
||||
onReject,
|
||||
isDecisionPending,
|
||||
}: {
|
||||
row: UpliftQueueItem;
|
||||
onApprove: (row: UpliftQueueItem) => void;
|
||||
onReject: (row: UpliftQueueItem) => void;
|
||||
isDecisionPending: boolean;
|
||||
}) {
|
||||
const canApproveTier = useUpliftsCanApprove(row.requiredTier);
|
||||
const canDecide = row.canDecide && canApproveTier.data === true;
|
||||
const tooltip = canDecide ? "" : `Requires Tier ${row.requiredTier} role`;
|
||||
return (
|
||||
<Box sx={{ display: "flex", gap: 1 }} onClick={(e) => e.stopPropagation()}>
|
||||
<Tooltip title={tooltip}>
|
||||
<span>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
color="success"
|
||||
disabled={!canDecide || isDecisionPending}
|
||||
onClick={() => onApprove(row)}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title={tooltip}>
|
||||
<span>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
color="error"
|
||||
disabled={!canDecide || isDecisionPending}
|
||||
onClick={() => onReject(row)}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
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={tooltip}>
|
||||
<span>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="error"
|
||||
disabled={!canRevoke || isDecisionPending || closed || row.workOrderId == null}
|
||||
onClick={() => onRevoke(row)}
|
||||
>
|
||||
Revoke
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function UpliftApprovalRow({
|
||||
row,
|
||||
tab,
|
||||
canRevoke,
|
||||
onOpenDetail,
|
||||
onApprove,
|
||||
onReject,
|
||||
onRevoke,
|
||||
onOpenAttachment,
|
||||
isDecisionPending,
|
||||
}: {
|
||||
row: UpliftQueueItem;
|
||||
tab: UpliftApprovalTab;
|
||||
canRevoke: boolean;
|
||||
onOpenDetail: (row: UpliftQueueItem) => void;
|
||||
onApprove: (row: UpliftQueueItem) => void;
|
||||
onReject: (row: UpliftQueueItem) => void;
|
||||
onRevoke: (row: UpliftQueueItem) => void;
|
||||
onOpenAttachment: (row: UpliftQueueItem) => void;
|
||||
isDecisionPending: boolean;
|
||||
}) {
|
||||
return (
|
||||
<TableRow
|
||||
hover
|
||||
sx={{ cursor: "pointer" }}
|
||||
onClick={() => onOpenDetail(row)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onOpenDetail(row);
|
||||
}}
|
||||
tabIndex={0}
|
||||
aria-label={`Open uplift details for ${workOrderLabel(row)}`}
|
||||
>
|
||||
<TableCell>
|
||||
<Box>
|
||||
<Text variant="body" className="font-medium">
|
||||
{workOrderLabel(row)}
|
||||
</Text>
|
||||
{Boolean(row.site) && (
|
||||
<Text variant="caption" tone="muted" className="block">
|
||||
{row.site}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Box>
|
||||
{Boolean(row.serviceName) && (
|
||||
<Text variant="body" className="block">
|
||||
{row.serviceName}
|
||||
</Text>
|
||||
)}
|
||||
<Text variant="caption" tone="muted">
|
||||
{row.vendorCompanyName || "—"}
|
||||
</Text>
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text variant="body" className="font-medium">
|
||||
{formatUpliftMoney(row.requestedNTE)}
|
||||
</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{row.approvedOnWoTotal != null ? (
|
||||
<Text variant="body">{formatUpliftMoney(row.approvedOnWoTotal)}</Text>
|
||||
) : (
|
||||
<Text variant="caption" tone="muted">
|
||||
—
|
||||
</Text>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell sx={{ maxWidth: 220 }}>
|
||||
<Text variant="caption" sx={{ whiteSpace: "pre-wrap" }}>
|
||||
{row.vendorReason}
|
||||
</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<AttachmentsCell row={row} onOpenAttachment={onOpenAttachment} />
|
||||
</TableCell>
|
||||
<TableCell>{row.requestedByVendorName || "—"}</TableCell>
|
||||
{tab === "pending" ? (
|
||||
<TableCell>
|
||||
<WaitingCell row={row} />
|
||||
</TableCell>
|
||||
) : (
|
||||
<>
|
||||
<TableCell>{row.decidedByName || "—"}</TableCell>
|
||||
<TableCell>{row.decidedAt ? formatDateTime(row.decidedAt) : "—"}</TableCell>
|
||||
</>
|
||||
)}
|
||||
<TableCell>
|
||||
{tab === "pending" ? (
|
||||
<PendingRowActions
|
||||
row={row}
|
||||
onApprove={onApprove}
|
||||
onReject={onReject}
|
||||
isDecisionPending={isDecisionPending}
|
||||
/>
|
||||
) : (
|
||||
<ApprovedRowActions
|
||||
row={row}
|
||||
canRevoke={canRevoke}
|
||||
onRevoke={onRevoke}
|
||||
isDecisionPending={isDecisionPending}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
const PENDING_HEADER_CELLS = [
|
||||
"Work Order",
|
||||
"Service / Vendor",
|
||||
"Amount",
|
||||
"Approved on WO",
|
||||
"Notes",
|
||||
"Attachments",
|
||||
"Requested By",
|
||||
"Waiting",
|
||||
"Actions",
|
||||
] as const;
|
||||
|
||||
const APPROVED_HEADER_CELLS = [
|
||||
"Work Order",
|
||||
"Service / Vendor",
|
||||
"Amount",
|
||||
"Approved on WO",
|
||||
"Notes",
|
||||
"Attachments",
|
||||
"Requested By",
|
||||
"Approved By",
|
||||
"Approved At",
|
||||
"Actions",
|
||||
] as const;
|
||||
|
||||
export function UpliftApprovalsTable({
|
||||
tab,
|
||||
isLoading,
|
||||
rows,
|
||||
canRevoke,
|
||||
onOpenDetail,
|
||||
onApprove,
|
||||
onReject,
|
||||
onRevoke,
|
||||
onOpenAttachment,
|
||||
isDecisionPending,
|
||||
}: {
|
||||
tab: UpliftApprovalTab;
|
||||
isLoading: boolean;
|
||||
rows: UpliftQueueItem[];
|
||||
canRevoke: boolean;
|
||||
onOpenDetail: (row: UpliftQueueItem) => void;
|
||||
onApprove: (row: UpliftQueueItem) => void;
|
||||
onReject: (row: UpliftQueueItem) => void;
|
||||
onRevoke: (row: UpliftQueueItem) => void;
|
||||
onOpenAttachment: (row: UpliftQueueItem) => void;
|
||||
isDecisionPending: boolean;
|
||||
}) {
|
||||
const headerCells = tab === "pending" ? PENDING_HEADER_CELLS : APPROVED_HEADER_CELLS;
|
||||
return (
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="small" aria-label={`${tab} uplift requests`}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{headerCells.map((label) => (
|
||||
<TableCell key={label} className="font-semibold">
|
||||
{label}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={headerCells.length} align="center" className="py-10">
|
||||
<CircularProgress size={28} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={headerCells.length} align="center" className="py-10">
|
||||
{tab === "pending" ? (
|
||||
<Text variant="description" tone="muted">
|
||||
No uplifts waiting for approval.
|
||||
</Text>
|
||||
) : (
|
||||
<Box>
|
||||
<Text variant="description">No approved uplifts yet</Text>
|
||||
<Text variant="caption" tone="muted">
|
||||
Uplifts you approve are archived here for reference.
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
<UpliftApprovalRow
|
||||
key={String(row.id)}
|
||||
row={row}
|
||||
tab={tab}
|
||||
canRevoke={canRevoke}
|
||||
onOpenDetail={onOpenDetail}
|
||||
onApprove={onApprove}
|
||||
onReject={onReject}
|
||||
onRevoke={onRevoke}
|
||||
onOpenAttachment={onOpenAttachment}
|
||||
isDecisionPending={isDecisionPending}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
418
src/app/(protected)/uplifts/_components/uplift-detail-modal.tsx
Normal file
418
src/app/(protected)/uplifts/_components/uplift-detail-modal.tsx
Normal file
|
|
@ -0,0 +1,418 @@
|
|||
import { useMemo, type ReactNode } from "react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Tooltip,
|
||||
} from "@mui/material";
|
||||
import { formatUpliftMoney } from "@/app/(protected)/uplifts/_components/format-uplift-money";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||
import { useWorkOrderUplifts } from "@/domain/work-orders/use-cases/use-work-order-uplifts";
|
||||
import { useUpliftsCanApprove } from "@/domain/uplifts/use-cases/use-uplifts-can-approve";
|
||||
import { isWorkOrderUpliftsReadOnly } from "@/domain/work-orders/utils/uplift-display-utils";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
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;
|
||||
onApprove: (row: UpliftQueueItem) => void;
|
||||
onReject: (row: UpliftQueueItem) => void;
|
||||
onRevoke: (row: UpliftQueueItem) => void;
|
||||
onOpenAttachment: (row: UpliftQueueItem) => void;
|
||||
};
|
||||
|
||||
function DetailField({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<Box>
|
||||
<Text variant="label" tone="muted" className="block">
|
||||
{label}
|
||||
</Text>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailSection({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<Box>
|
||||
<Text variant="heading" className="mb-1 block">
|
||||
{title}
|
||||
</Text>
|
||||
<Box sx={{ display: "grid", gap: 1.5 }}>{children}</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function UpliftAttachments({
|
||||
item,
|
||||
onOpenAttachment,
|
||||
}: {
|
||||
item: UpliftQueueItem;
|
||||
onOpenAttachment: (row: UpliftQueueItem) => void;
|
||||
}) {
|
||||
if (item.evidenceDocumentId == null && !item.evidenceFileName) {
|
||||
return (
|
||||
<Text variant="description" tone="muted">
|
||||
No attachments
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
const extraCount =
|
||||
typeof item.attachmentCount === "number" && item.attachmentCount > 1
|
||||
? item.attachmentCount - 1
|
||||
: 0;
|
||||
return (
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5, flexWrap: "wrap" }}>
|
||||
<Chip
|
||||
size="small"
|
||||
label={item.evidenceFileName || "Attachment"}
|
||||
onClick={item.evidenceDocumentId != null ? () => onOpenAttachment(item) : undefined}
|
||||
clickable={item.evidenceDocumentId != null}
|
||||
/>
|
||||
{extraCount > 0 && <Chip size="small" label={`+${extraCount}`} />}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
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 #">
|
||||
<Text variant="body">{item.woNumber || "—"}</Text>
|
||||
</DetailField>
|
||||
<DetailField label="Site">
|
||||
<Text variant="body">{info?.site || item.site || "—"}</Text>
|
||||
</DetailField>
|
||||
<DetailField label="Status">
|
||||
<Text variant="body">{info?.status || statusFallback}</Text>
|
||||
</DetailField>
|
||||
<DetailField label="Scheduled">
|
||||
<Text variant="body">
|
||||
{info?.scheduledOn || (unavailable ? UNAVAILABLE_LABEL : "Unscheduled")}
|
||||
</Text>
|
||||
</DetailField>
|
||||
<DetailField label="Vendor">
|
||||
<Text variant="body">{item.vendorCompanyName || "—"}</Text>
|
||||
</DetailField>
|
||||
</DetailSection>
|
||||
);
|
||||
}
|
||||
|
||||
function RequestSection({
|
||||
item,
|
||||
onOpenAttachment,
|
||||
}: {
|
||||
item: UpliftQueueItem;
|
||||
onOpenAttachment: (row: UpliftQueueItem) => void;
|
||||
}) {
|
||||
const waitingClass = item.requestedAt
|
||||
? getWaitTimeTextClass(waitTimeColor(item.requestedAt))
|
||||
: undefined;
|
||||
return (
|
||||
<DetailSection title="Request">
|
||||
<DetailField label="Amount">
|
||||
<Text variant="body">
|
||||
{formatUpliftMoney(item.currentNTE ?? 0)} → {formatUpliftMoney(item.requestedNTE)}
|
||||
</Text>
|
||||
</DetailField>
|
||||
<DetailField label="Requested by">
|
||||
<Text variant="body">{item.requestedByVendorName || "—"}</Text>
|
||||
</DetailField>
|
||||
<DetailField label="Waiting">
|
||||
<Text variant="body" className={waitingClass}>
|
||||
{item.requestedAt ? timeSince(item.requestedAt) : "—"}
|
||||
</Text>
|
||||
</DetailField>
|
||||
<DetailField label="Notes">
|
||||
<Text variant="description">{item.vendorReason || "No justification provided."}</Text>
|
||||
</DetailField>
|
||||
<DetailField label="Attachments">
|
||||
<UpliftAttachments item={item} onOpenAttachment={onOpenAttachment} />
|
||||
</DetailField>
|
||||
</DetailSection>
|
||||
);
|
||||
}
|
||||
|
||||
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 },
|
||||
{ label: "Admin-approved", value: admin, strong: false },
|
||||
{ label: "Total", value: total, strong: true },
|
||||
];
|
||||
return (
|
||||
<Box sx={{ display: "grid", gap: 0.5 }}>
|
||||
{rows.map((row) => (
|
||||
<BreakdownRow key={row.label} unavailable={unavailable} {...row} />
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function BreakdownRow({
|
||||
label,
|
||||
value,
|
||||
strong,
|
||||
unavailable,
|
||||
}: {
|
||||
label: string;
|
||||
value: number | null;
|
||||
strong: boolean;
|
||||
unavailable: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between" }}>
|
||||
<Text
|
||||
variant={strong ? "body" : "description"}
|
||||
tone={strong ? "default" : "muted"}
|
||||
className={strong ? "font-medium" : undefined}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
<Text
|
||||
variant={strong ? "body" : "description"}
|
||||
className={strong ? "font-medium" : undefined}
|
||||
>
|
||||
{value != null ? formatUpliftMoney(value) : unavailable ? UNAVAILABLE_LABEL : "—"}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function PendingModalActions({
|
||||
item,
|
||||
approvePending,
|
||||
onApprove,
|
||||
onReject,
|
||||
}: {
|
||||
item: UpliftQueueItem;
|
||||
approvePending: boolean;
|
||||
onApprove: (row: UpliftQueueItem) => void;
|
||||
onReject: (row: UpliftQueueItem) => void;
|
||||
}) {
|
||||
const canApproveTier = useUpliftsCanApprove(item.requiredTier);
|
||||
const canDecide = item.canDecide && canApproveTier.data === true;
|
||||
const tooltip = canDecide ? "" : `Requires Tier ${item.requiredTier} role`;
|
||||
return (
|
||||
<>
|
||||
<Tooltip title={tooltip}>
|
||||
<span>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
color="success"
|
||||
disabled={!canDecide || approvePending}
|
||||
onClick={() => onApprove(item)}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title={tooltip}>
|
||||
<span>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
color="error"
|
||||
disabled={!canDecide}
|
||||
onClick={() => onReject(item)}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function UpliftModalActions({
|
||||
item,
|
||||
closed,
|
||||
canRevoke,
|
||||
approvePending,
|
||||
revokePending,
|
||||
onApprove,
|
||||
onReject,
|
||||
onRevoke,
|
||||
}: {
|
||||
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" && (
|
||||
<PendingModalActions
|
||||
item={item}
|
||||
approvePending={approvePending}
|
||||
onApprove={onApprove}
|
||||
onReject={onReject}
|
||||
/>
|
||||
)}
|
||||
{item.status === "Approved" && (
|
||||
<Tooltip title={revokeTooltip}>
|
||||
<span>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="error"
|
||||
disabled={!canRevoke || closed || item.workOrderId == null || revokePending}
|
||||
onClick={() => onRevoke(item)}
|
||||
>
|
||||
Revoke
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function UpliftDetailModal({
|
||||
item,
|
||||
canRevoke,
|
||||
approvePending,
|
||||
revokePending,
|
||||
onClose,
|
||||
onApprove,
|
||||
onReject,
|
||||
onRevoke,
|
||||
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;
|
||||
const closed = useMemo(() => {
|
||||
if (item?.workOrderClosed === true) return true;
|
||||
return info ? isWorkOrderUpliftsReadOnly(info.status) : false;
|
||||
}, [item?.workOrderClosed, info]);
|
||||
|
||||
const breakdown = useMemo(() => {
|
||||
const woUplifts = woUpliftsQuery.data ?? [];
|
||||
const autoFallback = sumUpliftAmounts(woUplifts, "auto_approved");
|
||||
const adminFallback = sumUpliftAmounts(woUplifts, "approved");
|
||||
const hasWoData = woUplifts.length > 0;
|
||||
return {
|
||||
auto: item?.approvedOnWoAuto ?? (hasWoData ? autoFallback : null),
|
||||
admin: item?.approvedOnWoAdmin ?? (hasWoData ? adminFallback : null),
|
||||
total: item?.approvedOnWoTotal ?? (hasWoData ? autoFallback + adminFallback : null),
|
||||
};
|
||||
}, [
|
||||
item?.approvedOnWoAuto,
|
||||
item?.approvedOnWoAdmin,
|
||||
item?.approvedOnWoTotal,
|
||||
woUpliftsQuery.data,
|
||||
]);
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
aria-label="Uplift request details"
|
||||
>
|
||||
<DialogTitle>
|
||||
<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} 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>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button size="small" variant="outlined" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
<UpliftModalActions
|
||||
item={item}
|
||||
closed={closed}
|
||||
canRevoke={canRevoke}
|
||||
approvePending={approvePending}
|
||||
revokePending={revokePending}
|
||||
onApprove={onApprove}
|
||||
onReject={onReject}
|
||||
onRevoke={onRevoke}
|
||||
/>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function sumUpliftAmounts(
|
||||
uplifts: ReadonlyArray<{ status: string; amount: number }>,
|
||||
status: string,
|
||||
): number {
|
||||
return uplifts
|
||||
.filter((uplift) => uplift.status === status)
|
||||
.reduce((sum, uplift) => sum + uplift.amount, 0);
|
||||
}
|
||||
|
|
@ -1,473 +1,190 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useState } from "react";
|
||||
import { Alert, Box, Chip, Stack, Tab, Tabs, TablePagination } from "@mui/material";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TablePagination,
|
||||
TableRow,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { upliftsApi } from "@/domain/uplifts/api/uplifts-api";
|
||||
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
|
||||
import { UPLIFT_STATUSES } from "@/domain/uplifts/types/uplift";
|
||||
import {
|
||||
useApproveUplift,
|
||||
useRejectUplift,
|
||||
useRequestChangesUplift,
|
||||
} from "@/domain/uplifts/use-cases/use-uplift-actions";
|
||||
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 { 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 { formatDateTime } from "@/lib/time-utils";
|
||||
import { toast } from "react-toastify";
|
||||
import { RejectDialog } from "@/app/(protected)/uplifts/_components/reject-dialog";
|
||||
import { Text } from "@/components/ui/text";
|
||||
|
||||
const STATUS_OPTIONS = ["", ...UPLIFT_STATUSES];
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
type UpliftActionType = "approve" | "reject" | "request-changes";
|
||||
|
||||
type UpliftDecision = { type: UpliftActionType; item: UpliftQueueItem };
|
||||
|
||||
function formatMoney(value: number | null | undefined): string {
|
||||
return `$${Number(value ?? 0).toFixed(2)}`;
|
||||
}
|
||||
|
||||
function formatBytes(value: number | null | undefined): string {
|
||||
if (value == null) return "";
|
||||
if (value < 1024) return `${value} B`;
|
||||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(0)} KB`;
|
||||
return `${(value / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function UpliftQueueHeader({
|
||||
totalCount,
|
||||
isFetching,
|
||||
isLoading,
|
||||
}: {
|
||||
totalCount: number;
|
||||
isFetching: boolean;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
function UpliftApprovalsHeader({ pendingExposureTotal }: { pendingExposureTotal: number | null }) {
|
||||
return (
|
||||
<Stack direction="row" spacing={2} sx={{ alignItems: "baseline" }}>
|
||||
<Typography variant="h5" component="h1">
|
||||
Uplift Queue
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
{totalCount} total
|
||||
</Typography>
|
||||
{isFetching && !isLoading && <CircularProgress size={16} />}
|
||||
</Stack>
|
||||
<Box>
|
||||
<Text variant="title" as="h1">
|
||||
Uplift Approvals
|
||||
</Text>
|
||||
<Text variant="description" tone="muted">
|
||||
Requests to increase the approved spend on a work order, above its remaining auto-approval
|
||||
allowance.
|
||||
</Text>
|
||||
<Text when={pendingExposureTotal != null} variant="caption" tone="muted">
|
||||
Pending exposure:{" "}
|
||||
{pendingExposureTotal && pendingExposureTotal > 0
|
||||
? formatUpliftMoney(pendingExposureTotal)
|
||||
: "—"}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function UpliftQueueFilters({
|
||||
status,
|
||||
tier,
|
||||
onStatusChange,
|
||||
onTierChange,
|
||||
function UpliftApprovalsTabs({
|
||||
tab,
|
||||
pendingCount,
|
||||
onChange,
|
||||
}: {
|
||||
status: string;
|
||||
tier: string;
|
||||
onStatusChange: (value: string) => void;
|
||||
onTierChange: (value: string) => void;
|
||||
tab: UpliftApprovalTab;
|
||||
pendingCount: number;
|
||||
onChange: (_event: unknown, value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<Stack direction="row" spacing={2}>
|
||||
<FormControl size="small" className="min-w-[140px]">
|
||||
<InputLabel>Status</InputLabel>
|
||||
<Select label="Status" value={status} onChange={(e) => onStatusChange(e.target.value)}>
|
||||
{STATUS_OPTIONS.map((s) => (
|
||||
<MenuItem key={s || "all"} value={s}>
|
||||
{s || "All"}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl size="small" className="min-w-[120px]">
|
||||
<InputLabel>Tier</InputLabel>
|
||||
<Select label="Tier" value={tier} onChange={(e) => onTierChange(e.target.value)}>
|
||||
<MenuItem value="">All</MenuItem>
|
||||
<MenuItem value="1">Tier 1</MenuItem>
|
||||
<MenuItem value="2">Tier 2</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function UpliftQueueRow({
|
||||
row,
|
||||
onViewWorkOrder,
|
||||
onDownloadEvidence,
|
||||
onDecide,
|
||||
}: {
|
||||
row: UpliftQueueItem;
|
||||
onViewWorkOrder: (row: UpliftQueueItem) => void;
|
||||
onDownloadEvidence: (row: UpliftQueueItem) => void;
|
||||
onDecide: (type: UpliftActionType, row: UpliftQueueItem) => void;
|
||||
}) {
|
||||
const decisionable = row.status === "Pending";
|
||||
return (
|
||||
<TableRow hover>
|
||||
<TableCell>{row.dispatchNumber}</TableCell>
|
||||
<TableCell>{row.poNumber}</TableCell>
|
||||
<TableCell>{row.vendorCompanyName}</TableCell>
|
||||
<TableCell>{formatMoney(row.currentNTE)}</TableCell>
|
||||
<TableCell>{formatMoney(row.requestedNTE)}</TableCell>
|
||||
<TableCell>
|
||||
<Chip size="small" label={`Tier ${row.requiredTier}`} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip size="small" label={row.status} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{row.evidenceDocumentId != null ? (
|
||||
<Stack direction="column" spacing={0.5}>
|
||||
<Typography variant="caption">{row.evidenceFileName}</Typography>
|
||||
{Boolean(row.evidenceContentType) && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{row.evidenceContentType} · {formatBytes(row.evidenceSizeBytes)}
|
||||
</Typography>
|
||||
)}
|
||||
<Button size="small" variant="text" onClick={() => void onDownloadEvidence(row)}>
|
||||
Download
|
||||
</Button>
|
||||
<Tabs value={tab} onChange={onChange}>
|
||||
<Tab
|
||||
value="pending"
|
||||
label={
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
||||
<span>Pending</span>
|
||||
<Chip
|
||||
size="small"
|
||||
label={pendingCount}
|
||||
aria-label={`${pendingCount} pending uplifts`}
|
||||
/>
|
||||
</Stack>
|
||||
) : (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
—
|
||||
</Typography>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{row.requestedAt ? formatDateTime(row.requestedAt) : ""}</TableCell>
|
||||
<TableCell>
|
||||
<Stack direction="row" spacing={1}>
|
||||
{Boolean(row.workOrderId) && (
|
||||
<Button size="small" variant="outlined" onClick={() => onViewWorkOrder(row)}>
|
||||
View
|
||||
</Button>
|
||||
)}
|
||||
{decisionable && (
|
||||
<>
|
||||
<Tooltip title={!row.canDecide ? `Requires Tier ${row.requiredTier} role` : ""}>
|
||||
<span>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
color="success"
|
||||
disabled={!row.canDecide}
|
||||
onClick={() => onDecide("approve", row)}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title={!row.canDecide ? `Requires Tier ${row.requiredTier} role` : ""}>
|
||||
<span>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
color="error"
|
||||
disabled={!row.canDecide}
|
||||
onClick={() => onDecide("reject", row)}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title={!row.canDecide ? `Requires Tier ${row.requiredTier} role` : ""}>
|
||||
<span>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
disabled={!row.canDecide}
|
||||
onClick={() => onDecide("request-changes", row)}
|
||||
>
|
||||
Request Changes
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
}
|
||||
/>
|
||||
<Tab value="approved" label="Approved" />
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
function UpliftQueueTable({
|
||||
isLoading,
|
||||
rows,
|
||||
onViewWorkOrder,
|
||||
onDownloadEvidence,
|
||||
onDecide,
|
||||
function UpliftApprovalsPagination({
|
||||
tab,
|
||||
count,
|
||||
pendingPage,
|
||||
approvedPage,
|
||||
onPendingPageChange,
|
||||
onApprovedPageChange,
|
||||
}: {
|
||||
isLoading: boolean;
|
||||
rows: UpliftQueueItem[];
|
||||
onViewWorkOrder: (row: UpliftQueueItem) => void;
|
||||
onDownloadEvidence: (row: UpliftQueueItem) => void;
|
||||
onDecide: (type: UpliftActionType, row: UpliftQueueItem) => void;
|
||||
tab: UpliftApprovalTab;
|
||||
count: number;
|
||||
pendingPage: number;
|
||||
approvedPage: number;
|
||||
onPendingPageChange: (page: number) => void;
|
||||
onApprovedPageChange: (page: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell className="font-semibold">Dispatch</TableCell>
|
||||
<TableCell className="font-semibold">PO</TableCell>
|
||||
<TableCell className="font-semibold">Vendor</TableCell>
|
||||
<TableCell className="font-semibold">Current NTE</TableCell>
|
||||
<TableCell className="font-semibold">Requested</TableCell>
|
||||
<TableCell className="font-semibold">Tier</TableCell>
|
||||
<TableCell className="font-semibold">Status</TableCell>
|
||||
<TableCell className="font-semibold">Evidence</TableCell>
|
||||
<TableCell className="font-semibold">Requested At</TableCell>
|
||||
<TableCell className="font-semibold">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={10} align="center" className="py-10">
|
||||
<CircularProgress size={28} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={10} align="center" className="py-10">
|
||||
<Typography sx={{ color: "text.secondary" }}>
|
||||
No uplift requests match these filters.
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
<UpliftQueueRow
|
||||
key={String(row.id)}
|
||||
row={row}
|
||||
onViewWorkOrder={onViewWorkOrder}
|
||||
onDownloadEvidence={onDownloadEvidence}
|
||||
onDecide={onDecide}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={count}
|
||||
page={tab === "pending" ? pendingPage : approvedPage}
|
||||
onPageChange={(_event, nextPage) =>
|
||||
tab === "pending" ? onPendingPageChange(nextPage) : onApprovedPageChange(nextPage)
|
||||
}
|
||||
rowsPerPage={PAGE_SIZE}
|
||||
rowsPerPageOptions={[PAGE_SIZE]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function UpliftDecisionDialog({
|
||||
action,
|
||||
note,
|
||||
noteRequired,
|
||||
decisionPending,
|
||||
onNoteChange,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: {
|
||||
action: UpliftDecision | null;
|
||||
note: string;
|
||||
noteRequired: boolean;
|
||||
decisionPending: boolean;
|
||||
onNoteChange: (value: string) => void;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
function UpliftQueueError({ error }: { error: unknown }) {
|
||||
if (!error) return null;
|
||||
return (
|
||||
<Dialog open={Boolean(action)} onClose={onClose}>
|
||||
<DialogTitle>
|
||||
{action?.type === "approve"
|
||||
? `Approve uplift to ${formatMoney(action.item.requestedNTE)}?`
|
||||
: "Request Changes"}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
{action?.type === "approve" && (
|
||||
<Typography variant="body2" className="mt-1 mb-3">
|
||||
{formatMoney(action.item.currentNTE)} → {formatMoney(action.item.requestedNTE)}
|
||||
</Typography>
|
||||
)}
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={2}
|
||||
className="mt-2"
|
||||
label={noteRequired ? "Note (required)" : "Optional note"}
|
||||
value={note}
|
||||
required={noteRequired}
|
||||
onChange={(e) => onNoteChange(e.target.value)}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color={action?.type === "approve" ? "success" : "primary"}
|
||||
disabled={noteRequired ? !note.trim() || decisionPending : decisionPending}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
<Alert severity="error">
|
||||
{error instanceof Error ? error.message : "Failed to load uplift queue"}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
export default function UpliftQueuePage() {
|
||||
const navigate = useNavigate();
|
||||
const [status, setStatus] = useState("Pending");
|
||||
const [tier, setTier] = useState("");
|
||||
const [page, setPage] = useState(0);
|
||||
const [pageSize] = useState(25);
|
||||
const [upliftAction, setUpliftAction] = useState<UpliftDecision | null>(null);
|
||||
const [note, setNote] = useState("");
|
||||
const [tab, setTab] = useState<UpliftApprovalTab>("pending");
|
||||
const [pendingPage, setPendingPage] = useState(0);
|
||||
const [approvedPage, setApprovedPage] = useState(0);
|
||||
|
||||
const listParams = useMemo(
|
||||
() => ({ status, tier, page: page + 1, pageSize }),
|
||||
[status, tier, page, pageSize],
|
||||
);
|
||||
const { user } = useAuthContext();
|
||||
const canRevokeUplifts = isAdminUser(user?.userRoles);
|
||||
|
||||
const { data, isLoading, isFetching, error, refetch } = useUpliftsQueue(listParams);
|
||||
const approveUplift = useApproveUplift();
|
||||
const rejectUplift = useRejectUplift();
|
||||
const requestChangesUplift = useRequestChangesUplift();
|
||||
const pendingQuery = useUpliftsQueue({
|
||||
status: "Pending",
|
||||
page: pendingPage + 1,
|
||||
pageSize: PAGE_SIZE,
|
||||
});
|
||||
const approvedQuery = useUpliftsQueue({
|
||||
status: "Approved",
|
||||
page: approvedPage + 1,
|
||||
pageSize: PAGE_SIZE,
|
||||
});
|
||||
|
||||
const rows = data?.items ?? [];
|
||||
const totalCount = data?.totalCount ?? 0;
|
||||
const genericAction = upliftAction?.type === "reject" ? null : upliftAction;
|
||||
const noteRequired = genericAction?.type === "request-changes";
|
||||
const decisionPending = approveUplift.isPending || requestChangesUplift.isPending;
|
||||
const controller = useUpliftApprovalController();
|
||||
const {
|
||||
detailItem,
|
||||
approvePending,
|
||||
rejectPending,
|
||||
revokePending,
|
||||
handleApprove,
|
||||
handleRejectRequest,
|
||||
handleRevokeRequest,
|
||||
closeDetail,
|
||||
openDetail,
|
||||
} = controller;
|
||||
|
||||
const handleStatusChange = (value: string) => {
|
||||
setStatus(value);
|
||||
setPage(0);
|
||||
};
|
||||
const handleTierChange = (value: string) => {
|
||||
setTier(value);
|
||||
setPage(0);
|
||||
};
|
||||
const activeQuery = tab === "pending" ? pendingQuery : approvedQuery;
|
||||
const rows = activeQuery.data?.items ?? [];
|
||||
const pendingCount = pendingQuery.data?.totalCount ?? 0;
|
||||
const pendingExposureTotal = pendingQuery.data?.pendingExposureTotal ?? null;
|
||||
|
||||
const closeDialog = () => {
|
||||
setUpliftAction(null);
|
||||
setNote("");
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!upliftAction) return;
|
||||
if (noteRequired && !note.trim()) return;
|
||||
const id = upliftAction.item.id;
|
||||
const onDone = () => {
|
||||
closeDialog();
|
||||
void refetch();
|
||||
};
|
||||
if (upliftAction.type === "approve") {
|
||||
approveUplift.mutate({ id, note: note || undefined }, { onSuccess: onDone });
|
||||
return;
|
||||
const handleTabChange = (_event: unknown, value: string) => {
|
||||
if (value === "pending" || value === "approved") {
|
||||
setTab(value);
|
||||
}
|
||||
if (upliftAction.type === "request-changes") {
|
||||
requestChangesUplift.mutate({ id, note: note.trim() }, { onSuccess: onDone });
|
||||
}
|
||||
};
|
||||
|
||||
const handleRejectConfirm = (reason: string) => {
|
||||
if (upliftAction?.type !== "reject") return;
|
||||
rejectUplift.mutate(
|
||||
{ id: upliftAction.item.id, note: reason },
|
||||
{
|
||||
onSuccess: () => {
|
||||
closeDialog();
|
||||
void refetch();
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const downloadEvidence = async (item: UpliftQueueItem) => {
|
||||
try {
|
||||
await upliftsApi.downloadEvidence(item.id, item.evidenceFileName || "uplift-evidence");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Unable to download evidence.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDecide = (type: UpliftActionType, item: UpliftQueueItem) => {
|
||||
setUpliftAction({ type, item });
|
||||
setNote("");
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="flex flex-col gap-4 p-4">
|
||||
<Stack
|
||||
direction={{ xs: "column", md: "row" }}
|
||||
spacing={2}
|
||||
sx={{
|
||||
alignItems: { xs: "stretch", md: "center" },
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<UpliftQueueHeader totalCount={totalCount} isFetching={isFetching} isLoading={isLoading} />
|
||||
<UpliftQueueFilters
|
||||
status={status}
|
||||
tier={tier}
|
||||
onStatusChange={handleStatusChange}
|
||||
onTierChange={handleTierChange}
|
||||
/>
|
||||
</Stack>
|
||||
{Boolean(error) && (
|
||||
<Alert severity="error">
|
||||
{error instanceof Error ? error.message : "Failed to load uplift queue"}
|
||||
</Alert>
|
||||
)}
|
||||
<UpliftQueueTable
|
||||
isLoading={isLoading}
|
||||
<UpliftApprovalsHeader pendingExposureTotal={pendingExposureTotal} />
|
||||
<UpliftApprovalsTabs tab={tab} pendingCount={pendingCount} onChange={handleTabChange} />
|
||||
<UpliftQueueError error={activeQuery.error} />
|
||||
<UpliftApprovalsTable
|
||||
tab={tab}
|
||||
isLoading={activeQuery.isLoading}
|
||||
rows={rows}
|
||||
onViewWorkOrder={(r) => navigate(`/workorders/${r.workOrderId}`)}
|
||||
onDownloadEvidence={(r) => void downloadEvidence(r)}
|
||||
onDecide={handleDecide}
|
||||
canRevoke={canRevokeUplifts}
|
||||
onOpenDetail={openDetail}
|
||||
onApprove={handleApprove}
|
||||
onReject={handleRejectRequest}
|
||||
onRevoke={handleRevokeRequest}
|
||||
onOpenAttachment={openUpliftEvidence}
|
||||
isDecisionPending={approvePending || rejectPending || revokePending}
|
||||
/>
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={totalCount}
|
||||
page={page}
|
||||
onPageChange={(_e, nextPage) => setPage(nextPage)}
|
||||
rowsPerPage={pageSize}
|
||||
rowsPerPageOptions={[25]}
|
||||
/>
|
||||
<RejectDialog
|
||||
open={upliftAction?.type === "reject"}
|
||||
amount={upliftAction?.item.requestedNTE}
|
||||
onClose={closeDialog}
|
||||
onConfirm={handleRejectConfirm}
|
||||
isPending={rejectUplift.isPending}
|
||||
/>
|
||||
<UpliftDecisionDialog
|
||||
action={genericAction}
|
||||
note={note}
|
||||
noteRequired={noteRequired}
|
||||
decisionPending={decisionPending}
|
||||
onNoteChange={setNote}
|
||||
onClose={closeDialog}
|
||||
onConfirm={handleConfirm}
|
||||
<UpliftApprovalsPagination
|
||||
tab={tab}
|
||||
count={activeQuery.data?.totalCount ?? 0}
|
||||
pendingPage={pendingPage}
|
||||
approvedPage={approvedPage}
|
||||
onPendingPageChange={setPendingPage}
|
||||
onApprovedPageChange={setApprovedPage}
|
||||
/>
|
||||
{detailItem != null && (
|
||||
<UpliftDetailModal
|
||||
item={detailItem}
|
||||
canRevoke={canRevokeUplifts}
|
||||
approvePending={approvePending}
|
||||
revokePending={revokePending}
|
||||
onClose={closeDetail}
|
||||
onApprove={handleApprove}
|
||||
onReject={handleRejectRequest}
|
||||
onRevoke={handleRevokeRequest}
|
||||
onOpenAttachment={openUpliftEvidence}
|
||||
/>
|
||||
)}
|
||||
<UpliftDecisionDialogs controller={controller} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,11 @@ import { HTTPError } from "ky";
|
|||
import { API_PATHS } from "@/api/api-paths";
|
||||
import { apiGet, apiPost, apiRequestRaw } from "@/api/api";
|
||||
import { handleApiResponse } from "@/api/handle-api-response";
|
||||
import { mapUpliftQueueItems, mapUpliftRequests } from "@/domain/uplifts/mappers/uplift-mapper";
|
||||
import {
|
||||
mapUpliftQueueExposureTotal,
|
||||
mapUpliftQueueItems,
|
||||
mapUpliftRequests,
|
||||
} from "@/domain/uplifts/mappers/uplift-mapper";
|
||||
import type { UpliftsQueueParams, UpliftsQueueResult } from "@/domain/uplifts/types/uplift";
|
||||
import type { UpliftRequest } from "@/domain/uplifts/types/uplift";
|
||||
|
||||
|
|
@ -57,6 +61,7 @@ export const upliftsApi = {
|
|||
totalCount: total,
|
||||
page: params.page ?? 1,
|
||||
pageSize: params.pageSize ?? 25,
|
||||
pendingExposureTotal: mapUpliftQueueExposureTotal(response),
|
||||
};
|
||||
},
|
||||
|
||||
|
|
@ -81,10 +86,22 @@ export const upliftsApi = {
|
|||
await apiPost<unknown>(`${API_PATHS.rest.uplifts}/${id}/reject`, { note });
|
||||
},
|
||||
|
||||
canApprove: async (tier: number): Promise<boolean> => {
|
||||
const data = await apiGet<unknown>(`${API_PATHS.rest.uplifts}/can-approve`, {
|
||||
searchParams: { tier },
|
||||
});
|
||||
const response = handleApiResponse<{ canApprove?: unknown }>(data);
|
||||
return response?.canApprove === true;
|
||||
},
|
||||
|
||||
requestChanges: async (id: string | number, note: string): Promise<void> => {
|
||||
await apiPost<unknown>(`${API_PATHS.rest.uplifts}/${id}/request-changes`, { note });
|
||||
},
|
||||
|
||||
revoke: async (id: string | number, reason: string): Promise<void> => {
|
||||
await apiPost<unknown>(`${API_PATHS.rest.uplifts}/${id}/revoke`, { note: reason });
|
||||
},
|
||||
|
||||
downloadEvidence: async (
|
||||
id: string | number,
|
||||
fallbackFileName = "uplift-evidence",
|
||||
|
|
|
|||
|
|
@ -39,6 +39,14 @@ function readOptionalId(
|
|||
return null;
|
||||
}
|
||||
|
||||
function readOptionalBool(record: Record<string, unknown>, ...keys: string[]): boolean | null {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (typeof value === "boolean") return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function mapUpliftRequest(raw: unknown): UpliftRequest {
|
||||
const item = asRecord(raw);
|
||||
return {
|
||||
|
|
@ -49,7 +57,13 @@ export function mapUpliftRequest(raw: unknown): UpliftRequest {
|
|||
delta: readNumber(item, "delta", "Delta") ?? 0,
|
||||
vendorReason: readString(item, "vendorReason", "VendorReason"),
|
||||
requestedAt: readString(item, "requestedAt", "RequestedAt"),
|
||||
requestedByVendorName: readString(item, "requestedByVendorName", "RequestedByVendorName"),
|
||||
requestedByVendorName: readString(
|
||||
item,
|
||||
"requestedByName",
|
||||
"RequestedByName",
|
||||
"requestedByVendorName",
|
||||
"RequestedByVendorName",
|
||||
),
|
||||
decidedAt: readString(item, "decidedAt", "DecidedAt"),
|
||||
decidedByName: readString(item, "decidedByName", "DecidedByName"),
|
||||
decisionNote: readString(item, "decisionNote", "DecisionNote"),
|
||||
|
|
@ -79,9 +93,86 @@ export function mapUpliftQueueItem(raw: unknown): UpliftQueueItem {
|
|||
vendorCompanyName: readString(item, "vendorCompanyName", "VendorCompanyName"),
|
||||
workOrderId: readOptionalId(item, "workOrderId", "WorkOrderId"),
|
||||
dispatchId: readOptionalId(item, "dispatchId", "DispatchId"),
|
||||
woNumber: readString(item, "woNumber", "WoNumber", "workOrderNumber", "WorkOrderNumber"),
|
||||
site: readString(
|
||||
item,
|
||||
"workOrderSite",
|
||||
"WorkOrderSite",
|
||||
"site",
|
||||
"Site",
|
||||
"siteCode",
|
||||
"SiteCode",
|
||||
"locationName",
|
||||
"LocationName",
|
||||
),
|
||||
serviceName: readString(
|
||||
item,
|
||||
"workOrderService",
|
||||
"WorkOrderService",
|
||||
"serviceName",
|
||||
"ServiceName",
|
||||
"service",
|
||||
"Service",
|
||||
"trade",
|
||||
"Trade",
|
||||
),
|
||||
attachmentCount: readNumber(
|
||||
item,
|
||||
"attachmentCount",
|
||||
"AttachmentCount",
|
||||
"attachmentsCount",
|
||||
"AttachmentsCount",
|
||||
),
|
||||
approvedOnWoAuto: readNumber(
|
||||
item,
|
||||
"workOrderAutoApprovedTotal",
|
||||
"WorkOrderAutoApprovedTotal",
|
||||
"approvedOnWoAuto",
|
||||
"ApprovedOnWoAuto",
|
||||
"autoApprovedTotal",
|
||||
"AutoApprovedTotal",
|
||||
),
|
||||
approvedOnWoAdmin: readNumber(
|
||||
item,
|
||||
"workOrderAdminApprovedTotal",
|
||||
"WorkOrderAdminApprovedTotal",
|
||||
"approvedOnWoAdmin",
|
||||
"ApprovedOnWoAdmin",
|
||||
"adminApprovedTotal",
|
||||
"AdminApprovedTotal",
|
||||
),
|
||||
approvedOnWoTotal: readNumber(
|
||||
item,
|
||||
"workOrderApprovedExposureTotal",
|
||||
"WorkOrderApprovedExposureTotal",
|
||||
"approvedOnWoTotal",
|
||||
"ApprovedOnWoTotal",
|
||||
"approvedOnWorkOrderTotal",
|
||||
"ApprovedOnWorkOrderTotal",
|
||||
),
|
||||
workOrderClosed: readOptionalBool(
|
||||
item,
|
||||
"workOrderClosed",
|
||||
"WorkOrderClosed",
|
||||
"isWorkOrderClosed",
|
||||
"IsWorkOrderClosed",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function mapUpliftQueueItems(rawItems: unknown[]): UpliftQueueItem[] {
|
||||
return rawItems.map(mapUpliftQueueItem);
|
||||
}
|
||||
|
||||
export function mapUpliftQueueExposureTotal(raw: unknown): number | null {
|
||||
const record = asRecord(raw);
|
||||
return readNumber(
|
||||
record,
|
||||
"pendingExposureTotal",
|
||||
"PendingExposureTotal",
|
||||
"totalPendingExposure",
|
||||
"TotalPendingExposure",
|
||||
"exposureTotal",
|
||||
"ExposureTotal",
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,14 @@ export interface UpliftQueueItem extends UpliftRequest {
|
|||
vendorCompanyName: string;
|
||||
workOrderId: string | number | null;
|
||||
dispatchId: string | number | null;
|
||||
woNumber: string;
|
||||
site: string;
|
||||
serviceName: string;
|
||||
attachmentCount: number | null;
|
||||
approvedOnWoAuto: number | null;
|
||||
approvedOnWoAdmin: number | null;
|
||||
approvedOnWoTotal: number | null;
|
||||
workOrderClosed: boolean | null;
|
||||
}
|
||||
|
||||
export interface UpliftsQueueParams {
|
||||
|
|
@ -57,4 +65,5 @@ export interface UpliftsQueueResult {
|
|||
totalCount: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
pendingExposureTotal: number | null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,9 @@ function invalidateUpliftQueries(
|
|||
dispatchId?: string | number,
|
||||
) {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.uplifts.all });
|
||||
// Queue decisions update the same uplift records rendered by Work Order views.
|
||||
// Invalidate that shared cache even when the queue has no dispatch context.
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
|
||||
if (dispatchId !== undefined && dispatchId !== "") {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.uplifts.forDispatch(dispatchId) });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.dispatch(dispatchId) });
|
||||
|
|
@ -33,14 +36,32 @@ function invalidateUpliftQueries(
|
|||
}
|
||||
}
|
||||
|
||||
function invalidateWorkOrderUpliftQueries(queryClient: ReturnType<typeof useQueryClient>) {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.uplifts.all });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
|
||||
}
|
||||
|
||||
export interface ApproveUpliftInput {
|
||||
id: string | number;
|
||||
note?: string;
|
||||
amount?: number;
|
||||
woNumber?: string;
|
||||
}
|
||||
|
||||
export interface DecisionUpliftInput {
|
||||
id: string | number;
|
||||
note: string;
|
||||
amount?: number;
|
||||
woNumber?: string;
|
||||
}
|
||||
|
||||
function decisionAmount(amount: number | undefined): string {
|
||||
if (amount == null || !Number.isFinite(amount)) return "the requested amount";
|
||||
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(amount);
|
||||
}
|
||||
|
||||
function decisionWorkOrder(woNumber: string | undefined): string {
|
||||
return woNumber?.trim() || "the work order";
|
||||
}
|
||||
|
||||
export function useApproveUplift(
|
||||
|
|
@ -49,11 +70,12 @@ export function useApproveUplift(
|
|||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, note }: { id: string | number; note?: string }) =>
|
||||
upliftsApi.approve(id, note),
|
||||
onSuccess: () => {
|
||||
mutationFn: ({ id, note }: ApproveUpliftInput) => upliftsApi.approve(id, note),
|
||||
onSuccess: (_, variables) => {
|
||||
invalidateUpliftQueries(queryClient, dispatchId);
|
||||
toast.success("Uplift approved — NTE updated");
|
||||
toast.success(
|
||||
`Uplift approved — ${decisionAmount(variables.amount)} on ${decisionWorkOrder(variables.woNumber)}`,
|
||||
);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to approve uplift");
|
||||
|
|
@ -67,11 +89,12 @@ export function useRejectUplift(
|
|||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, note }: { id: string | number; note: string }) =>
|
||||
upliftsApi.reject(id, note),
|
||||
onSuccess: () => {
|
||||
mutationFn: ({ id, note }: DecisionUpliftInput) => upliftsApi.reject(id, note),
|
||||
onSuccess: (_, variables) => {
|
||||
invalidateUpliftQueries(queryClient, dispatchId);
|
||||
toast.success("Uplift rejected");
|
||||
toast.success(
|
||||
`Uplift rejected — ${decisionAmount(variables.amount)} on ${decisionWorkOrder(variables.woNumber)}`,
|
||||
);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to reject uplift");
|
||||
|
|
@ -96,3 +119,27 @@ export function useRequestChangesUplift(
|
|||
},
|
||||
});
|
||||
}
|
||||
|
||||
export interface RevokeUpliftInput {
|
||||
upliftId: string | number;
|
||||
reason: string;
|
||||
amount?: number;
|
||||
woNumber?: string;
|
||||
}
|
||||
|
||||
export function useRevokeUplift(): UseMutationResult<void, Error, RevokeUpliftInput> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ upliftId, reason }: RevokeUpliftInput) => upliftsApi.revoke(upliftId, reason),
|
||||
onSuccess: (_, variables) => {
|
||||
invalidateWorkOrderUpliftQueries(queryClient);
|
||||
toast.success(
|
||||
`Uplift revoked — ${decisionAmount(variables.amount)} on ${decisionWorkOrder(variables.woNumber)}`,
|
||||
);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to revoke uplift");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,75 @@
|
|||
import { useState } from "react";
|
||||
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
|
||||
import {
|
||||
useApproveUplift,
|
||||
useRejectUplift,
|
||||
useRevokeUplift,
|
||||
} from "@/domain/uplifts/use-cases/use-uplift-actions";
|
||||
|
||||
export function useUpliftApprovalController() {
|
||||
const [detailItem, setDetailItem] = useState<UpliftQueueItem | null>(null);
|
||||
const [rejectTarget, setRejectTarget] = useState<UpliftQueueItem | null>(null);
|
||||
const [revokeTarget, setRevokeTarget] = useState<UpliftQueueItem | null>(null);
|
||||
const approveUplift = useApproveUplift();
|
||||
const rejectUplift = useRejectUplift();
|
||||
const revokeUplift = useRevokeUplift();
|
||||
|
||||
const handleApprove = (row: UpliftQueueItem) => {
|
||||
approveUplift.mutate({ id: row.id, amount: row.delta, woNumber: row.woNumber });
|
||||
setDetailItem(null);
|
||||
};
|
||||
|
||||
const handleRejectRequest = (row: UpliftQueueItem) => {
|
||||
setDetailItem(null);
|
||||
setRejectTarget(row);
|
||||
};
|
||||
|
||||
const handleRevokeRequest = (row: UpliftQueueItem) => {
|
||||
setDetailItem(null);
|
||||
setRevokeTarget(row);
|
||||
};
|
||||
|
||||
const handleRejectConfirm = (reason: string) => {
|
||||
if (!rejectTarget) return;
|
||||
rejectUplift.mutate(
|
||||
{
|
||||
id: rejectTarget.id,
|
||||
note: reason,
|
||||
amount: rejectTarget.delta,
|
||||
woNumber: rejectTarget.woNumber,
|
||||
},
|
||||
{ onSuccess: () => setRejectTarget(null) },
|
||||
);
|
||||
};
|
||||
|
||||
const handleRevokeConfirm = (reason: string) => {
|
||||
if (!revokeTarget) return;
|
||||
revokeUplift.mutate(
|
||||
{
|
||||
upliftId: revokeTarget.id,
|
||||
reason,
|
||||
amount: revokeTarget.delta,
|
||||
woNumber: revokeTarget.woNumber,
|
||||
},
|
||||
{ onSuccess: () => setRevokeTarget(null) },
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
detailItem,
|
||||
rejectTarget,
|
||||
revokeTarget,
|
||||
approvePending: approveUplift.isPending,
|
||||
rejectPending: rejectUplift.isPending,
|
||||
revokePending: revokeUplift.isPending,
|
||||
handleApprove,
|
||||
handleRejectRequest,
|
||||
handleRevokeRequest,
|
||||
handleRejectConfirm,
|
||||
handleRevokeConfirm,
|
||||
closeDetail: () => setDetailItem(null),
|
||||
closeReject: () => setRejectTarget(null),
|
||||
closeRevoke: () => setRevokeTarget(null),
|
||||
openDetail: setDetailItem,
|
||||
};
|
||||
}
|
||||
17
src/domain/uplifts/use-cases/use-uplifts-can-approve.ts
Normal file
17
src/domain/uplifts/use-cases/use-uplifts-can-approve.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
|
||||
import { upliftsApi } from "@/domain/uplifts/api/uplifts-api";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
|
||||
/**
|
||||
* Backend-authoritative answer to "may the current user decide uplifts at this
|
||||
* tier?" (GET uplifts/can-approve). Combines with the per-row canDecide signal
|
||||
* so decision controls stay disabled for roles the backend would refuse.
|
||||
*/
|
||||
export function useUpliftsCanApprove(tier: number): UseQueryResult<boolean, Error> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.uplifts.canApprove(tier),
|
||||
queryFn: () => upliftsApi.canApprove(tier),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
retry: 1,
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -104,6 +104,7 @@ export const queryKeys = {
|
|||
forDispatch: (dispatchId: string | number) =>
|
||||
[...queryKeys.uplifts.all, "dispatch", dispatchId] as const,
|
||||
evidence: (id: string | number) => [...queryKeys.uplifts.all, "evidence", id] as const,
|
||||
canApprove: (tier: number) => [...queryKeys.uplifts.all, "can-approve", tier] as const,
|
||||
},
|
||||
vendorPos: {
|
||||
all: ["vendorPos"] as const,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,178 @@
|
|||
import { fireEvent, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { UpliftQueueItem, UpliftsQueueResult } from "@/domain/uplifts/types/uplift";
|
||||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
const revokeMutate = vi.fn();
|
||||
|
||||
const queueState = vi.hoisted(() => ({
|
||||
data: null as UpliftsQueueResult | null,
|
||||
}));
|
||||
const authState = vi.hoisted(() => ({
|
||||
userRoles: "Admin",
|
||||
}));
|
||||
|
||||
const approvedItem: UpliftQueueItem = {
|
||||
id: 77,
|
||||
status: "Approved",
|
||||
currentNTE: 500,
|
||||
requestedNTE: 900,
|
||||
delta: 400,
|
||||
vendorReason: "Additional scope",
|
||||
requestedAt: "2026-01-10T08:00:00Z",
|
||||
requestedByVendorName: "Gateway",
|
||||
decidedAt: "2026-01-12T09:30:00Z",
|
||||
decidedByName: "Admin User",
|
||||
decisionNote: "",
|
||||
requiredTier: 1,
|
||||
canDecide: false,
|
||||
expiresAt: "",
|
||||
notificationStatus: "",
|
||||
notificationError: "",
|
||||
evidenceDocumentId: "doc-1",
|
||||
evidenceFileName: "quote.pdf",
|
||||
evidenceContentType: "application/pdf",
|
||||
evidenceSizeBytes: 2048,
|
||||
dispatchNumber: "DSP-77",
|
||||
poNumber: "PO-77",
|
||||
vendorCompanyName: "Gateway Plumbing",
|
||||
workOrderId: 55,
|
||||
dispatchId: 12,
|
||||
woNumber: "WO-55",
|
||||
site: "Site B",
|
||||
serviceName: "HVAC service",
|
||||
attachmentCount: 3,
|
||||
approvedOnWoAuto: 100,
|
||||
approvedOnWoAdmin: 400,
|
||||
approvedOnWoTotal: 500,
|
||||
workOrderClosed: true,
|
||||
};
|
||||
|
||||
const approvedOpenItem: UpliftQueueItem = {
|
||||
...approvedItem,
|
||||
id: 78,
|
||||
woNumber: "WO-56",
|
||||
workOrderClosed: false,
|
||||
};
|
||||
|
||||
vi.mock("@/domain/uplifts/use-cases/use-uplifts-queue", () => ({
|
||||
useUpliftsQueue: () => ({
|
||||
data: queueState.data,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/domain/uplifts/use-cases/use-uplift-actions", () => ({
|
||||
useApproveUplift: () => ({ isPending: false, mutate: vi.fn() }),
|
||||
useRejectUplift: () => ({ isPending: false, mutate: vi.fn() }),
|
||||
useRevokeUplift: () => ({ isPending: false, mutate: revokeMutate }),
|
||||
}));
|
||||
|
||||
vi.mock("@/providers/auth-context", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
useAuthContext: () => ({ user: { userRoles: authState.userRoles } }),
|
||||
}));
|
||||
|
||||
vi.mock("@/domain/uplifts/use-cases/use-uplifts-can-approve", () => ({
|
||||
useUpliftsCanApprove: () => ({ data: true }),
|
||||
}));
|
||||
|
||||
describe("Uplift Approvals approved tab", () => {
|
||||
beforeEach(() => {
|
||||
revokeMutate.mockReset();
|
||||
authState.userRoles = "Admin";
|
||||
queueState.data = {
|
||||
items: [approvedItem],
|
||||
totalCount: 1,
|
||||
page: 1,
|
||||
pageSize: 25,
|
||||
pendingExposureTotal: null,
|
||||
};
|
||||
});
|
||||
|
||||
it("disables Revoke with the closed work order tooltip", async () => {
|
||||
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
|
||||
renderWithProviders(<UpliftQueuePage />);
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Approved" }));
|
||||
expect(screen.getByRole("columnheader", { name: "Approved By" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "Approved At" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("columnheader", { name: "Waiting" })).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Admin User")).toBeInTheDocument();
|
||||
const revoke = await screen.findByRole("button", { name: "Revoke" });
|
||||
expect(revoke).toBeDisabled();
|
||||
fireEvent.mouseOver(revoke);
|
||||
expect(
|
||||
await screen.findByText("This work order is closed. Uplifts can no longer be revoked."),
|
||||
).toBeInTheDocument();
|
||||
expect(revokeMutate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens RevokeDialog for an approved uplift on an open work order", async () => {
|
||||
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).toBeEnabled();
|
||||
fireEvent.click(revoke);
|
||||
expect(screen.getByRole("heading", { name: "Revoke this approval?" })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
"The approved uplift of $900.00 on WO WO-56 will be withdrawn. This does not recover money already spent — it records that the authorization was a mistake.",
|
||||
),
|
||||
).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: [],
|
||||
totalCount: 0,
|
||||
page: 1,
|
||||
pageSize: 25,
|
||||
pendingExposureTotal: null,
|
||||
};
|
||||
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
|
||||
renderWithProviders(<UpliftQueuePage />);
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Approved" }));
|
||||
expect(await screen.findByText("No approved uplifts yet")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Uplifts you approve are archived here for reference."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
183
src/test/app/(protected)/uplifts/uplift-detail-modal.test.tsx
Normal file
183
src/test/app/(protected)/uplifts/uplift-detail-modal.test.tsx
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
import { fireEvent, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { UpliftDetailModal } from "@/app/(protected)/uplifts/_components/uplift-detail-modal";
|
||||
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
|
||||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
const boardDetail = vi.hoisted(() => ({ data: null as Record<string, unknown> | null }));
|
||||
const woUplifts = vi.hoisted(() => ({ data: null as Array<Record<string, unknown>> | null }));
|
||||
|
||||
vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
|
||||
useWorkOrderBoardDetail: () => ({ data: boardDetail.data, isLoading: false, error: null }),
|
||||
}));
|
||||
|
||||
vi.mock("@/domain/work-orders/use-cases/use-work-order-uplifts", () => ({
|
||||
useWorkOrderUplifts: () => ({ data: woUplifts.data, isLoading: false, error: null }),
|
||||
}));
|
||||
|
||||
const canApproveState = vi.hoisted(() => ({ data: true as boolean | undefined }));
|
||||
|
||||
vi.mock("@/domain/uplifts/use-cases/use-uplifts-can-approve", () => ({
|
||||
useUpliftsCanApprove: () => ({ data: canApproveState.data }),
|
||||
}));
|
||||
|
||||
const baseItem: UpliftQueueItem = {
|
||||
id: 41,
|
||||
status: "Pending",
|
||||
currentNTE: 100,
|
||||
requestedNTE: 250,
|
||||
delta: 150,
|
||||
vendorReason: "",
|
||||
requestedAt: "2026-01-15T10:00:00Z",
|
||||
requestedByVendorName: "Gateway",
|
||||
decidedAt: "",
|
||||
decidedByName: "",
|
||||
decisionNote: "",
|
||||
requiredTier: 1,
|
||||
canDecide: true,
|
||||
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: null,
|
||||
};
|
||||
|
||||
function renderModal(overrides: Partial<UpliftQueueItem> = {}, canRevoke = true) {
|
||||
const onApprove = vi.fn();
|
||||
const onReject = vi.fn();
|
||||
const onRevoke = vi.fn();
|
||||
const onOpenAttachment = vi.fn();
|
||||
renderWithProviders(
|
||||
<UpliftDetailModal
|
||||
item={{ ...baseItem, ...overrides }}
|
||||
canRevoke={canRevoke}
|
||||
approvePending={false}
|
||||
revokePending={false}
|
||||
onClose={vi.fn()}
|
||||
onApprove={onApprove}
|
||||
onReject={onReject}
|
||||
onRevoke={onRevoke}
|
||||
onOpenAttachment={onOpenAttachment}
|
||||
/>,
|
||||
);
|
||||
return { onApprove, onReject, onRevoke, onOpenAttachment };
|
||||
}
|
||||
|
||||
describe("UpliftDetailModal", () => {
|
||||
beforeEach(() => {
|
||||
canApproveState.data = true;
|
||||
boardDetail.data = {
|
||||
info: { woNumber: "WO-99", site: "Site A", status: "Open", dueDate: "" },
|
||||
};
|
||||
woUplifts.data = [
|
||||
{ id: 1, status: "auto_approved", amount: 100 },
|
||||
{ id: 2, status: "approved", amount: 250 },
|
||||
];
|
||||
});
|
||||
|
||||
it("shows work order and request placeholders for a pending uplift", () => {
|
||||
renderModal();
|
||||
|
||||
expect(screen.getByText("Unscheduled")).toBeInTheDocument();
|
||||
expect(screen.getByText("No justification provided.")).toBeInTheDocument();
|
||||
expect(screen.getByText("No attachments")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens an evidence attachment through the provided callback", () => {
|
||||
const { onOpenAttachment } = renderModal({
|
||||
evidenceDocumentId: "document-1",
|
||||
evidenceFileName: "quote.pdf",
|
||||
attachmentCount: 1,
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("quote.pdf"));
|
||||
expect(onOpenAttachment).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ evidenceDocumentId: "document-1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the work order uplifts for the approved-on-WO breakdown", () => {
|
||||
renderModal();
|
||||
|
||||
expect(screen.getByText("Auto-approved")).toBeInTheDocument();
|
||||
expect(screen.getByText("Admin-approved")).toBeInTheDocument();
|
||||
expect(screen.getByText("$100.00")).toBeInTheDocument();
|
||||
expect(screen.getByText("$250.00")).toBeInTheDocument();
|
||||
expect(screen.getByText("$350.00")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers status-specific actions for a pending uplift", () => {
|
||||
const { onApprove, onReject } = renderModal();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Approve" }));
|
||||
expect(onApprove).toHaveBeenCalled();
|
||||
expect(screen.queryByRole("button", { name: "Revoke" })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Reject" }));
|
||||
expect(onReject).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disables Approve and Reject in the modal for a Dispatcher the backend refuses", async () => {
|
||||
canApproveState.data = false;
|
||||
const { onApprove, onReject } = renderModal();
|
||||
|
||||
const approve = screen.getByRole("button", { name: "Approve" });
|
||||
const reject = screen.getByRole("button", { name: "Reject" });
|
||||
expect(approve).toBeDisabled();
|
||||
expect(reject).toBeDisabled();
|
||||
fireEvent.mouseOver(approve);
|
||||
expect(await screen.findByText("Requires Tier 1 role")).toBeInTheDocument();
|
||||
fireEvent.click(approve);
|
||||
fireEvent.click(reject);
|
||||
expect(onApprove).not.toHaveBeenCalled();
|
||||
expect(onReject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("offers Revoke for an approved uplift and disables it on a closed work order", () => {
|
||||
const { onRevoke } = renderModal({ status: "Approved", workOrderClosed: true });
|
||||
|
||||
expect(screen.queryByRole("button", { name: "Approve" })).not.toBeInTheDocument();
|
||||
const revoke = screen.getByRole("button", { name: "Revoke" });
|
||||
expect(revoke).toBeDisabled();
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,13 +1,18 @@
|
|||
import { fireEvent, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
|
||||
import type { UpliftQueueItem, UpliftsQueueResult } from "@/domain/uplifts/types/uplift";
|
||||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
const rejectMutate = vi.fn();
|
||||
const approveMutate = vi.fn();
|
||||
const requestChangesMutate = vi.fn();
|
||||
const revokeMutate = vi.fn();
|
||||
const refetch = vi.fn();
|
||||
|
||||
const queueState = vi.hoisted(() => ({
|
||||
data: null as UpliftsQueueResult | null,
|
||||
}));
|
||||
const canApproveState = vi.hoisted(() => ({ data: true as boolean | undefined }));
|
||||
|
||||
const pendingItem: UpliftQueueItem = {
|
||||
id: 41,
|
||||
status: "Pending",
|
||||
|
|
@ -34,11 +39,19 @@ const pendingItem: UpliftQueueItem = {
|
|||
vendorCompanyName: "Gateway Plumbing",
|
||||
workOrderId: 99,
|
||||
dispatchId: 7,
|
||||
woNumber: "WO-99",
|
||||
site: "Site A",
|
||||
serviceName: "Plumbing repair",
|
||||
attachmentCount: null,
|
||||
approvedOnWoAuto: null,
|
||||
approvedOnWoAdmin: null,
|
||||
approvedOnWoTotal: 100,
|
||||
workOrderClosed: false,
|
||||
};
|
||||
|
||||
vi.mock("@/domain/uplifts/use-cases/use-uplifts-queue", () => ({
|
||||
useUpliftsQueue: () => ({
|
||||
data: { items: [pendingItem], totalCount: 1, page: 1, pageSize: 25 },
|
||||
data: queueState.data,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
|
|
@ -49,33 +62,102 @@ vi.mock("@/domain/uplifts/use-cases/use-uplifts-queue", () => ({
|
|||
vi.mock("@/domain/uplifts/use-cases/use-uplift-actions", () => ({
|
||||
useApproveUplift: () => ({ isPending: false, mutate: approveMutate }),
|
||||
useRejectUplift: () => ({ isPending: false, mutate: rejectMutate }),
|
||||
useRequestChangesUplift: () => ({ isPending: false, mutate: requestChangesMutate }),
|
||||
useRevokeUplift: () => ({ isPending: false, mutate: revokeMutate }),
|
||||
}));
|
||||
|
||||
describe("Uplift queue decision dialogs", () => {
|
||||
vi.mock("@/providers/auth-context", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
useAuthContext: () => ({ user: { userRoles: "Admin" } }),
|
||||
}));
|
||||
|
||||
vi.mock("@/domain/uplifts/use-cases/use-uplifts-can-approve", () => ({
|
||||
useUpliftsCanApprove: () => ({ data: canApproveState.data }),
|
||||
}));
|
||||
|
||||
describe("Uplift Approvals decision flow", () => {
|
||||
beforeEach(() => {
|
||||
rejectMutate.mockReset();
|
||||
approveMutate.mockReset();
|
||||
requestChangesMutate.mockReset();
|
||||
revokeMutate.mockReset();
|
||||
refetch.mockReset();
|
||||
canApproveState.data = true;
|
||||
queueState.data = {
|
||||
items: [pendingItem],
|
||||
totalCount: 1,
|
||||
page: 1,
|
||||
pageSize: 25,
|
||||
pendingExposureTotal: 250,
|
||||
};
|
||||
});
|
||||
|
||||
it("opens RejectDialog from Reject and does not reject on Cancel", async () => {
|
||||
it("shows the Uplift Approvals heading, count pill and exposure total", async () => {
|
||||
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
|
||||
renderWithProviders(<UpliftQueuePage />);
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Uplift Approvals" })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("1 pending uplifts")).toBeInTheDocument();
|
||||
expect(screen.getByText("Pending exposure: $250.00")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("approves in one click without a confirmation dialog", async () => {
|
||||
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
|
||||
renderWithProviders(<UpliftQueuePage />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Approve" }));
|
||||
expect(approveMutate).toHaveBeenCalledWith({ id: 41, amount: 150, woNumber: "WO-99" });
|
||||
expect(screen.queryByRole("button", { name: "Confirm" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens RejectDialog with the dispatcher copy and does not reject on Cancel", async () => {
|
||||
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
|
||||
renderWithProviders(<UpliftQueuePage />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Reject" }));
|
||||
expect(screen.getByRole("heading", { name: /reject uplift of \$250/i })).toBeInTheDocument();
|
||||
expect(screen.getByText("The dispatcher sees this reason on WO #WO-99.")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(rejectMutate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps Approve on the generic confirm dialog", async () => {
|
||||
it("shows the pending empty copy when nothing is waiting", async () => {
|
||||
queueState.data = {
|
||||
items: [],
|
||||
totalCount: 0,
|
||||
page: 1,
|
||||
pageSize: 25,
|
||||
pendingExposureTotal: null,
|
||||
};
|
||||
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
|
||||
renderWithProviders(<UpliftQueuePage />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Approve" }));
|
||||
expect(screen.getByRole("button", { name: "Confirm" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Reject uplift" })).not.toBeInTheDocument();
|
||||
expect(screen.getByText("No uplifts waiting for approval.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disables Approve and Reject for a Dispatcher the backend refuses, without dialogs or toasts", async () => {
|
||||
canApproveState.data = false;
|
||||
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
|
||||
renderWithProviders(<UpliftQueuePage />);
|
||||
|
||||
const approve = screen.getByRole("button", { name: "Approve" });
|
||||
const reject = screen.getByRole("button", { name: "Reject" });
|
||||
expect(approve).toBeDisabled();
|
||||
expect(reject).toBeDisabled();
|
||||
fireEvent.mouseOver(approve);
|
||||
expect(await screen.findByText("Requires Tier 1 role")).toBeInTheDocument();
|
||||
fireEvent.click(approve);
|
||||
fireEvent.click(reject);
|
||||
expect(approveMutate).not.toHaveBeenCalled();
|
||||
expect(rejectMutate).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("heading", { name: /reject uplift of/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps Approve and Reject enabled when the backend allows the tier", async () => {
|
||||
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
|
||||
renderWithProviders(<UpliftQueuePage />);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Approve" })).toBeEnabled();
|
||||
expect(screen.getByRole("button", { name: "Reject" })).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
45
src/test/domain/uplifts/api/uplifts-api.test.ts
Normal file
45
src/test/domain/uplifts/api/uplifts-api.test.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const apiGet = vi.fn();
|
||||
const apiPost = vi.fn();
|
||||
const apiRequestRaw = vi.fn();
|
||||
|
||||
vi.mock("@/api/api", () => ({
|
||||
apiGet: (...args: unknown[]) => apiGet(...args),
|
||||
apiPost: (...args: unknown[]) => apiPost(...args),
|
||||
apiRequestRaw: (...args: unknown[]) => apiRequestRaw(...args),
|
||||
}));
|
||||
|
||||
import { upliftsApi } from "@/domain/uplifts/api/uplifts-api";
|
||||
|
||||
describe("upliftsApi", () => {
|
||||
beforeEach(() => {
|
||||
apiGet.mockReset();
|
||||
apiPost.mockReset();
|
||||
apiRequestRaw.mockReset();
|
||||
});
|
||||
|
||||
it("posts admin revoke decisions to the uplift decision endpoint", async () => {
|
||||
apiPost.mockResolvedValueOnce(undefined);
|
||||
|
||||
await upliftsApi.revoke(42, "Scope was already covered");
|
||||
|
||||
expect(apiPost).toHaveBeenCalledWith("uplifts/42/revoke", {
|
||||
note: "Scope was already covered",
|
||||
});
|
||||
});
|
||||
|
||||
it("reads the backend tier decision permission", async () => {
|
||||
apiGet.mockResolvedValueOnce({ status: "Success", data: { canApprove: true } });
|
||||
|
||||
await expect(upliftsApi.canApprove(1)).resolves.toBe(true);
|
||||
|
||||
expect(apiGet).toHaveBeenCalledWith("uplifts/can-approve", { searchParams: { tier: 1 } });
|
||||
});
|
||||
|
||||
it("treats a missing tier permission as false", async () => {
|
||||
apiGet.mockResolvedValueOnce({ status: "Success", data: {} });
|
||||
|
||||
await expect(upliftsApi.canApprove(2)).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,8 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { mapUpliftQueueItem } from "@/domain/uplifts/mappers/uplift-mapper";
|
||||
import {
|
||||
mapUpliftQueueExposureTotal,
|
||||
mapUpliftQueueItem,
|
||||
} from "@/domain/uplifts/mappers/uplift-mapper";
|
||||
|
||||
describe("mapUpliftQueueItem", () => {
|
||||
it("maps ASP.NET PascalCase payload with queue fields", () => {
|
||||
|
|
@ -56,4 +59,71 @@ describe("mapUpliftQueueItem", () => {
|
|||
expect(result.status).toBe("Approved");
|
||||
expect(result.dispatchNumber).toBe("D-1");
|
||||
});
|
||||
|
||||
it("maps queue display fields in PascalCase", () => {
|
||||
const result = mapUpliftQueueItem({
|
||||
id: 1,
|
||||
WoNumber: "WO-9",
|
||||
WorkOrderSite: "Site A",
|
||||
WorkOrderService: "Plumbing",
|
||||
RequestedByName: "Pat Approver",
|
||||
AttachmentCount: 3,
|
||||
WorkOrderAutoApprovedTotal: 100,
|
||||
WorkOrderAdminApprovedTotal: 250,
|
||||
WorkOrderApprovedExposureTotal: 350,
|
||||
WorkOrderClosed: true,
|
||||
});
|
||||
|
||||
expect(result.woNumber).toBe("WO-9");
|
||||
expect(result.site).toBe("Site A");
|
||||
expect(result.serviceName).toBe("Plumbing");
|
||||
expect(result.requestedByVendorName).toBe("Pat Approver");
|
||||
expect(result.attachmentCount).toBe(3);
|
||||
expect(result.approvedOnWoAuto).toBe(100);
|
||||
expect(result.approvedOnWoAdmin).toBe(250);
|
||||
expect(result.approvedOnWoTotal).toBe(350);
|
||||
expect(result.workOrderClosed).toBe(true);
|
||||
});
|
||||
|
||||
it("maps queue display fields in camelCase", () => {
|
||||
const result = mapUpliftQueueItem({
|
||||
id: 2,
|
||||
woNumber: "WO-10",
|
||||
siteCode: "S-2",
|
||||
trade: "HVAC",
|
||||
attachmentCount: 2,
|
||||
autoApprovedTotal: 50,
|
||||
adminApprovedTotal: 75,
|
||||
approvedOnWorkOrderTotal: 125,
|
||||
isWorkOrderClosed: false,
|
||||
});
|
||||
|
||||
expect(result.woNumber).toBe("WO-10");
|
||||
expect(result.site).toBe("S-2");
|
||||
expect(result.serviceName).toBe("HVAC");
|
||||
expect(result.attachmentCount).toBe(2);
|
||||
expect(result.approvedOnWoAuto).toBe(50);
|
||||
expect(result.approvedOnWoAdmin).toBe(75);
|
||||
expect(result.approvedOnWoTotal).toBe(125);
|
||||
expect(result.workOrderClosed).toBe(false);
|
||||
});
|
||||
|
||||
it("tolerates absent optional queue fields", () => {
|
||||
const result = mapUpliftQueueItem({ id: 3 });
|
||||
|
||||
expect(result.woNumber).toBe("");
|
||||
expect(result.site).toBe("");
|
||||
expect(result.serviceName).toBe("");
|
||||
expect(result.attachmentCount).toBeNull();
|
||||
expect(result.approvedOnWoAuto).toBeNull();
|
||||
expect(result.approvedOnWoAdmin).toBeNull();
|
||||
expect(result.approvedOnWoTotal).toBeNull();
|
||||
expect(result.workOrderClosed).toBeNull();
|
||||
});
|
||||
|
||||
it("maps pending exposure total from the queue payload", () => {
|
||||
expect(mapUpliftQueueExposureTotal({ PendingExposureTotal: 1250 })).toBe(1250);
|
||||
expect(mapUpliftQueueExposureTotal({ pendingExposureTotal: "900" })).toBe(900);
|
||||
expect(mapUpliftQueueExposureTotal({})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
48
src/test/domain/uplifts/use-uplift-actions.test.tsx
Normal file
48
src/test/domain/uplifts/use-uplift-actions.test.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const approve = vi.fn();
|
||||
|
||||
vi.mock("@/domain/uplifts/api/uplifts-api", () => ({
|
||||
upliftsApi: {
|
||||
approve: (...args: unknown[]) => approve(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("react-toastify", () => ({
|
||||
toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn() },
|
||||
}));
|
||||
|
||||
import { useApproveUplift } from "@/domain/uplifts/use-cases/use-uplift-actions";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
|
||||
function makeWrapper(client: QueryClient) {
|
||||
return function Wrapper({ children }: { children: ReactNode }) {
|
||||
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
||||
};
|
||||
}
|
||||
|
||||
describe("useApproveUplift", () => {
|
||||
it("refreshes work-order views after a queue decision", async () => {
|
||||
approve.mockResolvedValueOnce(undefined);
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
const invalidateSpy = vi.spyOn(client, "invalidateQueries");
|
||||
const { result } = renderHook(() => useApproveUplift(), {
|
||||
wrapper: makeWrapper(client),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.mutate({ id: 17 });
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
expect(approve).toHaveBeenCalledWith(17, undefined);
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: queryKeys.uplifts.all });
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: queryKeys.workOrders.all });
|
||||
});
|
||||
});
|
||||
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