mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-06 05:22:04 +00:00
Merge pull request #114 from Sea-Haven-Industries/feat/sh-101-uplift-lifecycle
feat(uplifts): complete vendor approval lifecycle (SH-101)
This commit is contained in:
commit
2cf33ca6c1
30 changed files with 2369 additions and 753 deletions
356
e2e/vendors/vendor-uplift-workflow.spec.ts
vendored
Normal file
356
e2e/vendors/vendor-uplift-workflow.spec.ts
vendored
Normal 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." },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
136
src/app/v/[token]/dispatch/_components/uplift-request-card.tsx
Normal file
136
src/app/v/[token]/dispatch/_components/uplift-request-card.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
148
src/app/v/[token]/dispatch/_components/uplift-request-form.tsx
Normal file
148
src/app/v/[token]/dispatch/_components/uplift-request-form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
6
src/domain/vendor-portal/lib/request-key.ts
Normal file
6
src/domain/vendor-portal/lib/request-key.ts
Normal 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();
|
||||||
|
}
|
||||||
54
src/domain/vendor-portal/lib/uplift-validation.ts
Normal file
54
src/domain/vendor-portal/lib/uplift-validation.ts
Normal 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 "";
|
||||||
|
}
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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>) : {};
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
|
|
|
||||||
48
src/test/app/v/uplift-request-form.test.tsx
Normal file
48
src/test/app/v/uplift-request-form.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
86
src/test/domain/vendor-portal/uplift-workflow.test.ts
Normal file
86
src/test/domain/vendor-portal/uplift-workflow.test.ts
Normal 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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue