mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
Compare commits
3 commits
6b84db18ca
...
6a2b9eb80c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a2b9eb80c | ||
|
|
5aed31392c | ||
|
|
6f8b502116 |
13 changed files with 211 additions and 61 deletions
2
e2e/vendors/vendor-uplift-workflow.spec.ts
vendored
2
e2e/vendors/vendor-uplift-workflow.spec.ts
vendored
|
|
@ -367,7 +367,7 @@ test("internal approver can approve, reject, and revoke with audited notes", asy
|
|||
},
|
||||
{
|
||||
path: "/api/uplifts/41/revoke",
|
||||
body: { reason: "Approval was made in error." },
|
||||
body: { note: "Approval was made in error." },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import {
|
|||
import { formatUpliftMoney } from "@/app/(protected)/uplifts/_components/format-uplift-money";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
|
||||
import { getWaitTimeTextClass, timeSince, waitTimeColor } from "@/lib/time-utils";
|
||||
import { formatDateTime, getWaitTimeTextClass, timeSince, waitTimeColor } from "@/lib/time-utils";
|
||||
|
||||
export type UpliftApprovalTab = "pending" | "approved";
|
||||
|
||||
|
|
@ -42,7 +42,13 @@ function WaitingCell({ row }: { row: UpliftQueueItem }) {
|
|||
);
|
||||
}
|
||||
|
||||
function AttachmentsCell({ row }: { row: UpliftQueueItem }) {
|
||||
function AttachmentsCell({
|
||||
row,
|
||||
onOpenAttachment,
|
||||
}: {
|
||||
row: UpliftQueueItem;
|
||||
onOpenAttachment: (row: UpliftQueueItem) => void;
|
||||
}) {
|
||||
if (row.evidenceDocumentId == null && !row.evidenceFileName) {
|
||||
return (
|
||||
<Text variant="caption" tone="muted">
|
||||
|
|
@ -56,7 +62,12 @@ function AttachmentsCell({ row }: { row: UpliftQueueItem }) {
|
|||
: 0;
|
||||
return (
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
|
||||
<Chip size="small" label={row.evidenceFileName || "Attachment"} />
|
||||
<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>
|
||||
);
|
||||
|
|
@ -66,10 +77,12 @@ function PendingRowActions({
|
|||
row,
|
||||
onApprove,
|
||||
onReject,
|
||||
isDecisionPending,
|
||||
}: {
|
||||
row: UpliftQueueItem;
|
||||
onApprove: (row: UpliftQueueItem) => void;
|
||||
onReject: (row: UpliftQueueItem) => void;
|
||||
isDecisionPending: boolean;
|
||||
}) {
|
||||
const tooltip = row.canDecide ? "" : `Requires Tier ${row.requiredTier} role`;
|
||||
return (
|
||||
|
|
@ -80,7 +93,7 @@ function PendingRowActions({
|
|||
size="small"
|
||||
variant="contained"
|
||||
color="success"
|
||||
disabled={!row.canDecide}
|
||||
disabled={!row.canDecide || isDecisionPending}
|
||||
onClick={() => onApprove(row)}
|
||||
>
|
||||
Approve
|
||||
|
|
@ -93,7 +106,7 @@ function PendingRowActions({
|
|||
size="small"
|
||||
variant="contained"
|
||||
color="error"
|
||||
disabled={!row.canDecide}
|
||||
disabled={!row.canDecide || isDecisionPending}
|
||||
onClick={() => onReject(row)}
|
||||
>
|
||||
Reject
|
||||
|
|
@ -107,9 +120,11 @@ function PendingRowActions({
|
|||
function ApprovedRowActions({
|
||||
row,
|
||||
onRevoke,
|
||||
isDecisionPending,
|
||||
}: {
|
||||
row: UpliftQueueItem;
|
||||
onRevoke: (row: UpliftQueueItem) => void;
|
||||
isDecisionPending: boolean;
|
||||
}) {
|
||||
const closed = row.workOrderClosed === true;
|
||||
return (
|
||||
|
|
@ -120,7 +135,7 @@ function ApprovedRowActions({
|
|||
size="small"
|
||||
variant="outlined"
|
||||
color="error"
|
||||
disabled={closed || row.workOrderId == null}
|
||||
disabled={isDecisionPending || closed || row.workOrderId == null}
|
||||
onClick={() => onRevoke(row)}
|
||||
>
|
||||
Revoke
|
||||
|
|
@ -138,6 +153,8 @@ function UpliftApprovalRow({
|
|||
onApprove,
|
||||
onReject,
|
||||
onRevoke,
|
||||
onOpenAttachment,
|
||||
isDecisionPending,
|
||||
}: {
|
||||
row: UpliftQueueItem;
|
||||
tab: UpliftApprovalTab;
|
||||
|
|
@ -145,6 +162,8 @@ function UpliftApprovalRow({
|
|||
onApprove: (row: UpliftQueueItem) => void;
|
||||
onReject: (row: UpliftQueueItem) => void;
|
||||
onRevoke: (row: UpliftQueueItem) => void;
|
||||
onOpenAttachment: (row: UpliftQueueItem) => void;
|
||||
isDecisionPending: boolean;
|
||||
}) {
|
||||
return (
|
||||
<TableRow
|
||||
|
|
@ -201,24 +220,36 @@ function UpliftApprovalRow({
|
|||
</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<AttachmentsCell row={row} />
|
||||
<AttachmentsCell row={row} onOpenAttachment={onOpenAttachment} />
|
||||
</TableCell>
|
||||
<TableCell>{row.requestedByVendorName || "—"}</TableCell>
|
||||
<TableCell>
|
||||
<WaitingCell row={row} />
|
||||
</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} />
|
||||
<PendingRowActions
|
||||
row={row}
|
||||
onApprove={onApprove}
|
||||
onReject={onReject}
|
||||
isDecisionPending={isDecisionPending}
|
||||
/>
|
||||
) : (
|
||||
<ApprovedRowActions row={row} onRevoke={onRevoke} />
|
||||
<ApprovedRowActions row={row} onRevoke={onRevoke} isDecisionPending={isDecisionPending} />
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
const HEADER_CELLS = [
|
||||
const PENDING_HEADER_CELLS = [
|
||||
"Work Order",
|
||||
"Service / Vendor",
|
||||
"Amount",
|
||||
|
|
@ -230,6 +261,19 @@ const HEADER_CELLS = [
|
|||
"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,
|
||||
|
|
@ -238,6 +282,8 @@ export function UpliftApprovalsTable({
|
|||
onApprove,
|
||||
onReject,
|
||||
onRevoke,
|
||||
onOpenAttachment,
|
||||
isDecisionPending,
|
||||
}: {
|
||||
tab: UpliftApprovalTab;
|
||||
isLoading: boolean;
|
||||
|
|
@ -246,13 +292,16 @@ export function UpliftApprovalsTable({
|
|||
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>
|
||||
{HEADER_CELLS.map((label) => (
|
||||
{headerCells.map((label) => (
|
||||
<TableCell key={label} className="font-semibold">
|
||||
{label}
|
||||
</TableCell>
|
||||
|
|
@ -262,13 +311,13 @@ export function UpliftApprovalsTable({
|
|||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={HEADER_CELLS.length} align="center" className="py-10">
|
||||
<TableCell colSpan={headerCells.length} align="center" className="py-10">
|
||||
<CircularProgress size={28} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={HEADER_CELLS.length} align="center" className="py-10">
|
||||
<TableCell colSpan={headerCells.length} align="center" className="py-10">
|
||||
{tab === "pending" ? (
|
||||
<Text variant="description" tone="muted">
|
||||
No uplifts waiting for approval.
|
||||
|
|
@ -293,6 +342,8 @@ export function UpliftApprovalsTable({
|
|||
onApprove={onApprove}
|
||||
onReject={onReject}
|
||||
onRevoke={onRevoke}
|
||||
onOpenAttachment={onOpenAttachment}
|
||||
isDecisionPending={isDecisionPending}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ type ModalCallbacks = {
|
|||
onApprove: (row: UpliftQueueItem) => void;
|
||||
onReject: (row: UpliftQueueItem) => void;
|
||||
onRevoke: (row: UpliftQueueItem) => void;
|
||||
onOpenAttachment: (row: UpliftQueueItem) => void;
|
||||
};
|
||||
|
||||
function DetailField({ label, children }: { label: string; children: ReactNode }) {
|
||||
|
|
@ -49,7 +50,13 @@ function DetailSection({ title, children }: { title: string; children: ReactNode
|
|||
);
|
||||
}
|
||||
|
||||
function UpliftAttachments({ item }: { item: UpliftQueueItem }) {
|
||||
function UpliftAttachments({
|
||||
item,
|
||||
onOpenAttachment,
|
||||
}: {
|
||||
item: UpliftQueueItem;
|
||||
onOpenAttachment: (row: UpliftQueueItem) => void;
|
||||
}) {
|
||||
if (item.evidenceDocumentId == null && !item.evidenceFileName) {
|
||||
return (
|
||||
<Text variant="description" tone="muted">
|
||||
|
|
@ -63,7 +70,12 @@ function UpliftAttachments({ item }: { item: UpliftQueueItem }) {
|
|||
: 0;
|
||||
return (
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5, flexWrap: "wrap" }}>
|
||||
<Chip size="small" label={item.evidenceFileName || "Attachment"} />
|
||||
<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>
|
||||
);
|
||||
|
|
@ -91,7 +103,13 @@ function WorkOrderSection({ item, info }: { item: UpliftQueueItem; info?: WorkOr
|
|||
);
|
||||
}
|
||||
|
||||
function RequestSection({ item }: { item: UpliftQueueItem }) {
|
||||
function RequestSection({
|
||||
item,
|
||||
onOpenAttachment,
|
||||
}: {
|
||||
item: UpliftQueueItem;
|
||||
onOpenAttachment: (row: UpliftQueueItem) => void;
|
||||
}) {
|
||||
const waitingClass = item.requestedAt
|
||||
? getWaitTimeTextClass(waitTimeColor(item.requestedAt))
|
||||
: undefined;
|
||||
|
|
@ -114,7 +132,7 @@ function RequestSection({ item }: { item: UpliftQueueItem }) {
|
|||
<Text variant="description">{item.vendorReason || "No justification provided."}</Text>
|
||||
</DetailField>
|
||||
<DetailField label="Attachments">
|
||||
<UpliftAttachments item={item} />
|
||||
<UpliftAttachments item={item} onOpenAttachment={onOpenAttachment} />
|
||||
</DetailField>
|
||||
</DetailSection>
|
||||
);
|
||||
|
|
@ -269,6 +287,7 @@ export function UpliftDetailModal({
|
|||
onApprove,
|
||||
onReject,
|
||||
onRevoke,
|
||||
onOpenAttachment,
|
||||
}: {
|
||||
item: UpliftQueueItem | null;
|
||||
approvePending: boolean;
|
||||
|
|
@ -326,7 +345,7 @@ export function UpliftDetailModal({
|
|||
<DialogContent>
|
||||
<Box sx={{ display: "grid", gap: 3 }}>
|
||||
<WorkOrderSection item={item} info={info} />
|
||||
<RequestSection item={item} />
|
||||
<RequestSection item={item} onOpenAttachment={onOpenAttachment} />
|
||||
<DetailSection title="Approved on work order">
|
||||
<ApprovedOnWoBreakdown
|
||||
auto={breakdown.auto}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { Alert, Box, Chip, Stack, Tab, Tabs, TablePagination } from "@mui/material";
|
||||
import { toast } from "react-toastify";
|
||||
import { RejectDialog } from "@/app/(protected)/uplifts/_components/reject-dialog";
|
||||
import { RevokeDialog } from "@/app/(protected)/uplifts/_components/revoke-dialog";
|
||||
import {
|
||||
|
|
@ -11,22 +12,11 @@ import { formatUpliftMoney } from "@/app/(protected)/uplifts/_components/format-
|
|||
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
|
||||
import { useUpliftApprovalController } from "@/domain/uplifts/use-cases/use-uplift-approval-controller";
|
||||
import { useUpliftsQueue } from "@/domain/uplifts/use-cases/use-uplifts-queue";
|
||||
import { upliftsApi } from "@/domain/uplifts/api/uplifts-api";
|
||||
import { Text } from "@/components/ui/text";
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
function sortRows(rows: UpliftQueueItem[], tab: UpliftApprovalTab): UpliftQueueItem[] {
|
||||
const copy = [...rows];
|
||||
if (tab === "pending") {
|
||||
copy.sort((a, b) =>
|
||||
a.requestedAt < b.requestedAt ? -1 : a.requestedAt > b.requestedAt ? 1 : 0,
|
||||
);
|
||||
} else {
|
||||
copy.sort((a, b) => (a.decidedAt > b.decidedAt ? -1 : a.decidedAt < b.decidedAt ? 1 : 0));
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
function UpliftApprovalsHeader({ pendingExposureTotal }: { pendingExposureTotal: number | null }) {
|
||||
return (
|
||||
<Box>
|
||||
|
|
@ -38,7 +28,10 @@ function UpliftApprovalsHeader({ pendingExposureTotal }: { pendingExposureTotal:
|
|||
allowance.
|
||||
</Text>
|
||||
<Text when={pendingExposureTotal != null} variant="caption" tone="muted">
|
||||
Pending exposure: {formatUpliftMoney(pendingExposureTotal ?? 0)}
|
||||
Pending exposure:{" "}
|
||||
{pendingExposureTotal && pendingExposureTotal > 0
|
||||
? formatUpliftMoney(pendingExposureTotal)
|
||||
: "—"}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
|
@ -146,7 +139,7 @@ export default function UpliftQueuePage() {
|
|||
} = useUpliftApprovalController();
|
||||
|
||||
const activeQuery = tab === "pending" ? pendingQuery : approvedQuery;
|
||||
const rows = useMemo(() => sortRows(activeQuery.data?.items ?? [], tab), [activeQuery.data, tab]);
|
||||
const rows = activeQuery.data?.items ?? [];
|
||||
const pendingCount = pendingQuery.data?.totalCount ?? 0;
|
||||
const pendingExposureTotal = pendingQuery.data?.pendingExposureTotal ?? null;
|
||||
|
||||
|
|
@ -156,6 +149,16 @@ export default function UpliftQueuePage() {
|
|||
}
|
||||
};
|
||||
|
||||
const handleOpenAttachment = (row: UpliftQueueItem) => {
|
||||
void upliftsApi
|
||||
.downloadEvidence(row.id, row.evidenceFileName || "uplift-evidence")
|
||||
.catch((error: unknown) => {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Unable to download evidence right now.",
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="flex flex-col gap-4 p-4">
|
||||
<UpliftApprovalsHeader pendingExposureTotal={pendingExposureTotal} />
|
||||
|
|
@ -169,6 +172,8 @@ export default function UpliftQueuePage() {
|
|||
onApprove={handleApprove}
|
||||
onReject={handleRejectRequest}
|
||||
onRevoke={handleRevokeRequest}
|
||||
onOpenAttachment={handleOpenAttachment}
|
||||
isDecisionPending={approvePending || rejectPending || revokePending}
|
||||
/>
|
||||
<UpliftApprovalsPagination
|
||||
tab={tab}
|
||||
|
|
@ -187,6 +192,7 @@ export default function UpliftQueuePage() {
|
|||
onApprove={handleApprove}
|
||||
onReject={handleRejectRequest}
|
||||
onRevoke={handleRevokeRequest}
|
||||
onOpenAttachment={handleOpenAttachment}
|
||||
/>
|
||||
)}
|
||||
<RejectDialog
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ export const upliftsApi = {
|
|||
},
|
||||
|
||||
revoke: async (id: string | number, reason: string): Promise<void> => {
|
||||
await apiPost<unknown>(`${API_PATHS.rest.uplifts}/${id}/revoke`, { reason });
|
||||
await apiPost<unknown>(`${API_PATHS.rest.uplifts}/${id}/revoke`, { note: reason });
|
||||
},
|
||||
|
||||
downloadEvidence: async (
|
||||
|
|
|
|||
|
|
@ -57,7 +57,13 @@ export function mapUpliftRequest(raw: unknown): UpliftRequest {
|
|||
delta: readNumber(item, "delta", "Delta") ?? 0,
|
||||
vendorReason: readString(item, "vendorReason", "VendorReason"),
|
||||
requestedAt: readString(item, "requestedAt", "RequestedAt"),
|
||||
requestedByVendorName: readString(item, "requestedByVendorName", "RequestedByVendorName"),
|
||||
requestedByVendorName: readString(
|
||||
item,
|
||||
"requestedByName",
|
||||
"RequestedByName",
|
||||
"requestedByVendorName",
|
||||
"RequestedByVendorName",
|
||||
),
|
||||
decidedAt: readString(item, "decidedAt", "DecidedAt"),
|
||||
decidedByName: readString(item, "decidedByName", "DecidedByName"),
|
||||
decisionNote: readString(item, "decisionNote", "DecisionNote"),
|
||||
|
|
@ -88,9 +94,21 @@ export function mapUpliftQueueItem(raw: unknown): UpliftQueueItem {
|
|||
workOrderId: readOptionalId(item, "workOrderId", "WorkOrderId"),
|
||||
dispatchId: readOptionalId(item, "dispatchId", "DispatchId"),
|
||||
woNumber: readString(item, "woNumber", "WoNumber", "workOrderNumber", "WorkOrderNumber"),
|
||||
site: readString(item, "site", "Site", "siteCode", "SiteCode", "locationName", "LocationName"),
|
||||
site: readString(
|
||||
item,
|
||||
"workOrderSite",
|
||||
"WorkOrderSite",
|
||||
"site",
|
||||
"Site",
|
||||
"siteCode",
|
||||
"SiteCode",
|
||||
"locationName",
|
||||
"LocationName",
|
||||
),
|
||||
serviceName: readString(
|
||||
item,
|
||||
"workOrderService",
|
||||
"WorkOrderService",
|
||||
"serviceName",
|
||||
"ServiceName",
|
||||
"service",
|
||||
|
|
@ -107,6 +125,8 @@ export function mapUpliftQueueItem(raw: unknown): UpliftQueueItem {
|
|||
),
|
||||
approvedOnWoAuto: readNumber(
|
||||
item,
|
||||
"workOrderAutoApprovedTotal",
|
||||
"WorkOrderAutoApprovedTotal",
|
||||
"approvedOnWoAuto",
|
||||
"ApprovedOnWoAuto",
|
||||
"autoApprovedTotal",
|
||||
|
|
@ -114,6 +134,8 @@ export function mapUpliftQueueItem(raw: unknown): UpliftQueueItem {
|
|||
),
|
||||
approvedOnWoAdmin: readNumber(
|
||||
item,
|
||||
"workOrderAdminApprovedTotal",
|
||||
"WorkOrderAdminApprovedTotal",
|
||||
"approvedOnWoAdmin",
|
||||
"ApprovedOnWoAdmin",
|
||||
"adminApprovedTotal",
|
||||
|
|
@ -121,6 +143,8 @@ export function mapUpliftQueueItem(raw: unknown): UpliftQueueItem {
|
|||
),
|
||||
approvedOnWoTotal: readNumber(
|
||||
item,
|
||||
"workOrderApprovedExposureTotal",
|
||||
"WorkOrderApprovedExposureTotal",
|
||||
"approvedOnWoTotal",
|
||||
"ApprovedOnWoTotal",
|
||||
"approvedOnWorkOrderTotal",
|
||||
|
|
|
|||
|
|
@ -44,11 +44,24 @@ function invalidateWorkOrderUpliftQueries(queryClient: ReturnType<typeof useQuer
|
|||
export interface ApproveUpliftInput {
|
||||
id: string | number;
|
||||
note?: string;
|
||||
amount?: number;
|
||||
woNumber?: string;
|
||||
}
|
||||
|
||||
export interface DecisionUpliftInput {
|
||||
id: string | number;
|
||||
note: string;
|
||||
amount?: number;
|
||||
woNumber?: string;
|
||||
}
|
||||
|
||||
function decisionAmount(amount: number | undefined): string {
|
||||
if (amount == null || !Number.isFinite(amount)) return "the requested amount";
|
||||
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(amount);
|
||||
}
|
||||
|
||||
function decisionWorkOrder(woNumber: string | undefined): string {
|
||||
return woNumber?.trim() || "the work order";
|
||||
}
|
||||
|
||||
export function useApproveUplift(
|
||||
|
|
@ -57,11 +70,12 @@ export function useApproveUplift(
|
|||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, note }: { id: string | number; note?: string }) =>
|
||||
upliftsApi.approve(id, note),
|
||||
onSuccess: () => {
|
||||
mutationFn: ({ id, note }: ApproveUpliftInput) => upliftsApi.approve(id, note),
|
||||
onSuccess: (_, variables) => {
|
||||
invalidateUpliftQueries(queryClient, dispatchId);
|
||||
toast.success("Uplift approved — NTE updated");
|
||||
toast.success(
|
||||
`Uplift approved — ${decisionAmount(variables.amount)} on ${decisionWorkOrder(variables.woNumber)}`,
|
||||
);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to approve uplift");
|
||||
|
|
@ -75,11 +89,12 @@ export function useRejectUplift(
|
|||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, note }: { id: string | number; note: string }) =>
|
||||
upliftsApi.reject(id, note),
|
||||
onSuccess: () => {
|
||||
mutationFn: ({ id, note }: DecisionUpliftInput) => upliftsApi.reject(id, note),
|
||||
onSuccess: (_, variables) => {
|
||||
invalidateUpliftQueries(queryClient, dispatchId);
|
||||
toast.success("Uplift rejected");
|
||||
toast.success(
|
||||
`Uplift rejected — ${decisionAmount(variables.amount)} on ${decisionWorkOrder(variables.woNumber)}`,
|
||||
);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to reject uplift");
|
||||
|
|
@ -108,6 +123,8 @@ export function useRequestChangesUplift(
|
|||
export interface RevokeUpliftInput {
|
||||
upliftId: string | number;
|
||||
reason: string;
|
||||
amount?: number;
|
||||
woNumber?: string;
|
||||
}
|
||||
|
||||
export function useRevokeUplift(): UseMutationResult<void, Error, RevokeUpliftInput> {
|
||||
|
|
@ -115,9 +132,11 @@ export function useRevokeUplift(): UseMutationResult<void, Error, RevokeUpliftIn
|
|||
|
||||
return useMutation({
|
||||
mutationFn: ({ upliftId, reason }: RevokeUpliftInput) => upliftsApi.revoke(upliftId, reason),
|
||||
onSuccess: () => {
|
||||
onSuccess: (_, variables) => {
|
||||
invalidateWorkOrderUpliftQueries(queryClient);
|
||||
toast.success("Uplift revoked");
|
||||
toast.success(
|
||||
`Uplift revoked — ${decisionAmount(variables.amount)} on ${decisionWorkOrder(variables.woNumber)}`,
|
||||
);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to revoke uplift");
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export function useUpliftApprovalController() {
|
|||
const revokeUplift = useRevokeUplift();
|
||||
|
||||
const handleApprove = (row: UpliftQueueItem) => {
|
||||
approveUplift.mutate({ id: row.id });
|
||||
approveUplift.mutate({ id: row.id, amount: row.delta, woNumber: row.woNumber });
|
||||
setDetailItem(null);
|
||||
};
|
||||
|
||||
|
|
@ -32,7 +32,12 @@ export function useUpliftApprovalController() {
|
|||
const handleRejectConfirm = (reason: string) => {
|
||||
if (!rejectTarget) return;
|
||||
rejectUplift.mutate(
|
||||
{ id: rejectTarget.id, note: reason },
|
||||
{
|
||||
id: rejectTarget.id,
|
||||
note: reason,
|
||||
amount: rejectTarget.delta,
|
||||
woNumber: rejectTarget.woNumber,
|
||||
},
|
||||
{ onSuccess: () => setRejectTarget(null) },
|
||||
);
|
||||
};
|
||||
|
|
@ -40,7 +45,12 @@ export function useUpliftApprovalController() {
|
|||
const handleRevokeConfirm = (reason: string) => {
|
||||
if (!revokeTarget) return;
|
||||
revokeUplift.mutate(
|
||||
{ upliftId: revokeTarget.id, reason },
|
||||
{
|
||||
upliftId: revokeTarget.id,
|
||||
reason,
|
||||
amount: revokeTarget.delta,
|
||||
woNumber: revokeTarget.woNumber,
|
||||
},
|
||||
{ onSuccess: () => setRevokeTarget(null) },
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -85,6 +85,10 @@ describe("Uplift Approvals approved tab", () => {
|
|||
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);
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ function renderModal(overrides: Partial<UpliftQueueItem> = {}) {
|
|||
const onApprove = vi.fn();
|
||||
const onReject = vi.fn();
|
||||
const onRevoke = vi.fn();
|
||||
const onOpenAttachment = vi.fn();
|
||||
renderWithProviders(
|
||||
<UpliftDetailModal
|
||||
item={{ ...baseItem, ...overrides }}
|
||||
|
|
@ -64,9 +65,10 @@ function renderModal(overrides: Partial<UpliftQueueItem> = {}) {
|
|||
onApprove={onApprove}
|
||||
onReject={onReject}
|
||||
onRevoke={onRevoke}
|
||||
onOpenAttachment={onOpenAttachment}
|
||||
/>,
|
||||
);
|
||||
return { onApprove, onReject, onRevoke };
|
||||
return { onApprove, onReject, onRevoke, onOpenAttachment };
|
||||
}
|
||||
|
||||
describe("UpliftDetailModal", () => {
|
||||
|
|
@ -88,6 +90,19 @@ describe("UpliftDetailModal", () => {
|
|||
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();
|
||||
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ describe("Uplift Approvals decision flow", () => {
|
|||
renderWithProviders(<UpliftQueuePage />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Approve" }));
|
||||
expect(approveMutate).toHaveBeenCalledWith({ id: 41 });
|
||||
expect(approveMutate).toHaveBeenCalledWith({ id: 41, amount: 150, woNumber: "WO-99" });
|
||||
expect(screen.queryByRole("button", { name: "Confirm" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ describe("upliftsApi", () => {
|
|||
await upliftsApi.revoke(42, "Scope was already covered");
|
||||
|
||||
expect(apiPost).toHaveBeenCalledWith("uplifts/42/revoke", {
|
||||
reason: "Scope was already covered",
|
||||
note: "Scope was already covered",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -64,18 +64,20 @@ describe("mapUpliftQueueItem", () => {
|
|||
const result = mapUpliftQueueItem({
|
||||
id: 1,
|
||||
WoNumber: "WO-9",
|
||||
Site: "Site A",
|
||||
ServiceName: "Plumbing",
|
||||
WorkOrderSite: "Site A",
|
||||
WorkOrderService: "Plumbing",
|
||||
RequestedByName: "Pat Approver",
|
||||
AttachmentCount: 3,
|
||||
ApprovedOnWoAuto: 100,
|
||||
ApprovedOnWoAdmin: 250,
|
||||
ApprovedOnWoTotal: 350,
|
||||
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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue