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 feat/ab/sh-326-team-member
This commit is contained in:
commit
5b6991c93a
31 changed files with 2249 additions and 555 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 decisions: Array<{ path: string; body: unknown }> = [];
|
||||||
const statuses = new Map<number, string>([
|
const statuses = new Map<number, string>([
|
||||||
[41, "Pending"],
|
[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) => {
|
await page.route(/\/api\/uplifts(?:[/?]|$)/, async (route) => {
|
||||||
const request = route.request();
|
const request = route.request();
|
||||||
const path = new URL(request.url()).pathname;
|
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") {
|
if (action && request.method() === "POST") {
|
||||||
const id = Number(action[1]);
|
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() });
|
decisions.push({ path, body: request.postDataJSON() });
|
||||||
await fulfillJson(route, { id, status: statuses.get(id) });
|
await fulfillJson(route, { id, status: statuses.get(id) });
|
||||||
return;
|
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, {
|
await fulfillJson(route, {
|
||||||
total: 2,
|
total: items.length,
|
||||||
items: [
|
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",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.goto("/uplifts");
|
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" });
|
const approvalRow = page.getByRole("row").filter({ hasText: "DSP-41" });
|
||||||
await approvalRow.getByRole("button", { name: "Approve" }).click();
|
await approvalRow.getByRole("button", { name: "Approve" }).click();
|
||||||
await page.getByRole("button", { name: "Confirm" }).click();
|
|
||||||
|
|
||||||
const changesRow = page.getByRole("row").filter({ hasText: "DSP-42" });
|
const changesRow = page.getByRole("row").filter({ hasText: "DSP-42" });
|
||||||
await changesRow.getByRole("button", { name: "Request Changes" }).click();
|
await changesRow.getByRole("button", { name: "Reject" }).click();
|
||||||
await page.getByLabel("Note (required)").fill("Please itemize labor and parts.");
|
await page.getByLabel("Reason for rejection").fill("Please itemize labor and parts.");
|
||||||
await page.getByRole("button", { name: "Confirm" }).click();
|
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
|
await expect
|
||||||
.poll(() => decisions)
|
.poll(() => decisions)
|
||||||
.toEqual([
|
.toEqual([
|
||||||
{ path: "/api/uplifts/41/approve", body: {} },
|
{ path: "/api/uplifts/41/approve", body: {} },
|
||||||
{
|
{
|
||||||
path: "/api/uplifts/42/request-changes",
|
path: "/api/uplifts/42/reject",
|
||||||
body: { note: "Please itemize labor and parts." },
|
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 { useState } from "react";
|
||||||
import { useNavigate } from "react-router";
|
import { Alert, Box, Chip, Stack, Tab, Tabs, TablePagination } from "@mui/material";
|
||||||
import {
|
import {
|
||||||
Alert,
|
UpliftApprovalsTable,
|
||||||
Box,
|
type UpliftApprovalTab,
|
||||||
Button,
|
} from "@/app/(protected)/uplifts/_components/uplift-approvals-table";
|
||||||
Chip,
|
import { UpliftDecisionDialogs } from "@/app/(protected)/uplifts/_components/uplift-decision-dialogs";
|
||||||
CircularProgress,
|
import { UpliftDetailModal } from "@/app/(protected)/uplifts/_components/uplift-detail-modal";
|
||||||
Dialog,
|
import { openUpliftEvidence } from "@/app/(protected)/uplifts/_components/open-uplift-evidence";
|
||||||
DialogActions,
|
import { formatUpliftMoney } from "@/app/(protected)/uplifts/_components/format-uplift-money";
|
||||||
DialogContent,
|
import { isAdminUser } from "@/lib/auth/user-utils";
|
||||||
DialogTitle,
|
import { useAuthContext } from "@/providers/auth-context";
|
||||||
FormControl,
|
import { useUpliftApprovalController } from "@/domain/uplifts/use-cases/use-uplift-approval-controller";
|
||||||
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";
|
|
||||||
import { useUpliftsQueue } from "@/domain/uplifts/use-cases/use-uplifts-queue";
|
import { useUpliftsQueue } from "@/domain/uplifts/use-cases/use-uplifts-queue";
|
||||||
import { formatDateTime } from "@/lib/time-utils";
|
import { Text } from "@/components/ui/text";
|
||||||
import { toast } from "react-toastify";
|
|
||||||
import { RejectDialog } from "@/app/(protected)/uplifts/_components/reject-dialog";
|
|
||||||
|
|
||||||
const STATUS_OPTIONS = ["", ...UPLIFT_STATUSES];
|
const PAGE_SIZE = 25;
|
||||||
|
|
||||||
type UpliftActionType = "approve" | "reject" | "request-changes";
|
function UpliftApprovalsHeader({ pendingExposureTotal }: { pendingExposureTotal: number | null }) {
|
||||||
|
|
||||||
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;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<Stack direction="row" spacing={2} sx={{ alignItems: "baseline" }}>
|
<Box>
|
||||||
<Typography variant="h5" component="h1">
|
<Text variant="title" as="h1">
|
||||||
Uplift Queue
|
Uplift Approvals
|
||||||
</Typography>
|
</Text>
|
||||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
<Text variant="description" tone="muted">
|
||||||
{totalCount} total
|
Requests to increase the approved spend on a work order, above its remaining auto-approval
|
||||||
</Typography>
|
allowance.
|
||||||
{isFetching && !isLoading && <CircularProgress size={16} />}
|
</Text>
|
||||||
</Stack>
|
<Text when={pendingExposureTotal != null} variant="caption" tone="muted">
|
||||||
|
Pending exposure:{" "}
|
||||||
|
{pendingExposureTotal && pendingExposureTotal > 0
|
||||||
|
? formatUpliftMoney(pendingExposureTotal)
|
||||||
|
: "—"}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function UpliftQueueFilters({
|
function UpliftApprovalsTabs({
|
||||||
status,
|
tab,
|
||||||
tier,
|
pendingCount,
|
||||||
onStatusChange,
|
onChange,
|
||||||
onTierChange,
|
|
||||||
}: {
|
}: {
|
||||||
status: string;
|
tab: UpliftApprovalTab;
|
||||||
tier: string;
|
pendingCount: number;
|
||||||
onStatusChange: (value: string) => void;
|
onChange: (_event: unknown, value: string) => void;
|
||||||
onTierChange: (value: string) => void;
|
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Stack direction="row" spacing={2}>
|
<Tabs value={tab} onChange={onChange}>
|
||||||
<FormControl size="small" className="min-w-[140px]">
|
<Tab
|
||||||
<InputLabel>Status</InputLabel>
|
value="pending"
|
||||||
<Select label="Status" value={status} onChange={(e) => onStatusChange(e.target.value)}>
|
label={
|
||||||
{STATUS_OPTIONS.map((s) => (
|
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
||||||
<MenuItem key={s || "all"} value={s}>
|
<span>Pending</span>
|
||||||
{s || "All"}
|
<Chip
|
||||||
</MenuItem>
|
size="small"
|
||||||
))}
|
label={pendingCount}
|
||||||
</Select>
|
aria-label={`${pendingCount} pending uplifts`}
|
||||||
</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>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
) : (
|
}
|
||||||
<Typography variant="caption" color="text.secondary">
|
/>
|
||||||
—
|
<Tab value="approved" label="Approved" />
|
||||||
</Typography>
|
</Tabs>
|
||||||
)}
|
|
||||||
</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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function UpliftQueueTable({
|
function UpliftApprovalsPagination({
|
||||||
isLoading,
|
tab,
|
||||||
rows,
|
count,
|
||||||
onViewWorkOrder,
|
pendingPage,
|
||||||
onDownloadEvidence,
|
approvedPage,
|
||||||
onDecide,
|
onPendingPageChange,
|
||||||
|
onApprovedPageChange,
|
||||||
}: {
|
}: {
|
||||||
isLoading: boolean;
|
tab: UpliftApprovalTab;
|
||||||
rows: UpliftQueueItem[];
|
count: number;
|
||||||
onViewWorkOrder: (row: UpliftQueueItem) => void;
|
pendingPage: number;
|
||||||
onDownloadEvidence: (row: UpliftQueueItem) => void;
|
approvedPage: number;
|
||||||
onDecide: (type: UpliftActionType, row: UpliftQueueItem) => void;
|
onPendingPageChange: (page: number) => void;
|
||||||
|
onApprovedPageChange: (page: number) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<TableContainer component={Paper} variant="outlined">
|
<TablePagination
|
||||||
<Table size="small">
|
component="div"
|
||||||
<TableHead>
|
count={count}
|
||||||
<TableRow>
|
page={tab === "pending" ? pendingPage : approvedPage}
|
||||||
<TableCell className="font-semibold">Dispatch</TableCell>
|
onPageChange={(_event, nextPage) =>
|
||||||
<TableCell className="font-semibold">PO</TableCell>
|
tab === "pending" ? onPendingPageChange(nextPage) : onApprovedPageChange(nextPage)
|
||||||
<TableCell className="font-semibold">Vendor</TableCell>
|
}
|
||||||
<TableCell className="font-semibold">Current NTE</TableCell>
|
rowsPerPage={PAGE_SIZE}
|
||||||
<TableCell className="font-semibold">Requested</TableCell>
|
rowsPerPageOptions={[PAGE_SIZE]}
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function UpliftDecisionDialog({
|
function UpliftQueueError({ error }: { error: unknown }) {
|
||||||
action,
|
if (!error) return null;
|
||||||
note,
|
|
||||||
noteRequired,
|
|
||||||
decisionPending,
|
|
||||||
onNoteChange,
|
|
||||||
onClose,
|
|
||||||
onConfirm,
|
|
||||||
}: {
|
|
||||||
action: UpliftDecision | null;
|
|
||||||
note: string;
|
|
||||||
noteRequired: boolean;
|
|
||||||
decisionPending: boolean;
|
|
||||||
onNoteChange: (value: string) => void;
|
|
||||||
onClose: () => void;
|
|
||||||
onConfirm: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={Boolean(action)} onClose={onClose}>
|
<Alert severity="error">
|
||||||
<DialogTitle>
|
{error instanceof Error ? error.message : "Failed to load uplift queue"}
|
||||||
{action?.type === "approve"
|
</Alert>
|
||||||
? `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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function UpliftQueuePage() {
|
export default function UpliftQueuePage() {
|
||||||
const navigate = useNavigate();
|
const [tab, setTab] = useState<UpliftApprovalTab>("pending");
|
||||||
const [status, setStatus] = useState("Pending");
|
const [pendingPage, setPendingPage] = useState(0);
|
||||||
const [tier, setTier] = useState("");
|
const [approvedPage, setApprovedPage] = useState(0);
|
||||||
const [page, setPage] = useState(0);
|
|
||||||
const [pageSize] = useState(25);
|
|
||||||
const [upliftAction, setUpliftAction] = useState<UpliftDecision | null>(null);
|
|
||||||
const [note, setNote] = useState("");
|
|
||||||
|
|
||||||
const listParams = useMemo(
|
const { user } = useAuthContext();
|
||||||
() => ({ status, tier, page: page + 1, pageSize }),
|
const canRevokeUplifts = isAdminUser(user?.userRoles);
|
||||||
[status, tier, page, pageSize],
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data, isLoading, isFetching, error, refetch } = useUpliftsQueue(listParams);
|
const pendingQuery = useUpliftsQueue({
|
||||||
const approveUplift = useApproveUplift();
|
status: "Pending",
|
||||||
const rejectUplift = useRejectUplift();
|
page: pendingPage + 1,
|
||||||
const requestChangesUplift = useRequestChangesUplift();
|
pageSize: PAGE_SIZE,
|
||||||
|
});
|
||||||
|
const approvedQuery = useUpliftsQueue({
|
||||||
|
status: "Approved",
|
||||||
|
page: approvedPage + 1,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
});
|
||||||
|
|
||||||
const rows = data?.items ?? [];
|
const controller = useUpliftApprovalController();
|
||||||
const totalCount = data?.totalCount ?? 0;
|
const {
|
||||||
const genericAction = upliftAction?.type === "reject" ? null : upliftAction;
|
detailItem,
|
||||||
const noteRequired = genericAction?.type === "request-changes";
|
approvePending,
|
||||||
const decisionPending = approveUplift.isPending || requestChangesUplift.isPending;
|
rejectPending,
|
||||||
|
revokePending,
|
||||||
|
handleApprove,
|
||||||
|
handleRejectRequest,
|
||||||
|
handleRevokeRequest,
|
||||||
|
closeDetail,
|
||||||
|
openDetail,
|
||||||
|
} = controller;
|
||||||
|
|
||||||
const handleStatusChange = (value: string) => {
|
const activeQuery = tab === "pending" ? pendingQuery : approvedQuery;
|
||||||
setStatus(value);
|
const rows = activeQuery.data?.items ?? [];
|
||||||
setPage(0);
|
const pendingCount = pendingQuery.data?.totalCount ?? 0;
|
||||||
};
|
const pendingExposureTotal = pendingQuery.data?.pendingExposureTotal ?? null;
|
||||||
const handleTierChange = (value: string) => {
|
|
||||||
setTier(value);
|
|
||||||
setPage(0);
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeDialog = () => {
|
const handleTabChange = (_event: unknown, value: string) => {
|
||||||
setUpliftAction(null);
|
if (value === "pending" || value === "approved") {
|
||||||
setNote("");
|
setTab(value);
|
||||||
};
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
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 (
|
return (
|
||||||
<Box className="flex flex-col gap-4 p-4">
|
<Box className="flex flex-col gap-4 p-4">
|
||||||
<Stack
|
<UpliftApprovalsHeader pendingExposureTotal={pendingExposureTotal} />
|
||||||
direction={{ xs: "column", md: "row" }}
|
<UpliftApprovalsTabs tab={tab} pendingCount={pendingCount} onChange={handleTabChange} />
|
||||||
spacing={2}
|
<UpliftQueueError error={activeQuery.error} />
|
||||||
sx={{
|
<UpliftApprovalsTable
|
||||||
alignItems: { xs: "stretch", md: "center" },
|
tab={tab}
|
||||||
justifyContent: "space-between",
|
isLoading={activeQuery.isLoading}
|
||||||
}}
|
|
||||||
>
|
|
||||||
<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}
|
|
||||||
rows={rows}
|
rows={rows}
|
||||||
onViewWorkOrder={(r) => navigate(`/workorders/${r.workOrderId}`)}
|
canRevoke={canRevokeUplifts}
|
||||||
onDownloadEvidence={(r) => void downloadEvidence(r)}
|
onOpenDetail={openDetail}
|
||||||
onDecide={handleDecide}
|
onApprove={handleApprove}
|
||||||
|
onReject={handleRejectRequest}
|
||||||
|
onRevoke={handleRevokeRequest}
|
||||||
|
onOpenAttachment={openUpliftEvidence}
|
||||||
|
isDecisionPending={approvePending || rejectPending || revokePending}
|
||||||
/>
|
/>
|
||||||
<TablePagination
|
<UpliftApprovalsPagination
|
||||||
component="div"
|
tab={tab}
|
||||||
count={totalCount}
|
count={activeQuery.data?.totalCount ?? 0}
|
||||||
page={page}
|
pendingPage={pendingPage}
|
||||||
onPageChange={(_e, nextPage) => setPage(nextPage)}
|
approvedPage={approvedPage}
|
||||||
rowsPerPage={pageSize}
|
onPendingPageChange={setPendingPage}
|
||||||
rowsPerPageOptions={[25]}
|
onApprovedPageChange={setApprovedPage}
|
||||||
/>
|
|
||||||
<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}
|
|
||||||
/>
|
/>
|
||||||
|
{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>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useWorkOrdersDayPagination } from "@/app/(protected)/workorders/_hooks/
|
||||||
import { currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils";
|
import { currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils";
|
||||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
||||||
|
import { todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||||
import { pageSelectedDayGroups } from "@/domain/work-orders/utils/day-pagination";
|
import { pageSelectedDayGroups } from "@/domain/work-orders/utils/day-pagination";
|
||||||
import {
|
import {
|
||||||
buildSelectedDayGroup,
|
buildSelectedDayGroup,
|
||||||
|
|
@ -70,9 +71,7 @@ export function useWorkOrdersTableDayView({
|
||||||
});
|
});
|
||||||
const isEmptyWeek = !advActive && scheduled.length === 0 && !searchActive;
|
const isEmptyWeek = !advActive && scheduled.length === 0 && !searchActive;
|
||||||
const todayKey =
|
const todayKey =
|
||||||
filters.weekMonday === currentWeekMonday()
|
filters.weekMonday === currentWeekMonday() ? deriveDayFields(todayIso()).dayGroup : null;
|
||||||
? deriveDayFields(new Date().toISOString().slice(0, 10)).dayGroup
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
scheduled,
|
scheduled,
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,11 @@ import { HTTPError } from "ky";
|
||||||
import { API_PATHS } from "@/api/api-paths";
|
import { API_PATHS } from "@/api/api-paths";
|
||||||
import { apiGet, apiPost, apiRequestRaw } from "@/api/api";
|
import { apiGet, apiPost, apiRequestRaw } from "@/api/api";
|
||||||
import { handleApiResponse } from "@/api/handle-api-response";
|
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 { UpliftsQueueParams, UpliftsQueueResult } from "@/domain/uplifts/types/uplift";
|
||||||
import type { UpliftRequest } from "@/domain/uplifts/types/uplift";
|
import type { UpliftRequest } from "@/domain/uplifts/types/uplift";
|
||||||
|
|
||||||
|
|
@ -57,6 +61,7 @@ export const upliftsApi = {
|
||||||
totalCount: total,
|
totalCount: total,
|
||||||
page: params.page ?? 1,
|
page: params.page ?? 1,
|
||||||
pageSize: params.pageSize ?? 25,
|
pageSize: params.pageSize ?? 25,
|
||||||
|
pendingExposureTotal: mapUpliftQueueExposureTotal(response),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -81,10 +86,22 @@ export const upliftsApi = {
|
||||||
await apiPost<unknown>(`${API_PATHS.rest.uplifts}/${id}/reject`, { note });
|
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> => {
|
requestChanges: async (id: string | number, note: string): Promise<void> => {
|
||||||
await apiPost<unknown>(`${API_PATHS.rest.uplifts}/${id}/request-changes`, { note });
|
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 (
|
downloadEvidence: async (
|
||||||
id: string | number,
|
id: string | number,
|
||||||
fallbackFileName = "uplift-evidence",
|
fallbackFileName = "uplift-evidence",
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,14 @@ function readOptionalId(
|
||||||
return null;
|
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 {
|
export function mapUpliftRequest(raw: unknown): UpliftRequest {
|
||||||
const item = asRecord(raw);
|
const item = asRecord(raw);
|
||||||
return {
|
return {
|
||||||
|
|
@ -49,7 +57,13 @@ export function mapUpliftRequest(raw: unknown): UpliftRequest {
|
||||||
delta: readNumber(item, "delta", "Delta") ?? 0,
|
delta: readNumber(item, "delta", "Delta") ?? 0,
|
||||||
vendorReason: readString(item, "vendorReason", "VendorReason"),
|
vendorReason: readString(item, "vendorReason", "VendorReason"),
|
||||||
requestedAt: readString(item, "requestedAt", "RequestedAt"),
|
requestedAt: readString(item, "requestedAt", "RequestedAt"),
|
||||||
requestedByVendorName: readString(item, "requestedByVendorName", "RequestedByVendorName"),
|
requestedByVendorName: readString(
|
||||||
|
item,
|
||||||
|
"requestedByName",
|
||||||
|
"RequestedByName",
|
||||||
|
"requestedByVendorName",
|
||||||
|
"RequestedByVendorName",
|
||||||
|
),
|
||||||
decidedAt: readString(item, "decidedAt", "DecidedAt"),
|
decidedAt: readString(item, "decidedAt", "DecidedAt"),
|
||||||
decidedByName: readString(item, "decidedByName", "DecidedByName"),
|
decidedByName: readString(item, "decidedByName", "DecidedByName"),
|
||||||
decisionNote: readString(item, "decisionNote", "DecisionNote"),
|
decisionNote: readString(item, "decisionNote", "DecisionNote"),
|
||||||
|
|
@ -79,9 +93,86 @@ export function mapUpliftQueueItem(raw: unknown): UpliftQueueItem {
|
||||||
vendorCompanyName: readString(item, "vendorCompanyName", "VendorCompanyName"),
|
vendorCompanyName: readString(item, "vendorCompanyName", "VendorCompanyName"),
|
||||||
workOrderId: readOptionalId(item, "workOrderId", "WorkOrderId"),
|
workOrderId: readOptionalId(item, "workOrderId", "WorkOrderId"),
|
||||||
dispatchId: readOptionalId(item, "dispatchId", "DispatchId"),
|
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[] {
|
export function mapUpliftQueueItems(rawItems: unknown[]): UpliftQueueItem[] {
|
||||||
return rawItems.map(mapUpliftQueueItem);
|
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;
|
vendorCompanyName: string;
|
||||||
workOrderId: string | number | null;
|
workOrderId: string | number | null;
|
||||||
dispatchId: 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 {
|
export interface UpliftsQueueParams {
|
||||||
|
|
@ -57,4 +65,5 @@ export interface UpliftsQueueResult {
|
||||||
totalCount: number;
|
totalCount: number;
|
||||||
page: number;
|
page: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
|
pendingExposureTotal: number | null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,9 @@ function invalidateUpliftQueries(
|
||||||
dispatchId?: string | number,
|
dispatchId?: string | number,
|
||||||
) {
|
) {
|
||||||
void queryClient.invalidateQueries({ queryKey: queryKeys.uplifts.all });
|
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 !== "") {
|
if (dispatchId !== undefined && dispatchId !== "") {
|
||||||
void queryClient.invalidateQueries({ queryKey: queryKeys.uplifts.forDispatch(dispatchId) });
|
void queryClient.invalidateQueries({ queryKey: queryKeys.uplifts.forDispatch(dispatchId) });
|
||||||
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.dispatch(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 {
|
export interface ApproveUpliftInput {
|
||||||
id: string | number;
|
id: string | number;
|
||||||
note?: string;
|
note?: string;
|
||||||
|
amount?: number;
|
||||||
|
woNumber?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DecisionUpliftInput {
|
export interface DecisionUpliftInput {
|
||||||
id: string | number;
|
id: string | number;
|
||||||
note: string;
|
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(
|
export function useApproveUplift(
|
||||||
|
|
@ -49,11 +70,12 @@ export function useApproveUplift(
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ id, note }: { id: string | number; note?: string }) =>
|
mutationFn: ({ id, note }: ApproveUpliftInput) => upliftsApi.approve(id, note),
|
||||||
upliftsApi.approve(id, note),
|
onSuccess: (_, variables) => {
|
||||||
onSuccess: () => {
|
|
||||||
invalidateUpliftQueries(queryClient, dispatchId);
|
invalidateUpliftQueries(queryClient, dispatchId);
|
||||||
toast.success("Uplift approved — NTE updated");
|
toast.success(
|
||||||
|
`Uplift approved — ${decisionAmount(variables.amount)} on ${decisionWorkOrder(variables.woNumber)}`,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
onError: (error: Error) => {
|
onError: (error: Error) => {
|
||||||
toast.error(error.message || "Failed to approve uplift");
|
toast.error(error.message || "Failed to approve uplift");
|
||||||
|
|
@ -67,11 +89,12 @@ export function useRejectUplift(
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ id, note }: { id: string | number; note: string }) =>
|
mutationFn: ({ id, note }: DecisionUpliftInput) => upliftsApi.reject(id, note),
|
||||||
upliftsApi.reject(id, note),
|
onSuccess: (_, variables) => {
|
||||||
onSuccess: () => {
|
|
||||||
invalidateUpliftQueries(queryClient, dispatchId);
|
invalidateUpliftQueries(queryClient, dispatchId);
|
||||||
toast.success("Uplift rejected");
|
toast.success(
|
||||||
|
`Uplift rejected — ${decisionAmount(variables.amount)} on ${decisionWorkOrder(variables.woNumber)}`,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
onError: (error: Error) => {
|
onError: (error: Error) => {
|
||||||
toast.error(error.message || "Failed to reject uplift");
|
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(
|
export function useWorkOrderBoardDetail(
|
||||||
id: string | number | undefined,
|
id: string | number | undefined,
|
||||||
enabled = true,
|
enabled = true,
|
||||||
|
meta?: { suppressErrorToast?: boolean },
|
||||||
): UseQueryResult<WorkOrderBoardDetail, Error> {
|
): UseQueryResult<WorkOrderBoardDetail, Error> {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: queryKeys.workOrders.boardDetail(id ?? ""),
|
queryKey: queryKeys.workOrders.boardDetail(id ?? ""),
|
||||||
queryFn: () => workOrdersApi.getBoardDetail(requireQueryParam(id, "Work order id")),
|
queryFn: () => workOrdersApi.getBoardDetail(requireQueryParam(id, "Work order id")),
|
||||||
enabled: enabled && id !== undefined && id !== "",
|
enabled: enabled && id !== undefined && id !== "",
|
||||||
|
meta,
|
||||||
// Do not keepPreviousData — placeholder detail from another WO leaks into the slide-over.
|
// Do not keepPreviousData — placeholder detail from another WO leaks into the slide-over.
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,12 +24,14 @@ function invalidateUpliftQueries(
|
||||||
|
|
||||||
export function useWorkOrderUplifts(
|
export function useWorkOrderUplifts(
|
||||||
workOrderId: string | number | null | undefined,
|
workOrderId: string | number | null | undefined,
|
||||||
|
meta?: { suppressErrorToast?: boolean },
|
||||||
): UseQueryResult<WorkOrderUplift[], Error> {
|
): UseQueryResult<WorkOrderUplift[], Error> {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: queryKeys.workOrders.uplifts(workOrderId ?? ""),
|
queryKey: queryKeys.workOrders.uplifts(workOrderId ?? ""),
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
workOrderUpliftsApi.list(requireQueryParam(workOrderId ?? undefined, "Work order id")),
|
workOrderUpliftsApi.list(requireQueryParam(workOrderId ?? undefined, "Work order id")),
|
||||||
enabled: workOrderId != null && workOrderId !== "",
|
enabled: workOrderId != null && workOrderId !== "",
|
||||||
|
meta,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { ADVANCED_FILTER_PREDICATES } from "@/domain/work-orders/utils/advanced-
|
||||||
import { isCarryOverDisplayRow } from "@/domain/work-orders/utils/carry-over-display";
|
import { isCarryOverDisplayRow } from "@/domain/work-orders/utils/carry-over-display";
|
||||||
import { isDateInWeek } from "@/domain/work-orders/utils/table-day-utils";
|
import { isDateInWeek } from "@/domain/work-orders/utils/table-day-utils";
|
||||||
import { weekRange } from "@/domain/work-orders/utils/list-week-utils";
|
import { weekRange } from "@/domain/work-orders/utils/list-week-utils";
|
||||||
|
import { todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||||
|
|
||||||
const TERMINAL_STATUSES = new Set(["Completed", "Canceled", "Done", "Cancelled"]);
|
const TERMINAL_STATUSES = new Set(["Completed", "Canceled", "Done", "Cancelled"]);
|
||||||
|
|
||||||
|
|
@ -17,8 +18,10 @@ export function computeIsPastDue(row: WorkOrderTableRow): boolean {
|
||||||
if (!row.scheduledOn || TERMINAL_STATUSES.has(row.status)) {
|
if (!row.scheduledOn || TERMINAL_STATUSES.has(row.status)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
// scheduledOn is a calendar date, so compare it to the user's local calendar
|
||||||
return row.scheduledOn.slice(0, 10) < today;
|
// day. A UTC "today" rolls over hours early west of UTC and flags work
|
||||||
|
// scheduled for today as Past Due every evening.
|
||||||
|
return row.scheduledOn.slice(0, 10) < todayIso();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function enrichTableRow(row: WorkOrderTableRow): WorkOrderTableRow {
|
export function enrichTableRow(row: WorkOrderTableRow): WorkOrderTableRow {
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,7 @@ export const queryKeys = {
|
||||||
forDispatch: (dispatchId: string | number) =>
|
forDispatch: (dispatchId: string | number) =>
|
||||||
[...queryKeys.uplifts.all, "dispatch", dispatchId] as const,
|
[...queryKeys.uplifts.all, "dispatch", dispatchId] as const,
|
||||||
evidence: (id: string | number) => [...queryKeys.uplifts.all, "evidence", id] 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: {
|
vendorPos: {
|
||||||
all: ["vendorPos"] as const,
|
all: ["vendorPos"] as const,
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,39 @@
|
||||||
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
|
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
export const queryClient = new QueryClient({
|
export function createAppQueryClient(): QueryClient {
|
||||||
queryCache: new QueryCache({
|
return new QueryClient({
|
||||||
onError: (error) => {
|
queryCache: new QueryCache({
|
||||||
console.error("Query error:", error);
|
onError: (error, query) => {
|
||||||
const message =
|
if (query.meta?.suppressErrorToast) return;
|
||||||
error instanceof Error ? error.message : "An error occurred while fetching data";
|
|
||||||
toast.error(message);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
mutationCache: new MutationCache({
|
|
||||||
onError: (error, _variables, _context, mutation) => {
|
|
||||||
if (mutation.options.meta?.suppressErrorToast) return;
|
|
||||||
|
|
||||||
console.error("Mutation error:", error);
|
console.error("Query error:", error);
|
||||||
const message =
|
const message =
|
||||||
error instanceof Error ? error.message : "An error occurred while updating data";
|
error instanceof Error ? error.message : "An error occurred while fetching data";
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
mutationCache: new MutationCache({
|
||||||
|
onError: (error, _variables, _context, mutation) => {
|
||||||
|
if (mutation.options.meta?.suppressErrorToast) return;
|
||||||
|
|
||||||
|
console.error("Mutation error:", error);
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : "An error occurred while updating data";
|
||||||
|
toast.error(message);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
gcTime: 10 * 60 * 1000,
|
||||||
|
retry: 1,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}),
|
});
|
||||||
defaultOptions: {
|
}
|
||||||
queries: {
|
|
||||||
staleTime: 5 * 60 * 1000,
|
export const queryClient = createAppQueryClient();
|
||||||
gcTime: 10 * 60 * 1000,
|
|
||||||
retry: 1,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default queryClient;
|
export default queryClient;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import { screen } from "@testing-library/react";
|
import { screen } from "@testing-library/react";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import PmSchedulesListPage from "@/app/(protected)/pmschedules/index";
|
import PmSchedulesListPage from "@/app/(protected)/pmschedules/index";
|
||||||
|
// Static import: a dynamic import inside the test transforms the edit page's
|
||||||
|
// module graph against the 5s test budget, which times out under load.
|
||||||
|
import EditPage from "@/app/(protected)/pmschedules/edit/[id]";
|
||||||
import { renderWithProviders } from "@/test/test-utils";
|
import { renderWithProviders } from "@/test/test-utils";
|
||||||
|
|
||||||
const usePmSchedulesList = vi.fn();
|
const usePmSchedulesList = vi.fn();
|
||||||
|
|
@ -75,9 +78,7 @@ describe("PmSchedulesListPage actions", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("PmScheduleEditUnavailablePage", () => {
|
describe("PmScheduleEditUnavailablePage", () => {
|
||||||
it("shows unavailable message instead of edit form when canEdit is false", async () => {
|
it("shows unavailable message instead of edit form when canEdit is false", () => {
|
||||||
const EditPage = (await import("@/app/(protected)/pmschedules/edit/[id]")).default;
|
|
||||||
|
|
||||||
renderWithProviders(<EditPage />, {
|
renderWithProviders(<EditPage />, {
|
||||||
route: "/pmschedules/edit/1",
|
route: "/pmschedules/edit/1",
|
||||||
routerProps: { initialEntries: ["/pmschedules/edit/1"] },
|
routerProps: { initialEntries: ["/pmschedules/edit/1"] },
|
||||||
|
|
|
||||||
|
|
@ -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 { fireEvent, screen } from "@testing-library/react";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
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";
|
import { renderWithProviders } from "@/test/test-utils";
|
||||||
|
|
||||||
const rejectMutate = vi.fn();
|
const rejectMutate = vi.fn();
|
||||||
const approveMutate = vi.fn();
|
const approveMutate = vi.fn();
|
||||||
const requestChangesMutate = vi.fn();
|
const revokeMutate = vi.fn();
|
||||||
const refetch = 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 = {
|
const pendingItem: UpliftQueueItem = {
|
||||||
id: 41,
|
id: 41,
|
||||||
status: "Pending",
|
status: "Pending",
|
||||||
|
|
@ -34,11 +39,19 @@ const pendingItem: UpliftQueueItem = {
|
||||||
vendorCompanyName: "Gateway Plumbing",
|
vendorCompanyName: "Gateway Plumbing",
|
||||||
workOrderId: 99,
|
workOrderId: 99,
|
||||||
dispatchId: 7,
|
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", () => ({
|
vi.mock("@/domain/uplifts/use-cases/use-uplifts-queue", () => ({
|
||||||
useUpliftsQueue: () => ({
|
useUpliftsQueue: () => ({
|
||||||
data: { items: [pendingItem], totalCount: 1, page: 1, pageSize: 25 },
|
data: queueState.data,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isFetching: false,
|
isFetching: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
|
@ -49,33 +62,102 @@ vi.mock("@/domain/uplifts/use-cases/use-uplifts-queue", () => ({
|
||||||
vi.mock("@/domain/uplifts/use-cases/use-uplift-actions", () => ({
|
vi.mock("@/domain/uplifts/use-cases/use-uplift-actions", () => ({
|
||||||
useApproveUplift: () => ({ isPending: false, mutate: approveMutate }),
|
useApproveUplift: () => ({ isPending: false, mutate: approveMutate }),
|
||||||
useRejectUplift: () => ({ isPending: false, mutate: rejectMutate }),
|
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(() => {
|
beforeEach(() => {
|
||||||
rejectMutate.mockReset();
|
rejectMutate.mockReset();
|
||||||
approveMutate.mockReset();
|
approveMutate.mockReset();
|
||||||
requestChangesMutate.mockReset();
|
revokeMutate.mockReset();
|
||||||
refetch.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");
|
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
|
||||||
renderWithProviders(<UpliftQueuePage />);
|
renderWithProviders(<UpliftQueuePage />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Reject" }));
|
fireEvent.click(screen.getByRole("button", { name: "Reject" }));
|
||||||
expect(screen.getByRole("heading", { name: /reject uplift of \$250/i })).toBeInTheDocument();
|
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" }));
|
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
expect(rejectMutate).not.toHaveBeenCalled();
|
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");
|
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
|
||||||
renderWithProviders(<UpliftQueuePage />);
|
renderWithProviders(<UpliftQueuePage />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Approve" }));
|
expect(screen.getByText("No uplifts waiting for approval.")).toBeInTheDocument();
|
||||||
expect(screen.getByRole("button", { name: "Confirm" })).toBeInTheDocument();
|
});
|
||||||
expect(screen.queryByRole("button", { name: "Reject uplift" })).not.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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { VendorCreateModal } from "@/app/(protected)/vendors/_components/vendor-create-modal";
|
import { VendorCreateModal } from "@/app/(protected)/vendors/_components/vendor-create-modal";
|
||||||
import { VendorRosterFormFields } from "@/app/(protected)/vendors/_components/vendor-roster-form-fields";
|
import { VendorRosterFormFields } from "@/app/(protected)/vendors/_components/vendor-roster-form-fields";
|
||||||
import {
|
import {
|
||||||
|
|
@ -49,6 +49,10 @@ function renderCreateModal() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// delay: null skips user-event's per-keystroke setTimeout yields, which only
|
||||||
|
// add real-timer latency here.
|
||||||
|
let user = userEvent.setup({ delay: null });
|
||||||
|
|
||||||
async function fillCompanyNameAndSubmit(name = "Gateway Plumbing") {
|
async function fillCompanyNameAndSubmit(name = "Gateway Plumbing") {
|
||||||
fireEvent.change(screen.getByRole("combobox", { name: "Company (required)" }), {
|
fireEvent.change(screen.getByRole("combobox", { name: "Company (required)" }), {
|
||||||
target: { value: name },
|
target: { value: name },
|
||||||
|
|
@ -57,7 +61,7 @@ async function fillCompanyNameAndSubmit(name = "Gateway Plumbing") {
|
||||||
// phone-or-email rule, so the address is filled to keep the form's only
|
// phone-or-email rule, so the address is filled to keep the form's only
|
||||||
// outstanding problem the one under test.
|
// outstanding problem the one under test.
|
||||||
fillRequiredAddress();
|
fillRequiredAddress();
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Add Vendor" }));
|
await user.click(screen.getByRole("button", { name: "Add Vendor" }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function fillRequiredAddress({
|
function fillRequiredAddress({
|
||||||
|
|
@ -74,7 +78,15 @@ function fillRequiredAddress({
|
||||||
pickVendorState(state);
|
pickVendorState(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("VendorCreateModal validation", () => {
|
// Each test mounts the full MUI dialog (Autocompletes, 50-option State list)
|
||||||
|
// and costs ~1s of CPU when idle; the first also pays JIT warm-up. Under
|
||||||
|
// parallel agent load the 5s default is exceeded by CPU starvation alone,
|
||||||
|
// matching the 10s budgets already used by vendors-list.test.tsx.
|
||||||
|
describe("VendorCreateModal validation", { timeout: 15_000 }, () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
user = userEvent.setup({ delay: null });
|
||||||
|
});
|
||||||
|
|
||||||
it("explains the company phone-or-email requirement after submission", async () => {
|
it("explains the company phone-or-email requirement after submission", async () => {
|
||||||
renderCreateModal();
|
renderCreateModal();
|
||||||
await fillCompanyNameAndSubmit();
|
await fillCompanyNameAndSubmit();
|
||||||
|
|
@ -138,8 +150,8 @@ describe("VendorCreateModal validation", () => {
|
||||||
const phone = screen.getByRole("textbox", { name: "Company Phone (optional)" });
|
const phone = screen.getByRole("textbox", { name: "Company Phone (optional)" });
|
||||||
const email = screen.getByRole("textbox", { name: "Email (optional)" });
|
const email = screen.getByRole("textbox", { name: "Email (optional)" });
|
||||||
|
|
||||||
await userEvent.type(phone, "314");
|
await user.type(phone, "314");
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Add Vendor" }));
|
await user.click(screen.getByRole("button", { name: "Add Vendor" }));
|
||||||
|
|
||||||
expect(await screen.findByText("Enter a 10-digit phone number")).toBeInTheDocument();
|
expect(await screen.findByText("Enter a 10-digit phone number")).toBeInTheDocument();
|
||||||
expect(phone).toHaveAttribute("aria-invalid", "true");
|
expect(phone).toHaveAttribute("aria-invalid", "true");
|
||||||
|
|
@ -153,8 +165,8 @@ describe("VendorCreateModal validation", () => {
|
||||||
const phone = screen.getByRole("textbox", { name: "Company Phone (optional)" });
|
const phone = screen.getByRole("textbox", { name: "Company Phone (optional)" });
|
||||||
const email = screen.getByRole("textbox", { name: "Email (optional)" });
|
const email = screen.getByRole("textbox", { name: "Email (optional)" });
|
||||||
|
|
||||||
await userEvent.type(email, "not-an-email");
|
await user.type(email, "not-an-email");
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Add Vendor" }));
|
await user.click(screen.getByRole("button", { name: "Add Vendor" }));
|
||||||
|
|
||||||
expect(await screen.findByText("Invalid email")).toBeInTheDocument();
|
expect(await screen.findByText("Invalid email")).toBeInTheDocument();
|
||||||
expect(email).toHaveAttribute("aria-invalid", "true");
|
expect(email).toHaveAttribute("aria-invalid", "true");
|
||||||
|
|
@ -169,7 +181,7 @@ describe("VendorCreateModal validation", () => {
|
||||||
|
|
||||||
expect(await screen.findByText(VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE)).toBeInTheDocument();
|
expect(await screen.findByText(VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE)).toBeInTheDocument();
|
||||||
|
|
||||||
await userEvent.type(phone, "3145550100");
|
await user.type(phone, "3145550100");
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(screen.queryByText(VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE)).not.toBeInTheDocument(),
|
expect(screen.queryByText(VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE)).not.toBeInTheDocument(),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -68,11 +68,19 @@ function rosterWith(technicians: Array<Record<string, unknown>>) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// delay: null skips user-event's per-keystroke setTimeout yields, which only
|
||||||
|
// add real-timer latency here.
|
||||||
|
let user = userEvent.setup({ delay: null });
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
saveMutate.mockReset();
|
saveMutate.mockReset();
|
||||||
|
user = userEvent.setup({ delay: null });
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("VendorDetailDrawer selected-technician display", () => {
|
// The edit-mode drawer is a heavy MUI render and the first test pays JIT
|
||||||
|
// warm-up; under parallel agent load the 5s default is exceeded by CPU
|
||||||
|
// starvation alone, matching the 10s budgets in vendors-list.test.tsx.
|
||||||
|
describe("VendorDetailDrawer selected-technician display", { timeout: 15_000 }, () => {
|
||||||
it.each([
|
it.each([
|
||||||
{ removedPosition: 1, remainingId: 2, remainingName: "Beth" },
|
{ removedPosition: 1, remainingId: 2, remainingName: "Beth" },
|
||||||
{ removedPosition: 2, remainingId: 1, remainingName: "Adam" },
|
{ removedPosition: 2, remainingId: 1, remainingName: "Adam" },
|
||||||
|
|
@ -107,10 +115,10 @@ describe("VendorDetailDrawer selected-technician display", () => {
|
||||||
{ route: "/vendors", withAuth: false },
|
{ route: "/vendors", withAuth: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
await userEvent.click(
|
await user.click(
|
||||||
screen.getByRole("button", { name: `Remove technician ${removedPosition}` }),
|
screen.getByRole("button", { name: `Remove technician ${removedPosition}` }),
|
||||||
);
|
);
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Save changes" }));
|
await user.click(screen.getByRole("button", { name: "Save changes" }));
|
||||||
|
|
||||||
await waitFor(() => expect(saveMutate).toHaveBeenCalledTimes(1));
|
await waitFor(() => expect(saveMutate).toHaveBeenCalledTimes(1));
|
||||||
expect(saveMutate).toHaveBeenCalledWith(
|
expect(saveMutate).toHaveBeenCalledWith(
|
||||||
|
|
@ -161,12 +169,12 @@ describe("VendorDetailDrawer selected-technician display", () => {
|
||||||
{ route: "/vendors", withAuth: false },
|
{ route: "/vendors", withAuth: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Remove technician 2" }));
|
await user.click(screen.getByRole("button", { name: "Remove technician 2" }));
|
||||||
|
|
||||||
const save = screen.getByRole("button", { name: "Save changes" });
|
const save = screen.getByRole("button", { name: "Save changes" });
|
||||||
expect(save).toBeEnabled();
|
expect(save).toBeEnabled();
|
||||||
|
|
||||||
await userEvent.click(save);
|
await user.click(save);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
await screen.findByText("Provide a company phone or email (at least one required)"),
|
await screen.findByText("Provide a company phone or email (at least one required)"),
|
||||||
|
|
@ -444,10 +452,10 @@ describe("VendorDetailDrawer deactivation prompt", () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
const toggle = screen.getByRole("switch", { name: "Active status" });
|
const toggle = screen.getByRole("switch", { name: "Active status" });
|
||||||
await userEvent.click(toggle);
|
await user.click(toggle);
|
||||||
expect(toggle).toBeChecked();
|
expect(toggle).toBeChecked();
|
||||||
|
|
||||||
await userEvent.click(toggle);
|
await user.click(toggle);
|
||||||
|
|
||||||
expect(toggle).not.toBeChecked();
|
expect(toggle).not.toBeChecked();
|
||||||
expect(onRequestDeactivation).not.toHaveBeenCalled();
|
expect(onRequestDeactivation).not.toHaveBeenCalled();
|
||||||
|
|
@ -472,7 +480,7 @@ describe("VendorDetailDrawer deactivation prompt", () => {
|
||||||
{ route: "/vendors", withAuth: false },
|
{ route: "/vendors", withAuth: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
await userEvent.click(screen.getByRole("switch", { name: "Active status" }));
|
await user.click(screen.getByRole("switch", { name: "Active status" }));
|
||||||
|
|
||||||
expect(onRequestDeactivation).toHaveBeenCalledWith(vendor);
|
expect(onRequestDeactivation).toHaveBeenCalledWith(vendor);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { WorkOrderBoardSearchQueryParams } from "@/domain/work-orders/types/work-order-board";
|
import type { WorkOrderBoardSearchQueryParams } from "@/domain/work-orders/types/work-order-board";
|
||||||
import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters";
|
import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters";
|
||||||
|
|
@ -120,6 +120,12 @@ function pagedResult(rows: WorkOrderTableRow[], page: number, pageSize: number)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Role and text queries over the whole board scan every row cell; scoping them
|
||||||
|
// to the pagination footer keeps each waitFor poll cheap on a loaded machine.
|
||||||
|
function pagination() {
|
||||||
|
return within(screen.getByTestId("wo-day-pagination"));
|
||||||
|
}
|
||||||
|
|
||||||
function pagesAfter(callCount: number): number[] {
|
function pagesAfter(callCount: number): number[] {
|
||||||
return searchBoard.mock.calls.slice(callCount).map((call) => {
|
return searchBoard.mock.calls.slice(callCount).map((call) => {
|
||||||
const params = call[0] as WorkOrderBoardSearchQueryParams;
|
const params = call[0] as WorkOrderBoardSearchQueryParams;
|
||||||
|
|
@ -213,14 +219,14 @@ describe("advanced search page sync after mutation", () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
expect(screen.getByText("WO-0")).toBeInTheDocument();
|
expect(screen.getByText("WO-0")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Page 1 of 2")).toBeInTheDocument();
|
expect(pagination().getByText("Page 1 of 2")).toBeInTheDocument();
|
||||||
expect(screen.queryByText("WO-25")).not.toBeInTheDocument();
|
expect(screen.queryByText("WO-25")).not.toBeInTheDocument();
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Next page" }));
|
fireEvent.click(pagination().getByRole("button", { name: "Next page" }));
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTestId("harness-state")).toHaveTextContent("page=1");
|
expect(screen.getByTestId("harness-state")).toHaveTextContent("page=1");
|
||||||
expect(screen.getByText("WO-25")).toBeInTheDocument();
|
expect(screen.getByText("WO-25")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Page 2 of 2")).toBeInTheDocument();
|
expect(pagination().getByText("Page 2 of 2")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
const callsBeforeMutation = searchBoard.mock.calls.length;
|
const callsBeforeMutation = searchBoard.mock.calls.length;
|
||||||
|
|
@ -236,12 +242,12 @@ describe("advanced search page sync after mutation", () => {
|
||||||
expect(screen.getByTestId("harness-state")).toHaveTextContent(
|
expect(screen.getByTestId("harness-state")).toHaveTextContent(
|
||||||
"adv=true rows=20 total=20 page=0",
|
"adv=true rows=20 total=20 page=0",
|
||||||
);
|
);
|
||||||
expect(screen.getByText("Page 1 of 1")).toBeInTheDocument();
|
expect(pagination().getByText("Page 1 of 1")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
expect(screen.getByText("WO-0")).toBeInTheDocument();
|
expect(screen.getByText("WO-0")).toBeInTheDocument();
|
||||||
expect(screen.queryByText("WO-25")).not.toBeInTheDocument();
|
expect(screen.queryByText("WO-25")).not.toBeInTheDocument();
|
||||||
expect(screen.getByText("RESULTS · 20 items")).toBeInTheDocument();
|
expect(screen.getByText("RESULTS · 20 items")).toBeInTheDocument();
|
||||||
expect(screen.getByRole("button", { name: "Previous page" })).toBeDisabled();
|
expect(pagination().getByRole("button", { name: "Previous page" })).toBeDisabled();
|
||||||
expect(screen.getByRole("button", { name: "Next page" })).toBeDisabled();
|
expect(pagination().getByRole("button", { name: "Next page" })).toBeDisabled();
|
||||||
}, 15000);
|
}, 15000);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
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 { describe, expect, it } from "vitest";
|
||||||
import { mapUpliftQueueItem } from "@/domain/uplifts/mappers/uplift-mapper";
|
import {
|
||||||
|
mapUpliftQueueExposureTotal,
|
||||||
|
mapUpliftQueueItem,
|
||||||
|
} from "@/domain/uplifts/mappers/uplift-mapper";
|
||||||
|
|
||||||
describe("mapUpliftQueueItem", () => {
|
describe("mapUpliftQueueItem", () => {
|
||||||
it("maps ASP.NET PascalCase payload with queue fields", () => {
|
it("maps ASP.NET PascalCase payload with queue fields", () => {
|
||||||
|
|
@ -56,4 +59,71 @@ describe("mapUpliftQueueItem", () => {
|
||||||
expect(result.status).toBe("Approved");
|
expect(result.status).toBe("Approved");
|
||||||
expect(result.dispatchNumber).toBe("D-1");
|
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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { EMPTY_UPLIFT_SUMMARY } from "@/domain/work-orders/types/work-order-uplift";
|
import { EMPTY_UPLIFT_SUMMARY } from "@/domain/work-orders/types/work-order-uplift";
|
||||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters";
|
import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters";
|
||||||
|
|
@ -526,6 +526,41 @@ describe("computeIsPastDue", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("computeIsPastDue across the local/UTC day boundary", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// UTC-3 with no DST: local evening is already the next day in UTC.
|
||||||
|
vi.stubEnv("TZ", "America/Sao_Paulo");
|
||||||
|
vi.useFakeTimers({ toFake: ["Date"] });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not flag today's work as Past Due at 23:30 local (02:30 UTC next day)", () => {
|
||||||
|
vi.setSystemTime(new Date("2026-09-17T02:30:00.000Z"));
|
||||||
|
|
||||||
|
expect(computeIsPastDue(makeRow({ scheduledOn: "2026-09-16", status: "Scheduled" }))).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(computeIsPastDue(makeRow({ scheduledOn: "2026-09-15", status: "Scheduled" }))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags yesterday's work as Past Due at 00:30 local", () => {
|
||||||
|
vi.setSystemTime(new Date("2026-09-17T03:30:00.000Z"));
|
||||||
|
|
||||||
|
expect(computeIsPastDue(makeRow({ scheduledOn: "2026-09-16", status: "Scheduled" }))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(computeIsPastDue(makeRow({ scheduledOn: "2026-09-17", status: "Scheduled" }))).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("enrichTableRow", () => {
|
describe("enrichTableRow", () => {
|
||||||
it("recomputes isPastDue from scheduledOn and ignores sticky API true", () => {
|
it("recomputes isPastDue from scheduledOn and ignores sticky API true", () => {
|
||||||
const enriched = enrichTableRow(
|
const enriched = enrichTableRow(
|
||||||
|
|
|
||||||
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