Merge pull request #114 from Sea-Haven-Industries/feat/sh-101-uplift-lifecycle
Some checks are pending
CI / ci (push) Waiting to run
CI / governance (push) Waiting to run
CI / vendor-visual-regression (push) Waiting to run
Deploy / deploy (push) Waiting to run

feat(uplifts): complete vendor approval lifecycle (SH-101)
This commit is contained in:
Alexandre Brandizzi 2026-08-11 15:01:09 -03:00 • committed by GitHub
commit 2cf33ca6c1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 2369 additions and 753 deletions

View file

@ -0,0 +1,356 @@
import { expect, test, type Route } from "@playwright/test";
const INTERNAL_USER = {
token: "uplift-approver-token",
expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
email: "approver@seahavenind.com",
userRoles: "Admin,Tier2Approver",
fullname: "Uplift Approver",
id: "1",
};
async function fulfillJson(route: Route, data: unknown, status = 200) {
await route.fulfill({
status,
contentType: "application/json",
body: JSON.stringify({ status: status < 400 ? "Success" : "Error", data }),
});
}
test("vendor uploads scanned evidence and submits a dispatch-scoped uplift", async ({ page }) => {
let requestBody: Record<string, unknown> | undefined;
let uploadPurpose = "";
let created = false;
await page.route("**/api/vendor-portal/**", async (route) => {
const request = route.request();
const path = new URL(request.url()).pathname;
if (path.endsWith("/session")) {
await fulfillJson(route, { id: 9, companyName: "Gateway Plumbing" });
return;
}
if (path.endsWith("/dispatches/7/documents") && request.method() === "POST") {
uploadPurpose = request.postData() ?? "";
await fulfillJson(route, {
id: 22,
originalFileName: "estimate.pdf",
scanStatus: "Pending",
reviewStatus: "Processing",
purpose: "UpliftEvidence",
});
return;
}
if (path.endsWith("/dispatches/7/documents/22/status")) {
await fulfillJson(route, {
id: 22,
originalFileName: "estimate.pdf",
scanStatus: "Passed",
reviewStatus: "Approved",
purpose: "UpliftEvidence",
});
return;
}
if (path.endsWith("/dispatches/7/uplift-request") && request.method() === "POST") {
requestBody = request.postDataJSON();
created = true;
await fulfillJson(route, {
id: 31,
status: "Pending",
noApprovalRequired: false,
});
return;
}
if (path.endsWith("/dispatches/7")) {
await fulfillJson(route, {
id: 7,
dispatchNumber: "DSP-7",
poNumber: "PO-7",
status: "In Progress",
nteAmount: 100,
checklist: [],
comments: [],
signoffs: [],
documents: [],
upliftRequests: created
? [
{
id: 31,
status: "Pending",
currentNTE: 100,
requestedNTE: 125,
vendorReason: "Replacement compressor",
evidenceDocumentId: 22,
evidenceFileName: "estimate.pdf",
notificationStatus: "Sent",
},
]
: [],
});
return;
}
await fulfillJson(route, []);
});
await page.goto("/v/portal-token/dispatch/7");
await expect(page.getByRole("heading", { name: /DSP-7|PO-7/ })).toBeVisible();
await page.getByLabel("New total NTE").fill("125");
await page.getByLabel("Reason").fill("Replacement compressor");
await page.getByLabel("Evidence (PDF/JPG/PNG)").setInputFiles({
name: "estimate.pdf",
mimeType: "application/pdf",
buffer: Buffer.from("%PDF-1.4\nmock estimate"),
});
await page.getByRole("button", { name: "Request uplift" }).click();
await expect(page.getByText("Pending", { exact: true })).toBeVisible();
expect(uploadPurpose).toContain('name="purpose"');
expect(uploadPurpose).toContain("UpliftEvidence");
expect(requestBody).toMatchObject({
requestedNTE: 125,
reason: "Replacement compressor",
evidenceDocumentId: 22,
});
expect(requestBody?.requestKey).toMatch(/^[0-9a-f-]{36}$/i);
});
test("vendor can withdraw a pending uplift from the dispatch", async ({ page }) => {
let withdrawnPath = "";
let pending = true;
await page.route("**/api/vendor-portal/**", async (route) => {
const request = route.request();
const path = new URL(request.url()).pathname;
if (path.endsWith("/session")) {
await fulfillJson(route, { id: 9, companyName: "Gateway Plumbing" });
return;
}
if (path.endsWith("/dispatches/7/uplift-request/31/withdraw")) {
withdrawnPath = path;
pending = false;
await fulfillJson(route, { id: 31, status: "Withdrawn" });
return;
}
if (path.endsWith("/dispatches/7")) {
await fulfillJson(route, {
id: 7,
dispatchNumber: "DSP-7",
status: "In Progress",
nteAmount: 100,
checklist: [],
comments: [],
signoffs: [],
documents: [],
upliftRequests: [
{
id: 31,
status: pending ? "Pending" : "Withdrawn",
currentNTE: 100,
requestedNTE: 125,
vendorReason: "Parts",
},
],
});
return;
}
await fulfillJson(route, []);
});
page.on("dialog", (dialog) => void dialog.accept());
await page.goto("/v/portal-token/dispatch/7");
await page.getByRole("button", { name: "Withdraw request" }).click();
await expect(page.getByText("Withdrawn", { exact: true })).toBeVisible();
expect(withdrawnPath).toBe("/api/vendor-portal/dispatches/7/uplift-request/31/withdraw");
});
test("vendor receives the no-approval result for a request within the current NTE", async ({
page,
}) => {
let requestBody: Record<string, unknown> | undefined;
await page.route("**/api/vendor-portal/**", async (route) => {
const request = route.request();
const path = new URL(request.url()).pathname;
if (path.endsWith("/session")) {
await fulfillJson(route, { id: 9, companyName: "Gateway Plumbing" });
return;
}
if (path.endsWith("/dispatches/7/uplift-request") && request.method() === "POST") {
requestBody = request.postDataJSON();
await fulfillJson(route, { noApprovalRequired: true, status: "Approved" });
return;
}
if (path.endsWith("/dispatches/7")) {
await fulfillJson(route, {
id: 7,
dispatchNumber: "DSP-7",
status: "In Progress",
nteAmount: 100,
checklist: [],
comments: [],
signoffs: [],
documents: [],
upliftRequests: [],
});
return;
}
await fulfillJson(route, []);
});
await page.goto("/v/portal-token/dispatch/7");
await page.getByLabel("New total NTE").fill("90");
await expect(page.getByLabel("Reason")).toHaveCount(0);
await page.getByRole("button", { name: "Request uplift" }).click();
await expect(
page.getByText("No approval required — the existing NTE already covers this amount."),
).toBeVisible();
expect(requestBody).toMatchObject({ requestedNTE: 90, reason: "" });
expect(requestBody).not.toHaveProperty("evidenceDocumentId");
});
test("vendor revises a changes-requested uplift using its existing evidence", async ({ page }) => {
let revisionBody: Record<string, unknown> | undefined;
let revised = false;
await page.route("**/api/vendor-portal/**", async (route) => {
const request = route.request();
const path = new URL(request.url()).pathname;
if (path.endsWith("/session")) {
await fulfillJson(route, { id: 9, companyName: "Gateway Plumbing" });
return;
}
if (path.endsWith("/dispatches/7/uplift-request/31/revise")) {
revisionBody = request.postDataJSON();
revised = true;
await fulfillJson(route, { id: 31, status: "Pending" });
return;
}
if (path.endsWith("/dispatches/7")) {
await fulfillJson(route, {
id: 7,
dispatchNumber: "DSP-7",
status: "In Progress",
nteAmount: 100,
checklist: [],
comments: [],
signoffs: [],
documents: [],
upliftRequests: [
{
id: 31,
status: revised ? "Pending" : "ChangesRequested",
currentNTE: 100,
requestedNTE: 125,
vendorReason: "Parts",
decisionNote: "Itemize the estimate",
evidenceDocumentId: 22,
evidenceFileName: "estimate.pdf",
},
],
});
return;
}
await fulfillJson(route, []);
});
await page.goto("/v/portal-token/dispatch/7");
await page.getByRole("button", { name: "Revise" }).click();
await page.getByLabel("New total NTE").fill("120");
await page.getByLabel("Reason").fill("Parts itemized in the attached estimate");
await page.getByRole("button", { name: "Submit revision" }).click();
await expect(page.getByText("Pending", { exact: true })).toBeVisible();
expect(revisionBody).toEqual({
requestedNTE: 120,
reason: "Parts itemized in the attached estimate",
evidenceDocumentId: 22,
});
});
test("internal approver can approve and request changes with audited notes", async ({ page }) => {
const decisions: Array<{ path: string; body: unknown }> = [];
const statuses = new Map<number, string>([
[41, "Pending"],
[42, "Pending"],
]);
await page.addInitScript(
(user) => localStorage.setItem("auth", JSON.stringify(user)),
INTERNAL_USER,
);
await page.route("**/api/Dashboard/Stats", (route) => fulfillJson(route, {}));
await page.route("**/api/User*", (route) => fulfillJson(route, []));
await page.route(/\/api\/uplifts(?:[/?]|$)/, async (route) => {
const request = route.request();
const path = new URL(request.url()).pathname;
const action = path.match(/\/uplifts\/(41|42)\/(approve|request-changes)$/);
if (action && request.method() === "POST") {
const id = Number(action[1]);
statuses.set(id, action[2] === "approve" ? "Approved" : "ChangesRequested");
decisions.push({ path, body: request.postDataJSON() });
await fulfillJson(route, { id, status: statuses.get(id) });
return;
}
await fulfillJson(route, {
total: 2,
items: [
{
id: 41,
dispatchId: 7,
dispatchNumber: "DSP-41",
poNumber: "PO-41",
vendorCompanyName: "Gateway Plumbing",
currentNTE: 100,
requestedNTE: 125,
delta: 25,
requiredTier: 1,
status: statuses.get(41),
canDecide: true,
evidenceDocumentId: 22,
evidenceFileName: "estimate.pdf",
evidenceContentType: "application/pdf",
evidenceSizeBytes: 2048,
},
{
id: 42,
dispatchId: 8,
dispatchNumber: "DSP-42",
poNumber: "PO-42",
vendorCompanyName: "Metro HVAC",
currentNTE: 200,
requestedNTE: 260,
delta: 60,
requiredTier: 2,
status: statuses.get(42),
canDecide: true,
evidenceDocumentId: 23,
evidenceFileName: "quote.pdf",
},
],
});
});
await page.goto("/uplifts");
await expect(page.getByRole("heading", { name: "Uplift Queue" })).toBeVisible();
const approvalRow = page.getByRole("row").filter({ hasText: "DSP-41" });
await approvalRow.getByRole("button", { name: "Approve" }).click();
await page.getByRole("button", { name: "Confirm" }).click();
const changesRow = page.getByRole("row").filter({ hasText: "DSP-42" });
await changesRow.getByRole("button", { name: "Request Changes" }).click();
await page.getByLabel("Note (required)").fill("Please itemize labor and parts.");
await page.getByRole("button", { name: "Confirm" }).click();
await expect
.poll(() => decisions)
.toEqual([
{ path: "/api/uplifts/41/approve", body: {} },
{
path: "/api/uplifts/42/request-changes",
body: { note: "Please itemize labor and parts." },
},
]);
});

View file

@ -27,28 +27,319 @@ import {
Tooltip, Tooltip,
Typography, Typography,
} from "@mui/material"; } from "@mui/material";
import { upliftsApi } from "@/domain/uplifts/api/uplifts-api";
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift"; import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
import { useApproveUplift, useDenyUplift } from "@/domain/uplifts/use-cases/use-uplift-actions"; 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 { formatDateTime } from "@/lib/time-utils";
import { toast } from "react-toastify";
const STATUS_OPTIONS = ["Pending", "Approved", "Denied", "Cancelled", ""]; const STATUS_OPTIONS = ["", ...UPLIFT_STATUSES];
type UpliftActionType = "approve" | "reject" | "request-changes";
type UpliftDecision = { type: UpliftActionType; item: UpliftQueueItem };
function formatMoney(value: number | null | undefined): string { function formatMoney(value: number | null | undefined): string {
return `$${Number(value ?? 0).toFixed(2)}`; 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 (
<Stack direction="row" spacing={2} sx={{ alignItems: "baseline" }}>
<Typography variant="h5" component="h1">
Uplift Queue
</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
{totalCount} total
</Typography>
{isFetching && !isLoading && <CircularProgress size={16} />}
</Stack>
);
}
function UpliftQueueFilters({
status,
tier,
onStatusChange,
onTierChange,
}: {
status: string;
tier: string;
onStatusChange: (value: string) => void;
onTierChange: (value: string) => void;
}) {
return (
<Stack direction="row" spacing={2}>
<FormControl size="small" className="min-w-[140px]">
<InputLabel>Status</InputLabel>
<Select label="Status" value={status} onChange={(e) => onStatusChange(e.target.value)}>
{STATUS_OPTIONS.map((s) => (
<MenuItem key={s || "all"} value={s}>
{s || "All"}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl size="small" className="min-w-[120px]">
<InputLabel>Tier</InputLabel>
<Select label="Tier" value={tier} onChange={(e) => onTierChange(e.target.value)}>
<MenuItem value="">All</MenuItem>
<MenuItem value="1">Tier 1</MenuItem>
<MenuItem value="2">Tier 2</MenuItem>
</Select>
</FormControl>
</Stack>
);
}
function UpliftQueueRow({
row,
onViewWorkOrder,
onDownloadEvidence,
onDecide,
}: {
row: UpliftQueueItem;
onViewWorkOrder: (row: UpliftQueueItem) => void;
onDownloadEvidence: (row: UpliftQueueItem) => void;
onDecide: (type: UpliftActionType, row: UpliftQueueItem) => void;
}) {
const decisionable = row.status === "Pending";
return (
<TableRow hover>
<TableCell>{row.dispatchNumber}</TableCell>
<TableCell>{row.poNumber}</TableCell>
<TableCell>{row.vendorCompanyName}</TableCell>
<TableCell>{formatMoney(row.currentNTE)}</TableCell>
<TableCell>{formatMoney(row.requestedNTE)}</TableCell>
<TableCell>
<Chip size="small" label={`Tier ${row.requiredTier}`} />
</TableCell>
<TableCell>
<Chip size="small" label={row.status} />
</TableCell>
<TableCell>
{row.evidenceDocumentId != null ? (
<Stack direction="column" spacing={0.5}>
<Typography variant="caption">{row.evidenceFileName}</Typography>
{Boolean(row.evidenceContentType) && (
<Typography variant="caption" color="text.secondary">
{row.evidenceContentType} · {formatBytes(row.evidenceSizeBytes)}
</Typography>
)}
<Button size="small" variant="text" onClick={() => void onDownloadEvidence(row)}>
Download
</Button>
</Stack>
) : (
<Typography variant="caption" color="text.secondary">
—
</Typography>
)}
</TableCell>
<TableCell>{row.requestedAt ? formatDateTime(row.requestedAt) : ""}</TableCell>
<TableCell>
<Stack direction="row" spacing={1}>
{Boolean(row.workOrderId) && (
<Button size="small" variant="outlined" onClick={() => onViewWorkOrder(row)}>
View
</Button>
)}
{decisionable && (
<>
<Tooltip title={!row.canDecide ? `Requires Tier ${row.requiredTier} role` : ""}>
<span>
<Button
size="small"
variant="contained"
color="success"
disabled={!row.canDecide}
onClick={() => onDecide("approve", row)}
>
Approve
</Button>
</span>
</Tooltip>
<Tooltip title={!row.canDecide ? `Requires Tier ${row.requiredTier} role` : ""}>
<span>
<Button
size="small"
variant="contained"
color="error"
disabled={!row.canDecide}
onClick={() => onDecide("reject", row)}
>
Reject
</Button>
</span>
</Tooltip>
<Tooltip title={!row.canDecide ? `Requires Tier ${row.requiredTier} role` : ""}>
<span>
<Button
size="small"
variant="outlined"
disabled={!row.canDecide}
onClick={() => onDecide("request-changes", row)}
>
Request Changes
</Button>
</span>
</Tooltip>
</>
)}
</Stack>
</TableCell>
</TableRow>
);
}
function UpliftQueueTable({
isLoading,
rows,
onViewWorkOrder,
onDownloadEvidence,
onDecide,
}: {
isLoading: boolean;
rows: UpliftQueueItem[];
onViewWorkOrder: (row: UpliftQueueItem) => void;
onDownloadEvidence: (row: UpliftQueueItem) => void;
onDecide: (type: UpliftActionType, row: UpliftQueueItem) => void;
}) {
return (
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell className="font-semibold">Dispatch</TableCell>
<TableCell className="font-semibold">PO</TableCell>
<TableCell className="font-semibold">Vendor</TableCell>
<TableCell className="font-semibold">Current NTE</TableCell>
<TableCell className="font-semibold">Requested</TableCell>
<TableCell className="font-semibold">Tier</TableCell>
<TableCell className="font-semibold">Status</TableCell>
<TableCell className="font-semibold">Evidence</TableCell>
<TableCell className="font-semibold">Requested At</TableCell>
<TableCell className="font-semibold">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={10} align="center" className="py-10">
<CircularProgress size={28} />
</TableCell>
</TableRow>
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={10} align="center" className="py-10">
<Typography sx={{ color: "text.secondary" }}>
No uplift requests match these filters.
</Typography>
</TableCell>
</TableRow>
) : (
rows.map((row) => (
<UpliftQueueRow
key={String(row.id)}
row={row}
onViewWorkOrder={onViewWorkOrder}
onDownloadEvidence={onDownloadEvidence}
onDecide={onDecide}
/>
))
)}
</TableBody>
</Table>
</TableContainer>
);
}
function UpliftDecisionDialog({
action,
note,
noteRequired,
decisionPending,
onNoteChange,
onClose,
onConfirm,
}: {
action: UpliftDecision | null;
note: string;
noteRequired: boolean;
decisionPending: boolean;
onNoteChange: (value: string) => void;
onClose: () => void;
onConfirm: () => void;
}) {
return (
<Dialog open={Boolean(action)} onClose={onClose}>
<DialogTitle>
{action?.type === "approve"
? `Approve uplift to ${formatMoney(action.item.requestedNTE)}?`
: action?.type === "reject"
? "Reject Uplift"
: "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 navigate = useNavigate();
const [status, setStatus] = useState("Pending"); const [status, setStatus] = useState("Pending");
const [tier, setTier] = useState(""); const [tier, setTier] = useState("");
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const [pageSize] = useState(25); const [pageSize] = useState(25);
const [upliftAction, setUpliftAction] = useState<UpliftDecision | null>(null);
const [upliftAction, setUpliftAction] = useState<{
type: "approve" | "deny";
item: UpliftQueueItem;
} | null>(null);
const [note, setNote] = useState(""); const [note, setNote] = useState("");
const listParams = useMemo( const listParams = useMemo(
@ -58,39 +349,59 @@ export default function UpliftQueuePage() {
const { data, isLoading, isFetching, error, refetch } = useUpliftsQueue(listParams); const { data, isLoading, isFetching, error, refetch } = useUpliftsQueue(listParams);
const approveUplift = useApproveUplift(); const approveUplift = useApproveUplift();
const denyUplift = useDenyUplift(); const rejectUplift = useRejectUplift();
const requestChangesUplift = useRequestChangesUplift();
const rows = data?.items ?? []; const rows = data?.items ?? [];
const totalCount = data?.totalCount ?? 0; const totalCount = data?.totalCount ?? 0;
const noteRequired = upliftAction?.type === "reject" || upliftAction?.type === "request-changes";
const decisionPending =
approveUplift.isPending || rejectUplift.isPending || requestChangesUplift.isPending;
const handleStatusChange = (value: string) => {
setStatus(value);
setPage(0);
};
const handleTierChange = (value: string) => {
setTier(value);
setPage(0);
};
const closeDialog = () => {
setUpliftAction(null);
setNote("");
};
const handleConfirm = () => { const handleConfirm = () => {
if (!upliftAction) return; if (!upliftAction) return;
if (noteRequired && !note.trim()) return;
const id = upliftAction.item.id;
const onDone = () => {
closeDialog();
void refetch();
};
if (upliftAction.type === "approve") { if (upliftAction.type === "approve") {
approveUplift.mutate( approveUplift.mutate({ id, note: note || undefined }, { onSuccess: onDone });
{ id: upliftAction.item.id, note: note || undefined }, } else if (upliftAction.type === "reject") {
{ rejectUplift.mutate({ id, note: note.trim() }, { onSuccess: onDone });
onSuccess: () => {
setUpliftAction(null);
setNote("");
void refetch();
},
},
);
} else { } else {
if (!note.trim()) return; requestChangesUplift.mutate({ id, note: note.trim() }, { onSuccess: onDone });
denyUplift.mutate(
{ id: upliftAction.item.id, note: note.trim() },
{
onSuccess: () => {
setUpliftAction(null);
setNote("");
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 <Stack
@ -101,177 +412,26 @@ export default function UpliftQueuePage() {
justifyContent: "space-between", justifyContent: "space-between",
}} }}
> >
<Stack <UpliftQueueHeader totalCount={totalCount} isFetching={isFetching} isLoading={isLoading} />
direction="row" <UpliftQueueFilters
spacing={2} status={status}
sx={{ tier={tier}
alignItems: "baseline", onStatusChange={handleStatusChange}
}} onTierChange={handleTierChange}
> />
<Typography variant="h5" component="h1">
Uplift Queue
</Typography>
<Typography
variant="body2"
sx={{
color: "text.secondary",
}}
>
{totalCount} total
</Typography>
{isFetching && !isLoading && <CircularProgress size={16} />}
</Stack>
<Stack direction="row" spacing={2}>
<FormControl size="small" className="min-w-[140px]">
<InputLabel>Status</InputLabel>
<Select
label="Status"
value={status}
onChange={(e) => {
setStatus(e.target.value);
setPage(0);
}}
>
{STATUS_OPTIONS.map((s) => (
<MenuItem key={s || "all"} value={s}>
{s || "All"}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl size="small" className="min-w-[120px]">
<InputLabel>Tier</InputLabel>
<Select
label="Tier"
value={tier}
onChange={(e) => {
setTier(e.target.value);
setPage(0);
}}
>
<MenuItem value="">All</MenuItem>
<MenuItem value="1">Tier 1</MenuItem>
<MenuItem value="2">Tier 2</MenuItem>
</Select>
</FormControl>
</Stack>
</Stack> </Stack>
{Boolean(error) && ( {Boolean(error) && (
<Alert severity="error"> <Alert severity="error">
{error instanceof Error ? error.message : "Failed to load uplift queue"} {error instanceof Error ? error.message : "Failed to load uplift queue"}
</Alert> </Alert>
)} )}
<TableContainer component={Paper} variant="outlined"> <UpliftQueueTable
<Table size="small"> isLoading={isLoading}
<TableHead> rows={rows}
<TableRow> onViewWorkOrder={(r) => navigate(`/workorders/${r.workOrderId}`)}
<TableCell className="font-semibold">Dispatch</TableCell> onDownloadEvidence={(r) => void downloadEvidence(r)}
<TableCell className="font-semibold">PO</TableCell> onDecide={handleDecide}
<TableCell className="font-semibold">Vendor</TableCell> />
<TableCell className="font-semibold">Current NTE</TableCell>
<TableCell className="font-semibold">Requested</TableCell>
<TableCell className="font-semibold">Delta</TableCell>
<TableCell className="font-semibold">Tier</TableCell>
<TableCell className="font-semibold">Status</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) => (
<TableRow key={String(row.id)} hover>
<TableCell>{row.dispatchNumber}</TableCell>
<TableCell>{row.poNumber}</TableCell>
<TableCell>{row.vendorCompanyName}</TableCell>
<TableCell>{formatMoney(row.currentNTE)}</TableCell>
<TableCell>{formatMoney(row.requestedNTE)}</TableCell>
<TableCell>{formatMoney(row.delta)}</TableCell>
<TableCell>
<Chip size="small" label={`Tier ${row.requiredTier}`} />
</TableCell>
<TableCell>
<Chip size="small" label={row.status} />
</TableCell>
<TableCell>{row.requestedAt ? formatDateTime(row.requestedAt) : ""}</TableCell>
<TableCell>
<Stack direction="row" spacing={1}>
{Boolean(row.workOrderId) && (
<Button
size="small"
variant="outlined"
onClick={() => navigate(`/workorders/${row.workOrderId}`)}
>
View
</Button>
)}
{row.status === "Pending" && (
<>
<Tooltip
title={!row.canDecide ? `Requires Tier ${row.requiredTier} role` : ""}
>
<span>
<Button
size="small"
variant="contained"
color="success"
disabled={!row.canDecide}
onClick={() => {
setUpliftAction({ type: "approve", item: row });
setNote("");
}}
>
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={() => {
setUpliftAction({ type: "deny", item: row });
setNote("");
}}
>
Deny
</Button>
</span>
</Tooltip>
</>
)}
</Stack>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
<TablePagination <TablePagination
component="div" component="div"
count={totalCount} count={totalCount}
@ -280,46 +440,15 @@ export default function UpliftQueuePage() {
rowsPerPage={pageSize} rowsPerPage={pageSize}
rowsPerPageOptions={[25]} rowsPerPageOptions={[25]}
/> />
<Dialog open={Boolean(upliftAction)} onClose={() => setUpliftAction(null)}> <UpliftDecisionDialog
<DialogTitle> action={upliftAction}
{upliftAction?.type === "approve" note={note}
? `Approve uplift to ${formatMoney(upliftAction.item.requestedNTE)}?` noteRequired={noteRequired}
: "Deny Uplift"} decisionPending={decisionPending}
</DialogTitle> onNoteChange={setNote}
<DialogContent> onClose={closeDialog}
{upliftAction?.type === "approve" && ( onConfirm={handleConfirm}
<Typography variant="body2" className="mt-1 mb-3"> />
{formatMoney(upliftAction.item.currentNTE)} →{" "}
{formatMoney(upliftAction.item.requestedNTE)} (+
{formatMoney(upliftAction.item.delta)})
</Typography>
)}
<TextField
fullWidth
multiline
minRows={2}
className="mt-2"
label={upliftAction?.type === "deny" ? "Denial reason (required)" : "Optional note"}
value={note}
onChange={(e) => setNote(e.target.value)}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setUpliftAction(null)}>Cancel</Button>
<Button
variant="contained"
color={upliftAction?.type === "approve" ? "success" : "error"}
disabled={
upliftAction?.type === "deny"
? !note.trim() || denyUplift.isPending
: approveUplift.isPending
}
onClick={handleConfirm}
>
Confirm
</Button>
</DialogActions>
</Dialog>
</Box> </Box>
); );
} }

View file

@ -1,90 +1,82 @@
import { useState, type ReactNode } from "react"; import { useState } from "react";
import { useNavigate, useParams } from "react-router"; import { useNavigate, useParams } from "react-router";
import { import { Alert, Box, Button, Chip, CircularProgress, Stack, Typography } from "@mui/material";
Alert, import { toast } from "react-toastify";
Box, import { upliftsApi } from "@/domain/uplifts/api/uplifts-api";
Button,
Chip,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Paper,
Stack,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
TextField,
Typography,
} from "@mui/material";
import { useApproveUplift, useDenyUplift } from "@/domain/uplifts/use-cases/use-uplift-actions";
import { useVendorPoDetail } from "@/domain/vendor-pos/use-cases/use-vendor-po-detail";
import type { UpliftRequest } from "@/domain/uplifts/types/uplift"; import type { UpliftRequest } from "@/domain/uplifts/types/uplift";
import { formatDateTime, toDateInputValue } from "@/lib/time-utils"; import {
useApproveUplift,
useRejectUplift,
useRequestChangesUplift,
} from "@/domain/uplifts/use-cases/use-uplift-actions";
import { useVendorPoDetail } from "@/domain/vendor-pos/use-cases/use-vendor-po-detail";
import {
PoDetailsPaper,
SitePaper,
UpliftDecisionDialog,
UpliftHistoryPaper,
VendorSummaryPaper,
WorkOrdersPaper,
type UpliftActionType,
type VendorPoUpliftDecision,
} from "@/app/(protected)/vendor-pos/_components/vendor-po-sections";
function formatMoney(value: number | null | undefined): string { function formatMoney(value: number | null | undefined): string {
if (value == null) return "—"; if (value == null) return "—";
return `$${Number(value).toFixed(2)}`; return `$${Number(value).toFixed(2)}`;
} }
function InfoRow({ label, children }: { label: string; children: ReactNode }) {
return (
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>
{label}
</Typography>
<Typography variant="body2">
{children == null || children === "" ? "—" : children}
</Typography>
</Box>
);
}
export default function VendorPoViewPage() { export default function VendorPoViewPage() {
const { id } = useParams(); const { id } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const { data: dispatch, isLoading, error, refetch } = useVendorPoDetail(id); const { data: dispatch, isLoading, error, refetch } = useVendorPoDetail(id);
const approveUplift = useApproveUplift(id); const approveUplift = useApproveUplift(id);
const denyUplift = useDenyUplift(id); const rejectUplift = useRejectUplift(id);
const requestChangesUplift = useRequestChangesUplift(id);
const [upliftAction, setUpliftAction] = useState<{ const [upliftAction, setUpliftAction] = useState<VendorPoUpliftDecision | null>(null);
type: "approve" | "deny";
uplift: UpliftRequest;
} | null>(null);
const [note, setNote] = useState(""); const [note, setNote] = useState("");
const noteRequired = upliftAction?.type === "reject" || upliftAction?.type === "request-changes";
const decisionPending =
approveUplift.isPending || rejectUplift.isPending || requestChangesUplift.isPending;
const closeUpliftDialog = () => {
setUpliftAction(null);
setNote("");
};
const handleUpliftConfirm = () => { const handleUpliftConfirm = () => {
if (!upliftAction) return; if (!upliftAction) return;
if (noteRequired && !note.trim()) return;
const upliftId = upliftAction.uplift.id;
const onDone = () => {
closeUpliftDialog();
void refetch();
};
if (upliftAction.type === "approve") { if (upliftAction.type === "approve") {
approveUplift.mutate( approveUplift.mutate({ id: upliftId, note: note || undefined }, { onSuccess: onDone });
{ id: upliftAction.uplift.id, note: note || undefined }, } else if (upliftAction.type === "reject") {
{ rejectUplift.mutate({ id: upliftId, note: note.trim() }, { onSuccess: onDone });
onSuccess: () => {
setUpliftAction(null);
setNote("");
void refetch();
},
},
);
} else { } else {
if (!note.trim()) return; requestChangesUplift.mutate({ id: upliftId, note: note.trim() }, { onSuccess: onDone });
denyUplift.mutate(
{ id: upliftAction.uplift.id, note: note.trim() },
{
onSuccess: () => {
setUpliftAction(null);
setNote("");
void refetch();
},
},
);
} }
}; };
const downloadEvidence = async (uplift: UpliftRequest) => {
try {
await upliftsApi.downloadEvidence(uplift.id, uplift.evidenceFileName || "uplift-evidence");
} catch (err) {
toast.error(err instanceof Error ? err.message : "Unable to download evidence.");
}
};
const handleDecide = (type: UpliftActionType, uplift: UpliftRequest) => {
setUpliftAction({ type, uplift });
setNote("");
};
if (isLoading) { if (isLoading) {
return ( return (
<Box className="flex justify-center p-10"> <Box className="flex justify-center p-10">
@ -141,212 +133,29 @@ export default function VendorPoViewPage() {
</Stack> </Stack>
<Stack spacing={3}> <Stack spacing={3}>
<Paper variant="outlined" className="p-4"> <VendorSummaryPaper vendor={vendor} />
<Typography variant="subtitle1" className="mb-3 font-semibold"> <PoDetailsPaper dispatch={dispatch} />
Vendor <SitePaper location={dispatch.primaryLocation} />
</Typography> <WorkOrdersPaper
{vendor ? ( workOrders={dispatch.workOrders}
<Box className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3"> onView={(workOrderId) => navigate(`/workorders/${workOrderId}`)}
<InfoRow label="Company">{vendor.companyName}</InfoRow> />
<InfoRow label="Contact">{vendor.contactName}</InfoRow> <UpliftHistoryPaper
<InfoRow label="Email">{vendor.email}</InfoRow> upliftRequests={dispatch.upliftRequests}
<InfoRow label="Phone">{vendor.phone}</InfoRow> onDownload={(uplift) => void downloadEvidence(uplift)}
<InfoRow label="Address"> onDecide={handleDecide}
{[vendor.address, vendor.city, vendor.state, vendor.zip].filter(Boolean).join(", ")} />
</InfoRow>
<InfoRow label="Trades">{vendor.tradeSpecialties}</InfoRow>
</Box>
) : (
<Typography color="text.secondary">No vendor attached.</Typography>
)}
</Paper>
<Paper variant="outlined" className="p-4">
<Typography variant="subtitle1" className="mb-3 font-semibold">
PO Details
</Typography>
<Box className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3">
<InfoRow label="PO #">{dispatch.poNumber}</InfoRow>
<InfoRow label="Dispatch #">{dispatch.dispatchNumber}</InfoRow>
<InfoRow label="Status">{dispatch.status}</InfoRow>
<InfoRow label="NTE">{formatMoney(dispatch.nteAmount)}</InfoRow>
<InfoRow label="Dispatched">{formatDateTime(dispatch.dispatchedAt)}</InfoRow>
<InfoRow label="Acknowledged">{formatDateTime(dispatch.acknowledgedAt)}</InfoRow>
<InfoRow label="Scheduled">{toDateInputValue(dispatch.scheduledDate) || "—"}</InfoRow>
<InfoRow label="Completed">{toDateInputValue(dispatch.completedDate) || "—"}</InfoRow>
<InfoRow label="Verified">{formatDateTime(dispatch.verifiedAt)}</InfoRow>
<InfoRow label="Email sent">{dispatch.emailSent ? "Yes" : "No"}</InfoRow>
</Box>
{Boolean(dispatch.description) && (
<Box className="mt-3">
<Typography variant="caption" color="text.secondary">
Description
</Typography>
<Typography variant="body2" className="whitespace-pre-wrap">
{dispatch.description}
</Typography>
</Box>
)}
</Paper>
{dispatch.primaryLocation != null && (
<Paper variant="outlined" className="p-4">
<Typography variant="subtitle1" className="mb-3 font-semibold">
Site
</Typography>
<Box className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<InfoRow label="Location">{dispatch.primaryLocation.name}</InfoRow>
<InfoRow label="Address">
{[
dispatch.primaryLocation.address,
dispatch.primaryLocation.city,
dispatch.primaryLocation.state,
dispatch.primaryLocation.zipCode,
]
.filter(Boolean)
.join(", ")}
</InfoRow>
</Box>
</Paper>
)}
<Paper variant="outlined" className="p-4">
<Typography variant="subtitle1" className="mb-3 font-semibold">
Linked Work Orders ({dispatch.workOrders.length})
</Typography>
{dispatch.workOrders.length === 0 ? (
<Typography color="text.secondary">No work orders linked.</Typography>
) : (
<Table size="small">
<TableHead>
<TableRow>
<TableCell>WO #</TableCell>
<TableCell>Title</TableCell>
<TableCell>Trade</TableCell>
<TableCell>Status</TableCell>
<TableCell>Location</TableCell>
</TableRow>
</TableHead>
<TableBody>
{dispatch.workOrders.map((wo) => (
<TableRow key={String(wo.workOrderId)}>
<TableCell>
<Button
variant="text"
size="small"
onClick={() => navigate(`/workorders/${wo.workOrderId}`)}
>
{wo.internalWONumber || wo.workOrderId}
</Button>
</TableCell>
<TableCell>{wo.title}</TableCell>
<TableCell>{wo.trade}</TableCell>
<TableCell>{wo.status}</TableCell>
<TableCell>{wo.locationName}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</Paper>
<Paper variant="outlined" className="p-4">
<Typography variant="subtitle1" className="mb-3 font-semibold">
NTE Uplift History ({dispatch.upliftRequests.length})
</Typography>
{dispatch.upliftRequests.length === 0 ? (
<Typography color="text.secondary">No uplift requests on this PO.</Typography>
) : (
<Stack spacing={2}>
{dispatch.upliftRequests.map((u) => (
<Box key={String(u.id)} className="rounded border border-border p-3">
<Stack
direction="row"
sx={{ justifyContent: "space-between", alignItems: "center" }}
>
<Typography variant="body2" className="font-semibold">
{formatMoney(u.currentNTE)} → {formatMoney(u.requestedNTE)}
<Typography
component="span"
variant="caption"
color="text.secondary"
className="ml-2"
>
(Δ {formatMoney(u.delta)}, Tier {u.requiredTier})
</Typography>
</Typography>
<Chip size="small" label={u.status} />
</Stack>
{Boolean(u.vendorReason) && (
<Typography variant="body2" className="mt-1">
<strong>Reason:</strong> {u.vendorReason}
</Typography>
)}
{u.status === "Pending" && (
<Stack direction="row" spacing={1} className="mt-2">
<Button
size="small"
variant="contained"
color="success"
onClick={() => {
setUpliftAction({ type: "approve", uplift: u });
setNote("");
}}
>
Approve
</Button>
<Button
size="small"
variant="contained"
color="error"
onClick={() => {
setUpliftAction({ type: "deny", uplift: u });
setNote("");
}}
>
Deny
</Button>
</Stack>
)}
</Box>
))}
</Stack>
)}
</Paper>
</Stack> </Stack>
<Dialog open={Boolean(upliftAction)} onClose={() => setUpliftAction(null)}> <UpliftDecisionDialog
<DialogTitle> action={upliftAction}
{upliftAction?.type === "approve" ? "Approve Uplift" : "Deny Uplift"} note={note}
</DialogTitle> noteRequired={noteRequired}
<DialogContent> decisionPending={decisionPending}
<TextField onNoteChange={setNote}
fullWidth onClose={closeUpliftDialog}
multiline onConfirm={handleUpliftConfirm}
minRows={2} />
className="mt-2"
label={upliftAction?.type === "deny" ? "Denial reason (required)" : "Optional note"}
value={note}
onChange={(e) => setNote(e.target.value)}
required={upliftAction?.type === "deny"}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setUpliftAction(null)}>Cancel</Button>
<Button
variant="contained"
color={upliftAction?.type === "approve" ? "success" : "error"}
disabled={
upliftAction?.type === "deny"
? !note.trim() || denyUplift.isPending
: approveUplift.isPending
}
onClick={handleUpliftConfirm}
>
Confirm
</Button>
</DialogActions>
</Dialog>
</Box> </Box>
); );
} }

View file

@ -0,0 +1,341 @@
import { type ReactNode } from "react";
import {
Box,
Button,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Paper,
Stack,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
TextField,
Typography,
} from "@mui/material";
import type { UpliftRequest } from "@/domain/uplifts/types/uplift";
import type {
VendorPoDetail,
VendorPoLocation,
VendorPoVendor,
VendorPoWorkOrder,
} from "@/domain/vendor-pos/types/vendor-po";
import { formatDateTime, toDateInputValue } from "@/lib/time-utils";
export type UpliftActionType = "approve" | "reject" | "request-changes";
export type VendorPoUpliftDecision = { type: UpliftActionType; uplift: UpliftRequest };
function formatMoney(value: number | null | undefined): string {
if (value == null) return "—";
return `$${Number(value).toFixed(2)}`;
}
function InfoRow({ label, children }: { label: string; children: ReactNode }) {
return (
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>
{label}
</Typography>
<Typography variant="body2">
{children == null || children === "" ? "—" : children}
</Typography>
</Box>
);
}
export function VendorSummaryPaper({ vendor }: { vendor: VendorPoVendor | null }) {
return (
<Paper variant="outlined" className="p-4">
<Typography variant="subtitle1" className="mb-3 font-semibold">
Vendor
</Typography>
{vendor ? (
<Box className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3">
<InfoRow label="Company">{vendor.companyName}</InfoRow>
<InfoRow label="Contact">{vendor.contactName}</InfoRow>
<InfoRow label="Email">{vendor.email}</InfoRow>
<InfoRow label="Phone">{vendor.phone}</InfoRow>
<InfoRow label="Address">
{[vendor.address, vendor.city, vendor.state, vendor.zip].filter(Boolean).join(", ")}
</InfoRow>
<InfoRow label="Trades">{vendor.tradeSpecialties}</InfoRow>
</Box>
) : (
<Typography color="text.secondary">No vendor attached.</Typography>
)}
</Paper>
);
}
export function PoDetailsPaper({ dispatch }: { dispatch: VendorPoDetail }) {
return (
<Paper variant="outlined" className="p-4">
<Typography variant="subtitle1" className="mb-3 font-semibold">
PO Details
</Typography>
<Box className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3">
<InfoRow label="PO #">{dispatch.poNumber}</InfoRow>
<InfoRow label="Dispatch #">{dispatch.dispatchNumber}</InfoRow>
<InfoRow label="Status">{dispatch.status}</InfoRow>
<InfoRow label="NTE">{formatMoney(dispatch.nteAmount)}</InfoRow>
<InfoRow label="Dispatched">{formatDateTime(dispatch.dispatchedAt)}</InfoRow>
<InfoRow label="Acknowledged">{formatDateTime(dispatch.acknowledgedAt)}</InfoRow>
<InfoRow label="Scheduled">{toDateInputValue(dispatch.scheduledDate) || "—"}</InfoRow>
<InfoRow label="Completed">{toDateInputValue(dispatch.completedDate) || "—"}</InfoRow>
<InfoRow label="Verified">{formatDateTime(dispatch.verifiedAt)}</InfoRow>
<InfoRow label="Email sent">{dispatch.emailSent ? "Yes" : "No"}</InfoRow>
</Box>
{Boolean(dispatch.description) && (
<Box className="mt-3">
<Typography variant="caption" color="text.secondary">
Description
</Typography>
<Typography variant="body2" className="whitespace-pre-wrap">
{dispatch.description}
</Typography>
</Box>
)}
</Paper>
);
}
export function SitePaper({ location }: { location: VendorPoLocation | null }) {
if (location == null) return null;
return (
<Paper variant="outlined" className="p-4">
<Typography variant="subtitle1" className="mb-3 font-semibold">
Site
</Typography>
<Box className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<InfoRow label="Location">{location.name}</InfoRow>
<InfoRow label="Address">
{[location.address, location.city, location.state, location.zipCode]
.filter(Boolean)
.join(", ")}
</InfoRow>
</Box>
</Paper>
);
}
export function WorkOrdersPaper({
workOrders,
onView,
}: {
workOrders: VendorPoWorkOrder[];
onView: (workOrderId: string | number) => void;
}) {
return (
<Paper variant="outlined" className="p-4">
<Typography variant="subtitle1" className="mb-3 font-semibold">
Linked Work Orders ({workOrders.length})
</Typography>
{workOrders.length === 0 ? (
<Typography color="text.secondary">No work orders linked.</Typography>
) : (
<Table size="small">
<TableHead>
<TableRow>
<TableCell>WO #</TableCell>
<TableCell>Title</TableCell>
<TableCell>Trade</TableCell>
<TableCell>Status</TableCell>
<TableCell>Location</TableCell>
</TableRow>
</TableHead>
<TableBody>
{workOrders.map((wo) => (
<TableRow key={String(wo.workOrderId)}>
<TableCell>
<Button variant="text" size="small" onClick={() => onView(wo.workOrderId)}>
{wo.internalWONumber || wo.workOrderId}
</Button>
</TableCell>
<TableCell>{wo.title}</TableCell>
<TableCell>{wo.trade}</TableCell>
<TableCell>{wo.status}</TableCell>
<TableCell>{wo.locationName}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</Paper>
);
}
function UpliftHistoryItem({
uplift,
onDownload,
onDecide,
}: {
uplift: UpliftRequest;
onDownload: (uplift: UpliftRequest) => void;
onDecide: (type: UpliftActionType, uplift: UpliftRequest) => void;
}) {
const decisionable = uplift.status === "Pending";
return (
<Box className="rounded border border-border p-3">
<Stack direction="row" sx={{ justifyContent: "space-between", alignItems: "center" }}>
<Typography variant="body2" className="font-semibold">
{formatMoney(uplift.currentNTE)} → {formatMoney(uplift.requestedNTE)}
<Typography component="span" variant="caption" color="text.secondary" className="ml-2">
(Tier {uplift.requiredTier})
</Typography>
</Typography>
<Chip size="small" label={uplift.status} />
</Stack>
{Boolean(uplift.expiresAt) && (
<Typography variant="caption" color="text.secondary" className="mt-1 block">
Expires {formatDateTime(uplift.expiresAt)}
</Typography>
)}
{Boolean(uplift.vendorReason) && (
<Typography variant="body2" className="mt-1">
<strong>Reason:</strong> {uplift.vendorReason}
</Typography>
)}
{uplift.evidenceDocumentId != null && (
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }} className="mt-1">
<Typography variant="body2">
<strong>Evidence:</strong>{" "}
{uplift.evidenceFileName || `Document #${uplift.evidenceDocumentId}`}
</Typography>
<Button size="small" variant="text" onClick={() => onDownload(uplift)}>
Download
</Button>
</Stack>
)}
{Boolean(uplift.decisionNote) && (
<Typography variant="body2" className="mt-1">
<strong>Decision:</strong> {uplift.decisionNote}
</Typography>
)}
{uplift.notificationStatus === "Error" && Boolean(uplift.notificationError) && (
<Typography variant="body2" color="error" className="mt-1">
Notification error: {uplift.notificationError}
</Typography>
)}
{decisionable && (
<Stack direction="row" spacing={1} className="mt-2">
<Button
size="small"
variant="contained"
color="success"
disabled={!uplift.canDecide}
onClick={() => onDecide("approve", uplift)}
>
Approve
</Button>
<Button
size="small"
variant="contained"
color="error"
disabled={!uplift.canDecide}
onClick={() => onDecide("reject", uplift)}
>
Reject
</Button>
<Button
size="small"
variant="outlined"
disabled={!uplift.canDecide}
onClick={() => onDecide("request-changes", uplift)}
>
Request Changes
</Button>
</Stack>
)}
</Box>
);
}
export function UpliftHistoryPaper({
upliftRequests,
onDownload,
onDecide,
}: {
upliftRequests: UpliftRequest[];
onDownload: (uplift: UpliftRequest) => void;
onDecide: (type: UpliftActionType, uplift: UpliftRequest) => void;
}) {
return (
<Paper variant="outlined" className="p-4">
<Typography variant="subtitle1" className="mb-3 font-semibold">
NTE Uplift History ({upliftRequests.length})
</Typography>
{upliftRequests.length === 0 ? (
<Typography color="text.secondary">No uplift requests on this PO.</Typography>
) : (
<Stack spacing={2}>
{upliftRequests.map((uplift) => (
<UpliftHistoryItem
key={String(uplift.id)}
uplift={uplift}
onDownload={onDownload}
onDecide={onDecide}
/>
))}
</Stack>
)}
</Paper>
);
}
export function UpliftDecisionDialog({
action,
note,
noteRequired,
decisionPending,
onNoteChange,
onClose,
onConfirm,
}: {
action: VendorPoUpliftDecision | null;
note: string;
noteRequired: boolean;
decisionPending: boolean;
onNoteChange: (value: string) => void;
onClose: () => void;
onConfirm: () => void;
}) {
return (
<Dialog open={Boolean(action)} onClose={onClose}>
<DialogTitle>
{action?.type === "approve"
? "Approve Uplift"
: action?.type === "reject"
? "Reject Uplift"
: "Request Changes"}
</DialogTitle>
<DialogContent>
<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>
);
}

View file

@ -1,82 +1,178 @@
import { Box, Button, Chip, Stack, Tooltip, Typography } from "@mui/material"; import { Box, Button, Chip, Stack, Tooltip, Typography } from "@mui/material";
import type { UpliftRequest } from "@/domain/uplifts/types/uplift"; import type { UpliftRequest } from "@/domain/uplifts/types/uplift";
import { upliftsApi } from "@/domain/uplifts/api/uplifts-api";
import { formatDateTime } from "@/lib/time-utils"; import { formatDateTime } from "@/lib/time-utils";
import { toast } from "react-toastify";
type UpliftAction = "approve" | "reject" | "request-changes";
type DispatchUpliftsSectionProps = { type DispatchUpliftsSectionProps = {
uplifts: UpliftRequest[]; uplifts: UpliftRequest[];
isLocked: boolean; isLocked: boolean;
onApprove: (uplift: UpliftRequest) => void; onApprove: (uplift: UpliftRequest) => void;
onDeny: (uplift: UpliftRequest) => void; onReject: (uplift: UpliftRequest) => void;
onRequestChanges: (uplift: UpliftRequest) => void;
}; };
const ACTION_LABELS: Record<Exclude<UpliftAction, "approve">, string> = {
reject: "Reject",
"request-changes": "Request Changes",
};
function actionColor(action: UpliftAction): "success" | "error" | "primary" {
if (action === "approve") return "success";
if (action === "reject") return "error";
return "primary";
}
function actionVariant(action: UpliftAction): "contained" | "outlined" {
return action === "request-changes" ? "outlined" : "contained";
}
type UpliftCardProps = Omit<DispatchUpliftsSectionProps, "uplifts"> & { type UpliftCardProps = Omit<DispatchUpliftsSectionProps, "uplifts"> & {
uplift: UpliftRequest; uplift: UpliftRequest;
}; };
function UpliftCard({ uplift, isLocked, onApprove, onDeny }: UpliftCardProps) { type UpliftCardActionsProps = Pick<
const canDecide = uplift.canDecide && uplift.status === "Pending" && !isLocked; UpliftCardProps,
"isLocked" | "onApprove" | "onReject" | "onRequestChanges"
> & {
uplift: UpliftRequest;
};
function downloadUpliftEvidence(uplift: UpliftRequest) {
return upliftsApi
.downloadEvidence(uplift.id, uplift.evidenceFileName || "uplift-evidence")
.catch((err: unknown) => {
toast.error(err instanceof Error ? err.message : "Unable to download evidence.");
});
}
function UpliftCardHeader({ uplift }: { uplift: UpliftRequest }) {
const evidenceDocumentId = uplift.evidenceDocumentId;
return ( return (
<Box className="rounded border border-border p-3"> <>
<Stack direction="row" spacing={1} sx={{ alignItems: "center", flexWrap: "wrap" }}> <Stack direction="row" spacing={1} sx={{ alignItems: "center", flexWrap: "wrap" }}>
<Chip label={uplift.status} size="small" /> <Chip label={uplift.status} size="small" />
{uplift.requiredTier === 2 && ( {uplift.requiredTier === 2 && (
<Typography variant="caption">Tier 2 · Manager approval</Typography> <Typography variant="caption">Tier 2 · Manager approval</Typography>
)} )}
<Typography variant="body2"> <Typography variant="body2">
${Number(uplift.currentNTE ?? 0).toFixed(2)} → ${Number(uplift.requestedNTE).toFixed(2)}{" "} ${Number(uplift.currentNTE ?? 0).toFixed(2)} → ${Number(uplift.requestedNTE).toFixed(2)}
(+${Number(uplift.delta).toFixed(2)})
</Typography> </Typography>
</Stack> </Stack>
{Boolean(uplift.expiresAt) && (
<Typography variant="caption" color="text.secondary" className="mt-1 block">
Expires {formatDateTime(uplift.expiresAt)}
</Typography>
)}
{Boolean(uplift.vendorReason) && ( {Boolean(uplift.vendorReason) && (
<Typography variant="body2" className="mt-1"> <Typography variant="body2" className="mt-1">
<strong>Reason:</strong> {uplift.vendorReason} <strong>Reason:</strong> {uplift.vendorReason}
</Typography> </Typography>
)} )}
{evidenceDocumentId != null && (
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }} className="mt-1">
<Typography variant="body2">
<strong>Evidence:</strong>{" "}
{uplift.evidenceFileName || `Document #${evidenceDocumentId}`}
</Typography>
<Button size="small" variant="text" onClick={() => void downloadUpliftEvidence(uplift)}>
Download
</Button>
</Stack>
)}
{uplift.notificationStatus === "Error" && Boolean(uplift.notificationError) && (
<Typography variant="body2" color="error" className="mt-1">
Notification error: {uplift.notificationError}
</Typography>
)}
{Boolean(uplift.requestedByVendorName || uplift.requestedAt) && ( {Boolean(uplift.requestedByVendorName || uplift.requestedAt) && (
<Typography variant="body2" color="text.secondary" className="mt-1"> <Typography variant="body2" color="text.secondary" className="mt-1">
Requested by {uplift.requestedByVendorName || "vendor"} Requested by {uplift.requestedByVendorName || "vendor"}
{uplift.requestedAt ? ` · ${formatDateTime(uplift.requestedAt)}` : ""} {uplift.requestedAt ? ` · ${formatDateTime(uplift.requestedAt)}` : ""}
</Typography> </Typography>
)} )}
{uplift.status !== "Pending" && </>
Boolean(uplift.decidedByName || uplift.decidedAt || uplift.decisionNote) && ( );
<Box className="mt-1"> }
{Boolean(uplift.decidedByName || uplift.decidedAt) && (
<Typography variant="body2" color="text.secondary"> function UpliftCardDecisionSummary({ uplift }: { uplift: UpliftRequest }) {
{uplift.status} by {uplift.decidedByName || "—"} if (uplift.status === "Pending") return null;
{uplift.decidedAt ? ` · ${formatDateTime(uplift.decidedAt)}` : ""} if (!uplift.decidedByName && !uplift.decidedAt && !uplift.decisionNote) return null;
</Typography> return (
)} <Box className="mt-1">
{Boolean(uplift.decisionNote) && ( {Boolean(uplift.decidedByName || uplift.decidedAt) && (
<Typography variant="body2" className="mt-0.5"> <Typography variant="body2" color="text.secondary">
<strong>Note:</strong> {uplift.decisionNote} {uplift.status} by {uplift.decidedByName || "—"}
</Typography> {uplift.decidedAt ? ` · ${formatDateTime(uplift.decidedAt)}` : ""}
)} </Typography>
</Box>
)}
{uplift.status === "Pending" && (
<Stack direction="row" spacing={1} className="mt-2">
{(["approve", "deny"] as const).map((action) => (
<Tooltip
key={action}
title={!canDecide ? `Requires Tier ${uplift.requiredTier} role` : ""}
>
<span>
<Button
size="small"
color={action === "approve" ? "success" : "error"}
variant="contained"
disabled={!canDecide}
onClick={() => (action === "approve" ? onApprove(uplift) : onDeny(uplift))}
>
{action === "approve" ? "Approve" : "Deny"}
</Button>
</span>
</Tooltip>
))}
</Stack>
)} )}
{Boolean(uplift.decisionNote) && (
<Typography variant="body2" className="mt-0.5">
<strong>Note:</strong> {uplift.decisionNote}
</Typography>
)}
</Box>
);
}
function UpliftCardActions({
uplift,
isLocked,
onApprove,
onReject,
onRequestChanges,
}: UpliftCardActionsProps) {
if (uplift.status !== "Pending") return null;
const canDecide = uplift.canDecide && !isLocked;
const decisionActions: Exclude<UpliftAction, "approve">[] = ["reject", "request-changes"];
return (
<Stack direction="row" spacing={1} className="mt-2">
<Tooltip title={!canDecide ? `Requires Tier ${uplift.requiredTier} role` : ""}>
<span>
<Button
size="small"
color="success"
variant="contained"
disabled={!canDecide}
onClick={() => onApprove(uplift)}
>
Approve
</Button>
</span>
</Tooltip>
{decisionActions.map((action) => (
<Tooltip key={action} title={!canDecide ? `Requires Tier ${uplift.requiredTier} role` : ""}>
<span>
<Button
size="small"
color={actionColor(action)}
variant={actionVariant(action)}
disabled={!canDecide}
onClick={() => (action === "reject" ? onReject(uplift) : onRequestChanges(uplift))}
>
{ACTION_LABELS[action]}
</Button>
</span>
</Tooltip>
))}
</Stack>
);
}
function UpliftCard({ uplift, isLocked, onApprove, onReject, onRequestChanges }: UpliftCardProps) {
return (
<Box className="rounded border border-border p-3">
<UpliftCardHeader uplift={uplift} />
<UpliftCardDecisionSummary uplift={uplift} />
<UpliftCardActions
uplift={uplift}
isLocked={isLocked}
onApprove={onApprove}
onReject={onReject}
onRequestChanges={onRequestChanges}
/>
</Box> </Box>
); );
} }
@ -85,7 +181,8 @@ export function DispatchUpliftsSection({
uplifts, uplifts,
isLocked, isLocked,
onApprove, onApprove,
onDeny, onReject,
onRequestChanges,
}: DispatchUpliftsSectionProps) { }: DispatchUpliftsSectionProps) {
return ( return (
<Box> <Box>
@ -102,7 +199,8 @@ export function DispatchUpliftsSection({
uplift={uplift} uplift={uplift}
isLocked={isLocked} isLocked={isLocked}
onApprove={onApprove} onApprove={onApprove}
onDeny={onDeny} onReject={onReject}
onRequestChanges={onRequestChanges}
/> />
))} ))}
</Stack> </Stack>

View file

@ -99,7 +99,10 @@ export function DispatchDetailModal({
uplifts={controller.uplifts} uplifts={controller.uplifts}
isLocked={controller.isLocked} isLocked={controller.isLocked}
onApprove={(uplift) => controller.setUpliftAction({ type: "approve", uplift })} onApprove={(uplift) => controller.setUpliftAction({ type: "approve", uplift })}
onDeny={(uplift) => controller.setUpliftAction({ type: "deny", uplift })} onReject={(uplift) => controller.setUpliftAction({ type: "reject", uplift })}
onRequestChanges={(uplift) =>
controller.setUpliftAction({ type: "request-changes", uplift })
}
/> />
<DispatchVendorCommunication <DispatchVendorCommunication
@ -151,7 +154,9 @@ export function DispatchDetailModal({
onClose={() => controller.setUpliftAction(null)} onClose={() => controller.setUpliftAction(null)}
onConfirm={controller.handleUpliftConfirm} onConfirm={controller.handleUpliftConfirm}
isPending={ isPending={
controller.approveUpliftMutation.isPending || controller.denyUpliftMutation.isPending controller.approveUpliftMutation.isPending ||
controller.rejectUpliftMutation.isPending ||
controller.requestChangesUpliftMutation.isPending
} }
/> />
)} )}

View file

@ -11,7 +11,7 @@ import {
type UpliftDecisionDialogProps = { type UpliftDecisionDialogProps = {
open: boolean; open: boolean;
type: "approve" | "deny"; type: "approve" | "reject" | "request-changes";
requestedNTE: number; requestedNTE: number;
currentNTE: number | null; currentNTE: number | null;
onClose: () => void; onClose: () => void;
@ -19,6 +19,13 @@ type UpliftDecisionDialogProps = {
isPending?: boolean; isPending?: boolean;
}; };
function titleFor(type: UpliftDecisionDialogProps["type"], requestedNTE: number): string {
if (type === "approve") {
return `Approve uplift to $${requestedNTE.toFixed(2)}?`;
}
return type === "reject" ? "Reject Uplift" : "Request Changes";
}
export function UpliftDecisionDialog({ export function UpliftDecisionDialog({
open, open,
type, type,
@ -29,6 +36,7 @@ export function UpliftDecisionDialog({
isPending = false, isPending = false,
}: UpliftDecisionDialogProps) { }: UpliftDecisionDialogProps) {
const [note, setNote] = useState(""); const [note, setNote] = useState("");
const noteRequired = type === "reject" || type === "request-changes";
const handleClose = () => { const handleClose = () => {
setNote(""); setNote("");
@ -36,15 +44,14 @@ export function UpliftDecisionDialog({
}; };
const handleConfirm = () => { const handleConfirm = () => {
if (noteRequired && !note.trim()) return;
onConfirm(note.trim()); onConfirm(note.trim());
setNote(""); setNote("");
}; };
return ( return (
<Dialog open={open} onClose={handleClose}> <Dialog open={open} onClose={handleClose}>
<DialogTitle> <DialogTitle>{titleFor(type, requestedNTE)}</DialogTitle>
{type === "approve" ? `Approve uplift to $${requestedNTE.toFixed(2)}?` : "Deny Uplift"}
</DialogTitle>
<DialogContent> <DialogContent>
{type === "approve" && ( {type === "approve" && (
<Typography variant="body2" className="mt-1 mb-3"> <Typography variant="body2" className="mt-1 mb-3">
@ -57,7 +64,8 @@ export function UpliftDecisionDialog({
multiline multiline
minRows={2} minRows={2}
className="mt-2" className="mt-2"
label={type === "deny" ? "Denial reason (required)" : "Optional note"} label={noteRequired ? "Note (required)" : "Optional note"}
required={noteRequired}
value={note} value={note}
onChange={(event) => setNote(event.target.value)} onChange={(event) => setNote(event.target.value)}
/> />
@ -66,8 +74,8 @@ export function UpliftDecisionDialog({
<Button onClick={handleClose}>Cancel</Button> <Button onClick={handleClose}>Cancel</Button>
<Button <Button
variant="contained" variant="contained"
color={type === "approve" ? "success" : "error"} color={type === "approve" ? "success" : "primary"}
disabled={type === "deny" ? !note.trim() || isPending : isPending} disabled={noteRequired ? !note.trim() || isPending : isPending}
onClick={handleConfirm} onClick={handleConfirm}
> >
Confirm Confirm

View file

@ -1,7 +1,8 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { import {
useApproveUplift, useApproveUplift,
useDenyUplift, useRejectUplift,
useRequestChangesUplift,
useUpliftsForDispatch, useUpliftsForDispatch,
} from "@/domain/uplifts/use-cases/use-uplift-actions"; } from "@/domain/uplifts/use-cases/use-uplift-actions";
import type { UpliftRequest } from "@/domain/uplifts/types/uplift"; import type { UpliftRequest } from "@/domain/uplifts/types/uplift";
@ -68,7 +69,7 @@ export function useDispatchDetailController(
const [sendEmail, setSendEmail] = useState(true); const [sendEmail, setSendEmail] = useState(true);
const [sigCaptureType, setSigCaptureType] = useState<string | null>(null); const [sigCaptureType, setSigCaptureType] = useState<string | null>(null);
const [upliftAction, setUpliftAction] = useState<{ const [upliftAction, setUpliftAction] = useState<{
type: "approve" | "deny"; type: "approve" | "reject" | "request-changes";
uplift: UpliftRequest; uplift: UpliftRequest;
} | null>(null); } | null>(null);
@ -79,7 +80,8 @@ export function useDispatchDetailController(
const addSignoffMutation = useAddDispatchSignoff(dispatchId); const addSignoffMutation = useAddDispatchSignoff(dispatchId);
const verifyMutation = useVerifyDispatch(dispatchId, workOrderId); const verifyMutation = useVerifyDispatch(dispatchId, workOrderId);
const approveUpliftMutation = useApproveUplift(dispatchId); const approveUpliftMutation = useApproveUplift(dispatchId);
const denyUpliftMutation = useDenyUplift(dispatchId); const rejectUpliftMutation = useRejectUplift(dispatchId);
const requestChangesUpliftMutation = useRequestChangesUplift(dispatchId);
const userName = const userName =
(typeof user?.name === "string" ? user.name : undefined) ?? (typeof user?.name === "string" ? user.name : undefined) ??
@ -143,18 +145,19 @@ export function useDispatchDetailController(
return; return;
} }
const id = upliftAction.uplift.id;
if (upliftAction.type === "approve") { if (upliftAction.type === "approve") {
approveUpliftMutation.mutate( approveUpliftMutation.mutate({ id, note }, { onSuccess: () => setUpliftAction(null) });
{ id: upliftAction.uplift.id, note },
{ onSuccess: () => setUpliftAction(null) },
);
return; return;
} }
denyUpliftMutation.mutate( if (!note.trim()) {
{ id: upliftAction.uplift.id, note }, return;
{ onSuccess: () => setUpliftAction(null) }, }
);
const mutation =
upliftAction.type === "reject" ? rejectUpliftMutation : requestChangesUpliftMutation;
mutation.mutate({ id, note }, { onSuccess: () => setUpliftAction(null) });
}; };
return { return {
@ -189,7 +192,8 @@ export function useDispatchDetailController(
addSignoffMutation, addSignoffMutation,
verifyMutation, verifyMutation,
approveUpliftMutation, approveUpliftMutation,
denyUpliftMutation, rejectUpliftMutation,
requestChangesUpliftMutation,
addCommentMutation, addCommentMutation,
}; };
} }

View file

@ -31,6 +31,11 @@ export default function VendorPortalDispatchPage() {
await queryClient.invalidateQueries({ queryKey: queryKeys.vendorPortal.dispatches(token) }); await queryClient.invalidateQueries({ queryKey: queryKeys.vendorPortal.dispatches(token) });
}, [refetch, queryClient, token]); }, [refetch, queryClient, token]);
const pollEvidenceDocument = useCallback(
(documentId: number) => vendorPortalApi.getDocumentStatus(token, dispatchId, documentId),
[token, dispatchId],
);
const runAction = async (action: () => Promise<void>, confirmMsg?: string) => { const runAction = async (action: () => Promise<void>, confirmMsg?: string) => {
if (confirmMsg && !window.confirm(confirmMsg)) return; if (confirmMsg && !window.confirm(confirmMsg)) return;
setActionError(""); setActionError("");
@ -109,6 +114,7 @@ export default function VendorPortalDispatchPage() {
data={data} data={data}
locked={locked} locked={locked}
refresh={refresh} refresh={refresh}
pollEvidenceDocument={pollEvidenceDocument}
updateLocal={updateLocal} updateLocal={updateLocal}
/> />

View file

@ -4,7 +4,10 @@ import { CommentsSection } from "@/app/v/[token]/dispatch/_components/comments-s
import { SignoffSection } from "@/app/v/[token]/dispatch/_components/signoff-section"; import { SignoffSection } from "@/app/v/[token]/dispatch/_components/signoff-section";
import { UpliftRequestsSection } from "@/app/v/[token]/dispatch/_components/uplift-requests-section"; import { UpliftRequestsSection } from "@/app/v/[token]/dispatch/_components/uplift-requests-section";
import { VendorPortalDocuments } from "@/app/v/_components/vendor-portal-documents"; import { VendorPortalDocuments } from "@/app/v/_components/vendor-portal-documents";
import type { VendorPortalDispatchDetail } from "@/domain/vendor-portal/types/vendor-portal"; import type {
VendorPortalDispatchDetail,
VendorPortalDocumentStatus,
} from "@/domain/vendor-portal/types/vendor-portal";
type DispatchDetailSectionsProps = { type DispatchDetailSectionsProps = {
token: string; token: string;
@ -12,6 +15,7 @@ type DispatchDetailSectionsProps = {
data: VendorPortalDispatchDetail; data: VendorPortalDispatchDetail;
locked: boolean; locked: boolean;
refresh: () => Promise<void>; refresh: () => Promise<void>;
pollEvidenceDocument: (documentId: number) => Promise<VendorPortalDocumentStatus | undefined>;
updateLocal: ( updateLocal: (
updater: (current: VendorPortalDispatchDetail) => VendorPortalDispatchDetail, updater: (current: VendorPortalDispatchDetail) => VendorPortalDispatchDetail,
) => void; ) => void;
@ -23,6 +27,7 @@ export function DispatchDetailSections({
data, data,
locked, locked,
refresh, refresh,
pollEvidenceDocument,
updateLocal, updateLocal,
}: DispatchDetailSectionsProps) { }: DispatchDetailSectionsProps) {
return ( return (
@ -65,6 +70,7 @@ export function DispatchDetailSections({
upliftRequests={data.upliftRequests ?? []} upliftRequests={data.upliftRequests ?? []}
locked={locked} locked={locked}
onChanged={refresh} onChanged={refresh}
pollDocument={pollEvidenceDocument}
/> />
</div> </div>

View file

@ -0,0 +1,136 @@
import { useState } from "react";
import { Text } from "@/components/ui/text";
import { UpliftRequestForm } from "@/app/v/[token]/dispatch/_components/uplift-request-form";
import type { UpliftFormValues } from "@/domain/vendor-portal/lib/uplift-validation";
import type { VendorPortalUpliftRequest } from "@/domain/vendor-portal/types/vendor-portal";
import { formatDateTime } from "@/lib/time-utils";
type UpliftRequestCardProps = {
request: VendorPortalUpliftRequest;
locked: boolean;
canAct: boolean;
uploadingPercent: number | undefined;
submitting: boolean;
errorMessage: string;
onWithdraw: (request: VendorPortalUpliftRequest) => void;
onRevise: (request: VendorPortalUpliftRequest, values: UpliftFormValues) => void;
onCancelRevise: () => void;
};
const WITHDRAWABLE_STATUSES = new Set(["Pending", "ChangesRequested"]);
function statusClassName(status: string): string {
return `vp-uplift-status-${String(status).toLowerCase()}`;
}
export function UpliftRequestCard({
request,
locked,
canAct,
uploadingPercent,
submitting,
errorMessage,
onWithdraw,
onRevise,
onCancelRevise,
}: UpliftRequestCardProps) {
const [revising, setRevising] = useState(false);
const canWithdraw = !locked && canAct && WITHDRAWABLE_STATUSES.has(request.status);
const canRevise = !locked && canAct && request.status === "ChangesRequested";
return (
<div className="vp-uplift-row">
<div className="vp-row">
<strong>Requested</strong>
<span>
${Number(request.requestedNTE).toFixed(2)} (was $
{Number(request.currentNTE ?? 0).toFixed(2)})
</span>
</div>
<div className="vp-row">
<strong>Status</strong>
<span>
<span className={statusClassName(request.status)}>{request.status}</span>
</span>
</div>
{Boolean(request.expiresAt) && (
<div className="vp-row">
<strong>Expires</strong>
<span>{formatDateTime(request.expiresAt as string)}</span>
</div>
)}
{Boolean(request.vendorReason) && (
<div className="vp-row">
<strong>Reason</strong>
<span>{request.vendorReason}</span>
</div>
)}
{Boolean(request.evidenceFileName) && (
<div className="vp-row">
<strong>Evidence</strong>
<span>
{request.evidenceFileName}
{request.evidenceContentType ? ` · ${request.evidenceContentType}` : ""}
</span>
</div>
)}
{Boolean(request.decisionNote) && (
<div className="vp-row">
<strong>Decision</strong>
<span>
{request.decisionNote}
{request.decidedByName ? ` — ${request.decidedByName}` : ""}
</span>
</div>
)}
{request.notificationStatus === "Error" && Boolean(request.notificationError) && (
<div className="vp-row">
<strong>Notification</strong>
<span className="vp-error-inline">{request.notificationError}</span>
</div>
)}
<Text variant="error" when={Boolean(errorMessage)}>
{errorMessage}
</Text>
{revising && canRevise ? (
<UpliftRequestForm
mode="revise"
currentNte={request.currentNTE}
initialRequested={String(request.requestedNTE)}
initialReason={request.vendorReason ?? ""}
hasExistingEvidence={request.evidenceDocumentId != null}
submitting={submitting}
uploadingPercent={uploadingPercent}
errorMessage=""
onSubmit={(values) => onRevise(request, values)}
onCancel={() => {
setRevising(false);
onCancelRevise();
}}
/>
) : (
<div className="vp-actions">
{canWithdraw && (
<button
type="button"
className="vp-btn vp-btn-ghost"
onClick={() => onWithdraw(request)}
>
Withdraw request
</button>
)}
{canRevise && (
<button
type="button"
className="vp-btn vp-btn-primary"
onClick={() => setRevising(true)}
>
Revise
</button>
)}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,148 @@
import { useState } from "react";
import { Text } from "@/components/ui/text";
import { validateVendorDocument } from "@/domain/vendor-portal/lib/document-validation";
import {
isApprovalRequired,
validateUpliftAmount,
type UpliftFormValues,
} from "@/domain/vendor-portal/lib/uplift-validation";
type UpliftRequestFormProps = {
mode: "create" | "revise";
currentNte?: number;
initialRequested?: string;
initialReason?: string;
hasExistingEvidence?: boolean;
submitting: boolean;
uploadingPercent: number | undefined;
errorMessage: string;
onSubmit: (values: UpliftFormValues) => void;
onCancel?: () => void;
};
export function UpliftRequestForm({
mode,
currentNte,
initialRequested = "",
initialReason = "",
hasExistingEvidence = false,
submitting,
uploadingPercent,
errorMessage,
onSubmit,
onCancel,
}: UpliftRequestFormProps) {
const [requested, setRequested] = useState(initialRequested);
const [reason, setReason] = useState(initialReason);
const [evidenceFile, setEvidenceFile] = useState<File | undefined>();
const [formError, setFormError] = useState("");
const showApprovalFields = mode === "revise" || isApprovalRequired(requested, currentNte);
const submit = (event: React.FormEvent) => {
event.preventDefault();
const amount = Number.parseFloat(requested);
const validationError = validateUpliftAmount(requested, currentNte, reason, {
file: evidenceFile,
hasExisting: hasExistingEvidence,
mode,
});
setFormError(validationError);
if (validationError) {
return;
}
onSubmit({ amount, reason: reason.trim(), evidenceFile });
};
const chooseFile = (file?: File) => {
const validationMessage = file ? validateVendorDocument(file) : undefined;
setFormError(validationMessage ?? "");
setEvidenceFile(file);
};
const shownError = errorMessage || formError;
const busy = submitting || uploadingPercent !== undefined;
return (
<form
onSubmit={(event) => void submit(event)}
className="vp-uplift-form"
aria-label={mode === "revise" ? "Revise uplift request" : "Request NTE uplift"}
>
{mode === "create" && (
<div className="vp-row">
<strong>Current NTE</strong>
<span>${Number(currentNte ?? 0).toFixed(2)}</span>
</div>
)}
<div className="vp-row">
<strong>
<label htmlFor={`uplift-amount-${mode}`}>New total NTE</label>
</strong>
<input
id={`uplift-amount-${mode}`}
type="number"
step="0.01"
min="0"
className="vp-input"
value={requested}
onChange={(event) => {
setRequested(event.target.value);
setFormError("");
}}
placeholder="0.00"
/>
</div>
{showApprovalFields && (
<>
<div className="vp-row">
<strong>
<label htmlFor={`uplift-reason-${mode}`}>Reason</label>
</strong>
<textarea
id={`uplift-reason-${mode}`}
className="vp-textarea"
value={reason}
onChange={(event) => {
setReason(event.target.value);
setFormError("");
}}
placeholder="Why is the uplift needed?"
/>
</div>
<div className="vp-row">
<strong>
<label htmlFor={`uplift-evidence-${mode}`}>Evidence (PDF/JPG/PNG)</label>
</strong>
<input
id={`uplift-evidence-${mode}`}
type="file"
className="vp-input"
accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png"
onChange={(event) => chooseFile(event.target.files?.[0])}
/>
</div>
</>
)}
{uploadingPercent !== undefined && (
<div className="vp-row" role="status" aria-live="polite">
<strong>Uploading</strong>
<span>{uploadingPercent}%</span>
</div>
)}
<Text variant="error" when={Boolean(shownError)}>
{shownError}
</Text>
<div className="vp-actions">
<button type="submit" className="vp-btn vp-btn-primary" disabled={busy}>
{busy ? "Submitting…" : mode === "revise" ? "Submit revision" : "Request uplift"}
</button>
{mode === "revise" && (
<button type="button" className="vp-btn vp-btn-ghost" onClick={onCancel} disabled={busy}>
Cancel
</button>
)}
</div>
</form>
);
}

View file

@ -1,7 +1,20 @@
import { useState } from "react"; import { useCallback, useState } from "react";
import { Text } from "@/components/ui/text"; import { VendorPortalFeedback } from "@/app/v/_components/vendor-portal-feedback";
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api"; import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
import type { VendorPortalUpliftRequest } from "@/domain/vendor-portal/types/vendor-portal"; import { createUpliftRequestKey } from "@/domain/vendor-portal/lib/request-key";
import type {
VendorPortalDocumentStatus,
VendorPortalUpliftRequest,
} from "@/domain/vendor-portal/types/vendor-portal";
import { UpliftRequestForm } from "@/app/v/[token]/dispatch/_components/uplift-request-form";
import type { UpliftFormValues } from "@/domain/vendor-portal/lib/uplift-validation";
import { UpliftRequestCard } from "@/app/v/[token]/dispatch/_components/uplift-request-card";
import {
useVendorUpliftEvidence,
type EvidenceScanState,
} from "@/app/v/[token]/dispatch/_components/use-uplift-evidence";
const ACTIVE_STATUSES = new Set(["Pending", "ChangesRequested"]);
type UpliftRequestsSectionProps = { type UpliftRequestsSectionProps = {
token: string; token: string;
@ -10,20 +23,141 @@ type UpliftRequestsSectionProps = {
upliftRequests: VendorPortalUpliftRequest[]; upliftRequests: VendorPortalUpliftRequest[];
locked: boolean; locked: boolean;
onChanged: () => Promise<void>; onChanged: () => Promise<void>;
pollDocument: (documentId: number) => Promise<VendorPortalDocumentStatus | undefined>;
}; };
function validateUpliftRequest(requested: string, currentNte: number | undefined, reason: string) { type UseVendorUpliftActionsArgs = {
const amount = Number.parseFloat(requested); token: string;
if (!Number.isFinite(amount) || amount <= 0) { dispatchId: number;
return "Enter a valid requested NTE amount."; currentNte?: number;
onChanged: () => Promise<void>;
evidence: ReturnType<typeof useVendorUpliftEvidence>;
};
function scanErrorMessage(scan: EvidenceScanState): string {
if (scan === "Rejected") {
return "The evidence file was rejected by the security scan.";
} }
if (currentNte != null && amount <= currentNte) { return "Security scan timed out. Please try again once the file finishes scanning.";
return `Requested NTE must be greater than the current NTE ($${currentNte.toFixed(2)}).`; }
}
if (!reason.trim()) { function useVendorUpliftActions({
return "Please include a reason for the uplift request."; token,
} dispatchId,
return ""; currentNte,
onChanged,
evidence,
}: UseVendorUpliftActionsArgs) {
const [submitting, setSubmitting] = useState(false);
const [formError, setFormError] = useState("");
const [infoMessage, setInfoMessage] = useState("");
const [requestError, setRequestError] = useState("");
const [errorRequestId, setErrorRequestId] = useState<string | number | null>(null);
const uploadAndScan = useCallback(
async (file: File): Promise<{ documentId: number; scan: EvidenceScanState }> => {
const document = await evidence.uploadEvidence({ token, dispatchId, file });
const scan = await evidence.waitForScan(document.id);
evidence.setScanState(scan);
return { documentId: document.id, scan };
},
[evidence, token, dispatchId],
);
const handleCreate = async (values: UpliftFormValues) => {
setFormError("");
setInfoMessage("");
setSubmitting(true);
try {
const requiresApproval = currentNte == null || values.amount > (currentNte ?? 0);
let evidenceDocumentId: number | undefined;
if (requiresApproval) {
if (!values.evidenceFile) {
setFormError(
"Attach PDF, JPG, or PNG evidence — approval-required requests need evidence.",
);
return;
}
const result = await uploadAndScan(values.evidenceFile);
if (result.scan !== "Passed") {
setFormError(scanErrorMessage(result.scan));
return;
}
evidenceDocumentId = result.documentId;
}
const outcome = await vendorPortalApi.requestUplift(token, dispatchId, {
requestedNTE: values.amount,
reason: values.reason,
requestKey: createUpliftRequestKey(),
evidenceDocumentId,
});
evidence.reset();
await onChanged();
if (outcome.outcome === "NoApprovalRequired") {
setInfoMessage("No approval required — the existing NTE already covers this amount.");
}
} catch {
setFormError("Unable to request the NTE uplift. Please try again.");
} finally {
setSubmitting(false);
}
};
const handleWithdraw = async (request: VendorPortalUpliftRequest) => {
if (!window.confirm("Withdraw this uplift request?")) return;
setErrorRequestId(request.id);
setRequestError("");
setSubmitting(true);
try {
await vendorPortalApi.withdrawUplift(token, dispatchId, request.id);
await onChanged();
} catch {
setRequestError("Unable to withdraw the uplift request. Please try again.");
} finally {
setSubmitting(false);
}
};
const handleRevise = async (request: VendorPortalUpliftRequest, values: UpliftFormValues) => {
setErrorRequestId(request.id);
setRequestError("");
setSubmitting(true);
try {
const requiresApproval =
request.currentNTE == null || values.amount > (request.currentNTE ?? 0);
let evidenceDocumentId = request.evidenceDocumentId;
if (requiresApproval && values.evidenceFile) {
const result = await uploadAndScan(values.evidenceFile);
if (result.scan !== "Passed") {
setRequestError(scanErrorMessage(result.scan));
return;
}
evidenceDocumentId = result.documentId;
}
await vendorPortalApi.reviseUplift(token, dispatchId, request.id, {
requestedNTE: values.amount,
reason: values.reason,
evidenceDocumentId,
});
evidence.reset();
await onChanged();
} catch {
setRequestError("Unable to revise the uplift request. Please try again.");
} finally {
setSubmitting(false);
}
};
return {
submitting,
formError,
infoMessage,
requestError,
errorRequestId,
handleCreate,
handleWithdraw,
handleRevise,
};
} }
export function UpliftRequestsSection({ export function UpliftRequestsSection({
@ -33,136 +167,56 @@ export function UpliftRequestsSection({
upliftRequests, upliftRequests,
locked, locked,
onChanged, onChanged,
pollDocument,
}: UpliftRequestsSectionProps) { }: UpliftRequestsSectionProps) {
const [requested, setRequested] = useState(""); const evidence = useVendorUpliftEvidence({ pollDocument });
const [reason, setReason] = useState(""); const {
const [submitting, setSubmitting] = useState(false); submitting,
const [errorMessage, setErrorMessage] = useState(""); formError,
infoMessage,
requestError,
errorRequestId,
handleCreate,
handleWithdraw,
handleRevise,
} = useVendorUpliftActions({ token, dispatchId, currentNte, onChanged, evidence });
const pending = upliftRequests.find((request) => request.status === "Pending"); const activeRequest = upliftRequests.find((request) => ACTIVE_STATUSES.has(request.status));
const canShowForm = !locked && !activeRequest;
const submit = async (event: React.FormEvent) => {
event.preventDefault();
const amount = Number.parseFloat(requested);
const validationError = validateUpliftRequest(requested, currentNte, reason);
setErrorMessage(validationError);
if (validationError) {
return;
}
setSubmitting(true);
try {
await vendorPortalApi.requestUplift(token, dispatchId, {
requestedNTE: amount,
reason: reason.trim(),
});
setRequested("");
setReason("");
await onChanged();
} catch {
setErrorMessage("Unable to request the NTE uplift. Please try again.");
} finally {
setSubmitting(false);
}
};
const cancel = async (requestId: number) => {
if (!window.confirm("Cancel this uplift request?")) return;
setErrorMessage("");
try {
await vendorPortalApi.cancelUplift(token, dispatchId, requestId);
await onChanged();
} catch {
setErrorMessage("Unable to cancel the NTE uplift request. Please try again.");
}
};
return ( return (
<> <>
{upliftRequests.length === 0 && !pending && ( {upliftRequests.length === 0 && !activeRequest && (
<div className="vp-muted" style={{ marginBottom: 12 }}> <VendorPortalFeedback>
No uplift requests yet. Use the form below if the work will exceed the current NTE. No uplift requests yet. Use the form below if the work will exceed the current NTE.
</div> </VendorPortalFeedback>
)} )}
{Boolean(infoMessage) && <VendorPortalFeedback>{infoMessage}</VendorPortalFeedback>}
{upliftRequests.map((request) => ( {upliftRequests.map((request) => (
<div key={request.id} className="vp-uplift-row"> <UpliftRequestCard
<div className="vp-row"> key={request.id}
<strong>Requested</strong> request={request}
<span> locked={locked}
${Number(request.requestedNTE).toFixed(2)} (was $ canAct={!locked}
{Number(request.currentNTE ?? 0).toFixed(2)}) uploadingPercent={evidence.uploadingPercent}
</span> submitting={submitting}
</div> errorMessage={errorRequestId === request.id ? requestError : ""}
<div className="vp-row"> onWithdraw={(r) => void handleWithdraw(r)}
<strong>Status</strong> onRevise={(r, values) => void handleRevise(r, values)}
<span> onCancelRevise={evidence.reset}
<span className={`vp-uplift-status-${String(request.status).toLowerCase()}`}> />
{request.status}
</span>
</span>
</div>
{Boolean(request.vendorReason) && (
<div className="vp-row">
<strong>Reason</strong>
<span>{request.vendorReason}</span>
</div>
)}
{request.status === "Pending" && (
<div className="vp-actions">
<button
type="button"
className="vp-btn vp-btn-ghost"
onClick={() => void cancel(request.id)}
>
Cancel Request
</button>
</div>
)}
</div>
))} ))}
<Text variant="error" when={Boolean(errorMessage)}>
{errorMessage}
</Text>
{!locked && !pending && ( {canShowForm && (
<form onSubmit={(event) => void submit(event)} style={{ marginTop: 12 }}> <UpliftRequestForm
<div className="vp-row"> mode="create"
<strong>Current NTE</strong> currentNte={currentNte}
<span>${Number(currentNte ?? 0).toFixed(2)}</span> submitting={submitting}
</div> uploadingPercent={evidence.uploadingPercent}
<div className="vp-row"> errorMessage={formError}
<strong>New total NTE</strong> onSubmit={(values) => void handleCreate(values)}
<input />
type="number"
step="0.01"
min="0"
className="vp-input"
value={requested}
onChange={(event) => {
setRequested(event.target.value);
setErrorMessage("");
}}
placeholder="0.00"
/>
</div>
<div className="vp-row">
<strong>Reason</strong>
<textarea
className="vp-textarea"
value={reason}
onChange={(event) => {
setReason(event.target.value);
setErrorMessage("");
}}
placeholder="Why is the uplift needed?"
/>
</div>
<div className="vp-actions">
<button type="submit" className="vp-btn vp-btn-primary" disabled={submitting}>
{submitting ? "Submitting…" : "Request Uplift"}
</button>
</div>
</form>
)} )}
</> </>
); );

View file

@ -0,0 +1,72 @@
import { useCallback, useState } from "react";
import { useMutation } from "@tanstack/react-query";
import {
VENDOR_EVIDENCE_PURPOSE,
vendorPortalApi,
} from "@/domain/vendor-portal/api/vendor-portal-api";
import type { VendorPortalDocumentStatus } from "@/domain/vendor-portal/types/vendor-portal";
const POLL_INTERVAL_MS = 1000;
const MAX_POLL_ATTEMPTS = 30;
export type EvidenceScanState = "Passed" | "Rejected" | "Timeout";
export type UploadEvidenceArgs = {
token: string;
dispatchId: number;
file: File;
};
type UseVendorUpliftEvidenceOptions = {
pollDocument: (documentId: number) => Promise<VendorPortalDocumentStatus | undefined>;
};
export function useVendorUpliftEvidence({ pollDocument }: UseVendorUpliftEvidenceOptions) {
const [uploadingPercent, setUploadingPercent] = useState<number | undefined>();
const [scanState, setScanState] = useState<EvidenceScanState | undefined>();
const uploadMutation = useMutation({
mutationFn: ({ token, dispatchId, file }: UploadEvidenceArgs) =>
vendorPortalApi.uploadDocument({
token,
dispatchId,
file,
purpose: VENDOR_EVIDENCE_PURPOSE,
onProgress: setUploadingPercent,
}),
});
const waitForScan = useCallback(
async (documentId: number): Promise<EvidenceScanState> => {
for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS; attempt += 1) {
const document = await pollDocument(documentId);
if (document?.scanStatus === "Passed") {
return "Passed";
}
if (document?.scanStatus === "Rejected") {
return "Rejected";
}
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
}
return "Timeout";
},
[pollDocument],
);
const reset = useCallback(() => {
setUploadingPercent(undefined);
setScanState(undefined);
uploadMutation.reset();
}, [uploadMutation]);
return {
uploadEvidence: uploadMutation.mutateAsync,
uploadError: uploadMutation.error ? "Unable to upload the evidence file." : undefined,
uploadingPercent,
scanState,
setScanState,
waitForScan,
isUploading: uploadMutation.isPending,
reset,
};
}

View file

@ -300,6 +300,56 @@
color: var(--status-pending-text); color: var(--status-pending-text);
} }
.vp-uplift-status-approved {
display: inline-block;
padding: 3px 9px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
background: var(--status-completed-bg);
color: var(--status-completed-text);
}
.vp-uplift-status-rejected {
display: inline-block;
padding: 3px 9px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
background: var(--doc-no-bg);
color: var(--doc-no-text);
}
.vp-uplift-status-changesrequested {
display: inline-block;
padding: 3px 9px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
background: var(--status-scheduled-bg);
color: var(--status-scheduled-text);
}
.vp-uplift-status-withdrawn,
.vp-uplift-status-expired {
display: inline-block;
padding: 3px 9px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
background: var(--input-background);
color: var(--muted-foreground);
}
.vp-uplift-form {
margin-top: 12px;
}
.vp-error-inline {
color: var(--doc-no-text);
font-weight: 600;
}
.vp-nav { .vp-nav {
background: var(--background); background: var(--background);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);

View file

@ -1,9 +1,9 @@
import { HTTPError } from "ky";
import { API_PATHS } from "@/api/api-paths"; import { API_PATHS } from "@/api/api-paths";
import { apiGet, apiPost } from "@/api/api"; import { api, apiGet, apiPost } from "@/api/api";
import { handleApiResponse } from "@/api/handle-api-response"; import { handleApiResponse } from "@/api/handle-api-response";
import { mapUpliftQueueItems } from "@/domain/uplifts/mappers/uplift-mapper"; import { 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 { mapUpliftRequests } from "@/domain/work-orders/mappers/work-order-mapper";
import type { UpliftRequest } from "@/domain/uplifts/types/uplift"; import type { UpliftRequest } from "@/domain/uplifts/types/uplift";
function buildQueueParams(params: UpliftsQueueParams): Record<string, string | number> { function buildQueueParams(params: UpliftsQueueParams): Record<string, string | number> {
@ -16,6 +16,26 @@ function buildQueueParams(params: UpliftsQueueParams): Record<string, string | n
return searchParams; return searchParams;
} }
function evidenceUrl(id: string | number): string {
return `${API_PATHS.rest.uplifts}/${id}/evidence`;
}
function readContentDispositionFilename(response: Response, fallback: string): string {
const disposition = response.headers.get("Content-Disposition") ?? "";
const match = /filename="?([^";]+)"?/i.exec(disposition);
return match ? match[1] : fallback;
}
function evidenceHttpError(status: number): Error {
if (status === 423) {
return new Error("Evidence is still being scanned. Try again shortly.");
}
if (status === 403) {
return new Error("You are not authorized to view this evidence.");
}
return new Error("Unable to download evidence right now.");
}
export const upliftsApi = { export const upliftsApi = {
list: async (params: UpliftsQueueParams = {}): Promise<UpliftsQueueResult> => { list: async (params: UpliftsQueueParams = {}): Promise<UpliftsQueueResult> => {
const data = await apiGet<unknown>(API_PATHS.rest.uplifts, { const data = await apiGet<unknown>(API_PATHS.rest.uplifts, {
@ -57,7 +77,35 @@ export const upliftsApi = {
await apiPost<unknown>(`${API_PATHS.rest.uplifts}/${id}/approve`, { note }); await apiPost<unknown>(`${API_PATHS.rest.uplifts}/${id}/approve`, { note });
}, },
deny: async (id: string | number, note: string): Promise<void> => { reject: async (id: string | number, note: string): Promise<void> => {
await apiPost<unknown>(`${API_PATHS.rest.uplifts}/${id}/deny`, { note }); await apiPost<unknown>(`${API_PATHS.rest.uplifts}/${id}/reject`, { note });
},
requestChanges: async (id: string | number, note: string): Promise<void> => {
await apiPost<unknown>(`${API_PATHS.rest.uplifts}/${id}/request-changes`, { note });
},
downloadEvidence: async (
id: string | number,
fallbackFileName = "uplift-evidence",
): Promise<void> => {
let response: Response;
try {
response = await api.get(evidenceUrl(id));
} catch (error) {
if (error instanceof HTTPError) {
throw evidenceHttpError(error.response.status);
}
throw error;
}
const blob = await response.blob();
const fileName = readContentDispositionFilename(response, fallbackFileName);
const url = URL.createObjectURL(blob);
const anchor = window.document.createElement("a");
anchor.href = url;
anchor.download = fileName;
anchor.click();
URL.revokeObjectURL(url);
}, },
}; };

View file

@ -1,5 +1,4 @@
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift"; import type { UpliftQueueItem, UpliftRequest } from "@/domain/uplifts/types/uplift";
import { mapUpliftRequest } from "@/domain/work-orders/mappers/work-order-mapper";
function asRecord(raw: unknown): Record<string, unknown> { function asRecord(raw: unknown): Record<string, unknown> {
return typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {}; return typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
@ -14,6 +13,21 @@ function readString(record: Record<string, unknown>, ...keys: string[]): string
return ""; return "";
} }
function readNumber(record: Record<string, unknown>, ...keys: string[]): number | null {
for (const key of keys) {
const value = record[key];
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim() && Number.isFinite(Number(value))) {
return Number(value);
}
}
return null;
}
function readBool(record: Record<string, unknown>, ...keys: string[]): boolean {
return keys.some((key) => record[key] === true);
}
function readOptionalId( function readOptionalId(
record: Record<string, unknown>, record: Record<string, unknown>,
...keys: string[] ...keys: string[]
@ -25,6 +39,36 @@ function readOptionalId(
return null; return null;
} }
export function mapUpliftRequest(raw: unknown): UpliftRequest {
const item = asRecord(raw);
return {
id: readOptionalId(item, "id", "Id") ?? "",
status: readString(item, "status", "Status"),
currentNTE: readNumber(item, "currentNTE", "CurrentNTE"),
requestedNTE: readNumber(item, "requestedNTE", "RequestedNTE") ?? 0,
delta: readNumber(item, "delta", "Delta") ?? 0,
vendorReason: readString(item, "vendorReason", "VendorReason"),
requestedAt: readString(item, "requestedAt", "RequestedAt"),
requestedByVendorName: readString(item, "requestedByVendorName", "RequestedByVendorName"),
decidedAt: readString(item, "decidedAt", "DecidedAt"),
decidedByName: readString(item, "decidedByName", "DecidedByName"),
decisionNote: readString(item, "decisionNote", "DecisionNote"),
requiredTier: readNumber(item, "requiredTier", "RequiredTier") ?? 1,
canDecide: readBool(item, "canDecide", "CanDecide"),
expiresAt: readString(item, "expiresAt", "ExpiresAt"),
notificationStatus: readString(item, "notificationStatus", "NotificationStatus"),
notificationError: readString(item, "notificationError", "NotificationError"),
evidenceDocumentId: readOptionalId(item, "evidenceDocumentId", "EvidenceDocumentId"),
evidenceFileName: readString(item, "evidenceFileName", "EvidenceFileName"),
evidenceContentType: readString(item, "evidenceContentType", "EvidenceContentType"),
evidenceSizeBytes: readNumber(item, "evidenceSizeBytes", "EvidenceSizeBytes"),
};
}
export function mapUpliftRequests(rawItems: unknown[]): UpliftRequest[] {
return rawItems.map(mapUpliftRequest);
}
export function mapUpliftQueueItem(raw: unknown): UpliftQueueItem { export function mapUpliftQueueItem(raw: unknown): UpliftQueueItem {
const base = mapUpliftRequest(raw); const base = mapUpliftRequest(raw);
const item = asRecord(raw); const item = asRecord(raw);

View file

@ -1,3 +1,19 @@
export const UPLIFT_STATUSES = [
"Pending",
"Approved",
"Rejected",
"ChangesRequested",
"Withdrawn",
"Expired",
] as const;
export type UpliftStatus = (typeof UPLIFT_STATUSES)[number];
export const UPLIFT_DECISIONABLE_STATUSES = ["Pending"] as const;
export const UPLIFT_NOTIFICATION_STATUSES = ["Pending", "Sent", "Error"] as const;
export type UpliftNotificationStatus = (typeof UPLIFT_NOTIFICATION_STATUSES)[number];
export interface UpliftRequest { export interface UpliftRequest {
id: string | number; id: string | number;
status: string; status: string;
@ -12,6 +28,13 @@ export interface UpliftRequest {
decisionNote: string; decisionNote: string;
requiredTier: number; requiredTier: number;
canDecide: boolean; canDecide: boolean;
expiresAt: string;
notificationStatus: string;
notificationError: string;
evidenceDocumentId: string | number | null;
evidenceFileName: string;
evidenceContentType: string;
evidenceSizeBytes: number | null;
} }
export interface UpliftQueueItem extends UpliftRequest { export interface UpliftQueueItem extends UpliftRequest {

View file

@ -38,7 +38,7 @@ export interface ApproveUpliftInput {
note?: string; note?: string;
} }
export interface DenyUpliftInput { export interface DecisionUpliftInput {
id: string | number; id: string | number;
note: string; note: string;
} }
@ -61,19 +61,38 @@ export function useApproveUplift(
}); });
} }
export function useDenyUplift( export function useRejectUplift(
dispatchId?: string | number, dispatchId?: string | number,
): UseMutationResult<void, Error, DenyUpliftInput> { ): UseMutationResult<void, Error, DecisionUpliftInput> {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: ({ id, note }: { id: string | number; note: string }) => upliftsApi.deny(id, note), mutationFn: ({ id, note }: { id: string | number; note: string }) =>
upliftsApi.reject(id, note),
onSuccess: () => { onSuccess: () => {
invalidateUpliftQueries(queryClient, dispatchId); invalidateUpliftQueries(queryClient, dispatchId);
toast.success("Uplift denied"); toast.success("Uplift rejected");
}, },
onError: (error: Error) => { onError: (error: Error) => {
toast.error(error.message || "Failed to deny uplift"); toast.error(error.message || "Failed to reject uplift");
},
});
}
export function useRequestChangesUplift(
dispatchId?: string | number,
): UseMutationResult<void, Error, DecisionUpliftInput> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, note }: { id: string | number; note: string }) =>
upliftsApi.requestChanges(id, note),
onSuccess: () => {
invalidateUpliftQueries(queryClient, dispatchId);
toast.success("Changes requested");
},
onError: (error: Error) => {
toast.error(error.message || "Failed to request changes");
}, },
}); });
} }

View file

@ -6,8 +6,12 @@ import type {
VendorPortalDispatchDetail, VendorPortalDispatchDetail,
VendorPortalDispatchSummary, VendorPortalDispatchSummary,
VendorPortalDocument, VendorPortalDocument,
VendorPortalDocumentStatus,
VendorPortalSession, VendorPortalSession,
VendorPortalSignoff, VendorPortalSignoff,
VendorUpliftRequestBody,
VendorUpliftRequestOutcome,
VendorUpliftReviseBody,
} from "@/domain/vendor-portal/types/vendor-portal"; } from "@/domain/vendor-portal/types/vendor-portal";
function resolveVendorPortalUrl(path: string): string { function resolveVendorPortalUrl(path: string): string {
@ -15,11 +19,14 @@ function resolveVendorPortalUrl(path: string): string {
return `${base}/${API_PATHS.vendorPortal.base}${path}`; return `${base}/${API_PATHS.vendorPortal.base}${path}`;
} }
export const VENDOR_EVIDENCE_PURPOSE = "UpliftEvidence" as const;
type UploadVendorDocumentOptions = { type UploadVendorDocumentOptions = {
token: string; token: string;
dispatchId: string | number; dispatchId: string | number;
file: File; file: File;
replacesDocumentId?: number; replacesDocumentId?: number;
purpose?: string;
onProgress: (percent: number) => void; onProgress: (percent: number) => void;
}; };
@ -28,6 +35,7 @@ function uploadVendorDocument({
dispatchId, dispatchId,
file, file,
replacesDocumentId, replacesDocumentId,
purpose,
onProgress, onProgress,
}: UploadVendorDocumentOptions): Promise<VendorPortalDocument> { }: UploadVendorDocumentOptions): Promise<VendorPortalDocument> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@ -51,6 +59,7 @@ function uploadVendorDocument({
if (replacesDocumentId !== undefined) { if (replacesDocumentId !== undefined) {
body.append("replacesDocumentId", String(replacesDocumentId)); body.append("replacesDocumentId", String(replacesDocumentId));
} }
body.append("purpose", purpose ?? "Completion");
request.send(body); request.send(body);
}); });
} }
@ -106,6 +115,23 @@ async function vendorPortalCall<T>(
return (payload.data ?? payload.Data ?? payload) as T; return (payload.data ?? payload.Data ?? payload) as T;
} }
function isNoApprovalRequired(payload: unknown): boolean {
if (typeof payload !== "object" || payload === null) return false;
const record = payload as Record<string, unknown>;
return (
record.outcome === "NoApprovalRequired" ||
record.status === "NoApprovalRequired" ||
record.noApprovalRequired === true
);
}
function toVendorUpliftOutcome(payload: unknown): VendorUpliftRequestOutcome {
if (isNoApprovalRequired(payload)) {
return { outcome: "NoApprovalRequired" };
}
return { outcome: "Created" };
}
export const vendorPortalApi = { export const vendorPortalApi = {
session: (token: string) => session: (token: string) =>
vendorPortalCall<VendorPortalSession>(token, API_PATHS.vendorPortal.session), vendorPortalCall<VendorPortalSession>(token, API_PATHS.vendorPortal.session),
@ -186,23 +212,41 @@ export const vendorPortalApi = {
{ method: "POST", body: { commentText } }, { method: "POST", body: { commentText } },
), ),
requestUplift: ( requestUplift: (token: string, dispatchId: string | number, payload: VendorUpliftRequestBody) =>
token: string, vendorPortalCall<VendorUpliftRequestOutcome>(
id: string | number, token,
payload: { requestedNTE: number; reason: string }, `${API_PATHS.vendorPortal.dispatches}/${dispatchId}/uplift-request`,
) => {
vendorPortalCall<void>(token, `${API_PATHS.vendorPortal.dispatches}/${id}/uplift-request`, { method: "POST",
method: "POST", body: payload,
body: payload, },
}), ).then(toVendorUpliftOutcome),
cancelUplift: (token: string, id: string | number, requestId: string | number) => withdrawUplift: (token: string, dispatchId: string | number, requestId: string | number) =>
vendorPortalCall<void>( vendorPortalCall<void>(
token, token,
`${API_PATHS.vendorPortal.dispatches}/${id}/uplift-request/${requestId}/cancel`, `${API_PATHS.vendorPortal.dispatches}/${dispatchId}/uplift-request/${requestId}/withdraw`,
{ method: "POST" }, { method: "POST" },
), ),
reviseUplift: (
token: string,
dispatchId: string | number,
requestId: string | number,
payload: VendorUpliftReviseBody,
) =>
vendorPortalCall<void>(
token,
`${API_PATHS.vendorPortal.dispatches}/${dispatchId}/uplift-request/${requestId}/revise`,
{ method: "POST", body: payload },
),
getDocumentStatus: (token: string, dispatchId: string | number, documentId: string | number) =>
vendorPortalCall<VendorPortalDocumentStatus>(
token,
`${API_PATHS.vendorPortal.dispatches}/${dispatchId}/documents/${documentId}/status`,
),
uploadDocument: uploadVendorDocument, uploadDocument: uploadVendorDocument,
downloadDocument: downloadVendorDocument, downloadDocument: downloadVendorDocument,
}; };

View file

@ -0,0 +1,6 @@
export function createUpliftRequestKey(): string {
if (typeof globalThis.crypto?.randomUUID !== "function") {
throw new Error("Secure request identifiers are unavailable in this browser");
}
return globalThis.crypto.randomUUID();
}

View file

@ -0,0 +1,54 @@
export type UpliftFormValues = {
amount: number;
reason: string;
evidenceFile?: File;
};
export type UpliftMode = "create" | "revise";
export type UpliftValidationOptions = {
file?: File;
hasExisting?: boolean;
mode?: UpliftMode;
};
export function isApprovalRequired(
requested: string,
currentNte: number | undefined,
mode: UpliftMode = "create",
): boolean {
const amount = Number.parseFloat(requested);
if (!Number.isFinite(amount)) {
return currentNte == null;
}
if (mode === "revise") {
return true;
}
return currentNte == null || amount > currentNte;
}
export function validateUpliftAmount(
requested: string,
currentNte: number | undefined,
reason: string,
options: UpliftValidationOptions = {},
): string {
const mode = options.mode ?? "create";
const amount = Number.parseFloat(requested);
if (!Number.isFinite(amount) || amount <= 0) {
return "Enter a valid requested NTE amount.";
}
if (mode === "revise" && currentNte != null && amount <= currentNte) {
return `The revised NTE must be greater than the current NTE ($${currentNte.toFixed(2)}).`;
}
if (!isApprovalRequired(requested, currentNte, mode)) {
return "";
}
if (!reason.trim()) {
return "Please include a reason for the uplift request.";
}
if (!options.file && !options.hasExisting) {
return "Attach PDF, JPG, or PNG evidence — approval-required requests need evidence.";
}
return "";
}

View file

@ -33,12 +33,18 @@ export interface VendorPortalDocument {
rejectionReason?: string; rejectionReason?: string;
version: number; version: number;
replacesDocumentId?: number; replacesDocumentId?: number;
purpose?: string;
createdDate?: string; createdDate?: string;
scannedAt?: string; scannedAt?: string;
reviewedAt?: string; reviewedAt?: string;
canDownload: boolean; canDownload: boolean;
} }
export type VendorPortalDocumentStatus = Pick<
VendorPortalDocument,
"id" | "originalFileName" | "scanStatus" | "reviewStatus" | "purpose"
>;
export interface VendorPortalChecklistItem { export interface VendorPortalChecklistItem {
id: number; id: number;
itemText: string; itemText: string;
@ -65,6 +71,29 @@ export interface VendorPortalUpliftRequest {
decidedByName?: string; decidedByName?: string;
decisionNote?: string; decisionNote?: string;
requiredTier?: number; requiredTier?: number;
expiresAt?: string;
notificationStatus?: string;
notificationError?: string;
evidenceDocumentId?: number;
evidenceFileName?: string;
evidenceContentType?: string;
evidenceSizeBytes?: number;
}
export type VendorUpliftRequestOutcome =
{ outcome: "NoApprovalRequired" } | { outcome: "Created"; request?: VendorPortalUpliftRequest };
export interface VendorUpliftRequestBody {
requestedNTE: number;
reason: string;
requestKey: string;
evidenceDocumentId?: number;
}
export interface VendorUpliftReviseBody {
requestedNTE: number;
reason: string;
evidenceDocumentId?: number;
} }
export interface VendorPortalSignoff { export interface VendorPortalSignoff {

View file

@ -9,7 +9,7 @@ import type {
VendorPoVendor, VendorPoVendor,
VendorPoWorkOrder, VendorPoWorkOrder,
} from "@/domain/vendor-pos/types/vendor-po"; } from "@/domain/vendor-pos/types/vendor-po";
import { mapUpliftRequests } from "@/domain/work-orders/mappers/work-order-mapper"; import { mapUpliftRequests } from "@/domain/uplifts/mappers/uplift-mapper";
function asRecord(raw: unknown): Record<string, unknown> { function asRecord(raw: unknown): Record<string, unknown> {
return typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {}; return typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};

View file

@ -10,7 +10,6 @@ import type {
WorkOrderFormValues, WorkOrderFormValues,
WorkOrderListItem, WorkOrderListItem,
} from "@/domain/work-orders/types/work-order"; } from "@/domain/work-orders/types/work-order";
import type { UpliftRequest } from "@/domain/uplifts/types/uplift";
import type { WorkOrderStatus } from "@/domain/work-orders/types/work-order-common"; import type { WorkOrderStatus } from "@/domain/work-orders/types/work-order-common";
import { WORK_ORDER_STATUS_OPTIONS } from "@/domain/work-orders/types/work-order-common"; import { WORK_ORDER_STATUS_OPTIONS } from "@/domain/work-orders/types/work-order-common";
import { toDateInputValue } from "@/lib/time-utils"; import { toDateInputValue } from "@/lib/time-utils";
@ -462,27 +461,3 @@ export {
mapDispatchWorkOrderRef, mapDispatchWorkOrderRef,
mapUpdateDispatchPayload, mapUpdateDispatchPayload,
} from "./work-order-dispatch-mapper"; } from "./work-order-dispatch-mapper";
export function mapUpliftRequest(raw: unknown): UpliftRequest {
const item = asRecord(raw);
return {
id: readId(item),
status: readString(item, "status", "Status"),
currentNTE: readNumber(item, "currentNTE", "CurrentNTE"),
requestedNTE: readNumber(item, "requestedNTE", "RequestedNTE") ?? 0,
delta: readNumber(item, "delta", "Delta") ?? 0,
vendorReason: readString(item, "vendorReason", "VendorReason"),
requestedAt: readString(item, "requestedAt", "RequestedAt"),
requestedByVendorName: readString(item, "requestedByVendorName", "RequestedByVendorName"),
decidedAt: readString(item, "decidedAt", "DecidedAt"),
decidedByName: readString(item, "decidedByName", "DecidedByName"),
decisionNote: readString(item, "decisionNote", "DecisionNote"),
requiredTier: readNumber(item, "requiredTier", "RequiredTier") ?? 1,
canDecide: readBool(item, "canDecide", "CanDecide"),
};
}
export function mapUpliftRequests(rawItems: unknown[]): UpliftRequest[] {
return rawItems.map(mapUpliftRequest);
}

View file

@ -88,6 +88,7 @@ export const queryKeys = {
[...queryKeys.uplifts.all, "queue", params ?? {}] as const, [...queryKeys.uplifts.all, "queue", params ?? {}] as const,
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,
}, },
vendorPos: { vendorPos: {
all: ["vendorPos"] as const, all: ["vendorPos"] as const,

View file

@ -55,6 +55,7 @@ function renderSections({
data={data} data={data}
locked={locked} locked={locked}
refresh={vi.fn()} refresh={vi.fn()}
pollEvidenceDocument={vi.fn(async () => undefined)}
updateLocal={vi.fn()} updateLocal={vi.fn()}
/>, />,
{ withAuth: false }, { withAuth: false },

View file

@ -0,0 +1,48 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { UpliftRequestForm } from "@/app/v/[token]/dispatch/_components/uplift-request-form";
function renderForm(overrides: Partial<React.ComponentProps<typeof UpliftRequestForm>> = {}) {
return render(
<UpliftRequestForm
mode="revise"
currentNte={1000}
initialRequested="1000"
initialReason=""
hasExistingEvidence={false}
submitting={false}
uploadingPercent={undefined}
errorMessage=""
onSubmit={vi.fn()}
onCancel={vi.fn()}
{...overrides}
/>,
);
}
describe("UpliftRequestForm revise mode", () => {
it("shows reason and evidence fields regardless of amount", () => {
renderForm({ initialRequested: "900" });
expect(screen.getByLabelText(/reason/i)).toBeInTheDocument();
expect(screen.getByLabelText(/evidence/i)).toBeInTheDocument();
});
it("blocks submit and does not call onSubmit when revised NTE is at or below current NTE", () => {
const onSubmit = vi.fn();
renderForm({ initialRequested: "1000", onSubmit });
fireEvent.click(screen.getByRole("button", { name: /submit revision/i }));
expect(onSubmit).not.toHaveBeenCalled();
expect(screen.getByText(/must be greater than the current NTE/i)).toBeInTheDocument();
});
it("reports the current NTE threshold in the actionable error", () => {
renderForm({ initialRequested: "900", currentNte: 1000 });
fireEvent.click(screen.getByRole("button", { name: /submit revision/i }));
expect(screen.getByText(/\$1000\.00/)).toBeInTheDocument();
});
});

View file

@ -0,0 +1,86 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
import { createUpliftRequestKey } from "@/domain/vendor-portal/lib/request-key";
import { validateUpliftAmount } from "@/domain/vendor-portal/lib/uplift-validation";
afterEach(() => {
vi.restoreAllMocks();
});
function jsonResponse(data: unknown): Response {
return new Response(JSON.stringify({ status: "Success", data }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
describe("vendor uplift workflow contract", () => {
it("requires reason and evidence only when the requested NTE exceeds the current NTE", () => {
expect(validateUpliftAmount("90", 100, "", {})).toBe("");
expect(validateUpliftAmount("110", 100, "", {})).toMatch(/reason/i);
expect(validateUpliftAmount("110", 100, "Emergency work", {})).toMatch(/evidence/i);
expect(validateUpliftAmount("110", 100, "Emergency work", { hasExisting: true })).toBe("");
});
it("keeps create-mode requests at or below current NTE as NoApprovalRequired", () => {
expect(validateUpliftAmount("90", 100, "", { mode: "create" })).toBe("");
expect(validateUpliftAmount("100", 100, "", { mode: "create" })).toBe("");
});
it("rejects revise-mode requests at or below current NTE with an actionable error", () => {
expect(
validateUpliftAmount("90", 100, "Reason", { hasExisting: true, mode: "revise" }),
).toMatch(/must be greater than the current NTE/i);
expect(
validateUpliftAmount("100", 100, "Reason", { hasExisting: true, mode: "revise" }),
).toMatch(/must be greater than the current NTE/i);
});
it("still requires approval details in revise mode when current NTE is unknown", () => {
expect(validateUpliftAmount("110", undefined, "", { mode: "revise" })).toMatch(/reason/i);
expect(validateUpliftAmount("110", undefined, "Emergency work", { mode: "revise" })).toMatch(
/evidence/i,
);
expect(
validateUpliftAmount("110", undefined, "Emergency work", {
hasExisting: true,
mode: "revise",
}),
).toBe("");
});
it("uses a secure UUID request key", () => {
expect(createUpliftRequestKey()).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
);
});
it("uses dispatch-scoped request, withdraw, revise, and status routes", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse({ id: 31 }));
await vendorPortalApi.requestUplift("token", 7, {
requestedNTE: 125,
reason: "Parts",
requestKey: "key-1",
evidenceDocumentId: 22,
});
await vendorPortalApi.withdrawUplift("token", 7, 31);
await vendorPortalApi.reviseUplift("token", 7, 31, {
requestedNTE: 130,
reason: "Updated parts",
evidenceDocumentId: 22,
});
await vendorPortalApi.getDocumentStatus("token", 7, 22);
expect(fetchMock.mock.calls.map(([url]) => new URL(String(url)).pathname)).toEqual([
"/api/vendor-portal/dispatches/7/uplift-request",
"/api/vendor-portal/dispatches/7/uplift-request/31/withdraw",
"/api/vendor-portal/dispatches/7/uplift-request/31/revise",
"/api/vendor-portal/dispatches/7/documents/22/status",
]);
expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toMatchObject({
requestedNTE: 125,
evidenceDocumentId: 22,
});
});
});

View file

@ -1,9 +1,9 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
mapUpliftRequest,
mapWorkOrderDispatchSummary, mapWorkOrderDispatchSummary,
mapWorkOrderTableRow, mapWorkOrderTableRow,
} from "@/domain/work-orders/mappers/work-order-mapper"; } from "@/domain/work-orders/mappers/work-order-mapper";
import { mapUpliftRequest } from "@/domain/uplifts/mappers/uplift-mapper";
describe("mapWorkOrderDispatchSummary", () => { describe("mapWorkOrderDispatchSummary", () => {
it("preserves the assigned vendor identity needed by inline editing", () => { it("preserves the assigned vendor identity needed by inline editing", () => {
@ -39,6 +39,12 @@ describe("mapUpliftRequest", () => {
DecisionNote: "Approved per contract", DecisionNote: "Approved per contract",
RequiredTier: 2, RequiredTier: 2,
CanDecide: false, CanDecide: false,
ExpiresAt: "2026-02-05T08:00:00Z",
NotificationStatus: "Sent",
EvidenceDocumentId: 77,
EvidenceFileName: "invoice.pdf",
EvidenceContentType: "application/pdf",
EvidenceSizeBytes: 2048,
}); });
expect(result).toEqual({ expect(result).toEqual({
@ -55,16 +61,26 @@ describe("mapUpliftRequest", () => {
decisionNote: "Approved per contract", decisionNote: "Approved per contract",
requiredTier: 2, requiredTier: 2,
canDecide: false, canDecide: false,
expiresAt: "2026-02-05T08:00:00Z",
notificationStatus: "Sent",
notificationError: "",
evidenceDocumentId: 77,
evidenceFileName: "invoice.pdf",
evidenceContentType: "application/pdf",
evidenceSizeBytes: 2048,
}); });
}); });
it("defaults missing numeric fields", () => { it("defaults missing numeric and evidence fields", () => {
const result = mapUpliftRequest({ id: 1, status: "Pending" }); const result = mapUpliftRequest({ id: 1, status: "Pending" });
expect(result.requestedNTE).toBe(0); expect(result.requestedNTE).toBe(0);
expect(result.delta).toBe(0); expect(result.delta).toBe(0);
expect(result.requiredTier).toBe(1); expect(result.requiredTier).toBe(1);
expect(result.canDecide).toBe(false); expect(result.canDecide).toBe(false);
expect(result.evidenceDocumentId).toBeNull();
expect(result.evidenceFileName).toBe("");
expect(result.evidenceSizeBytes).toBeNull();
}); });
}); });