mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
Compare commits
No commits in common. "2cf33ca6c1a10f6d301f92103bbc24a48e80569a" and "a30ac73d2982ea4cd670b05f9e933f4b9315ae5f" have entirely different histories.
2cf33ca6c1
...
a30ac73d29
56 changed files with 838 additions and 3931 deletions
356
e2e/vendors/vendor-uplift-workflow.spec.ts
vendored
356
e2e/vendors/vendor-uplift-workflow.spec.ts
vendored
|
|
@ -1,356 +0,0 @@
|
|||
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,319 +27,28 @@ import {
|
|||
Tooltip,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { upliftsApi } from "@/domain/uplifts/api/uplifts-api";
|
||||
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
|
||||
import { UPLIFT_STATUSES } from "@/domain/uplifts/types/uplift";
|
||||
import {
|
||||
useApproveUplift,
|
||||
useRejectUplift,
|
||||
useRequestChangesUplift,
|
||||
} from "@/domain/uplifts/use-cases/use-uplift-actions";
|
||||
import { useApproveUplift, useDenyUplift } from "@/domain/uplifts/use-cases/use-uplift-actions";
|
||||
import { useUpliftsQueue } from "@/domain/uplifts/use-cases/use-uplifts-queue";
|
||||
import { formatDateTime } from "@/lib/time-utils";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
const STATUS_OPTIONS = ["", ...UPLIFT_STATUSES];
|
||||
|
||||
type UpliftActionType = "approve" | "reject" | "request-changes";
|
||||
|
||||
type UpliftDecision = { type: UpliftActionType; item: UpliftQueueItem };
|
||||
const STATUS_OPTIONS = ["Pending", "Approved", "Denied", "Cancelled", ""];
|
||||
|
||||
function formatMoney(value: number | null | undefined): string {
|
||||
return `$${Number(value ?? 0).toFixed(2)}`;
|
||||
}
|
||||
|
||||
function formatBytes(value: number | null | undefined): string {
|
||||
if (value == null) return "";
|
||||
if (value < 1024) return `${value} B`;
|
||||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(0)} KB`;
|
||||
return `${(value / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function UpliftQueueHeader({
|
||||
totalCount,
|
||||
isFetching,
|
||||
isLoading,
|
||||
}: {
|
||||
totalCount: number;
|
||||
isFetching: boolean;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
return (
|
||||
<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() {
|
||||
const navigate = useNavigate();
|
||||
const [status, setStatus] = useState("Pending");
|
||||
const [tier, setTier] = useState("");
|
||||
const [page, setPage] = useState(0);
|
||||
const [pageSize] = useState(25);
|
||||
const [upliftAction, setUpliftAction] = useState<UpliftDecision | null>(null);
|
||||
|
||||
const [upliftAction, setUpliftAction] = useState<{
|
||||
type: "approve" | "deny";
|
||||
item: UpliftQueueItem;
|
||||
} | null>(null);
|
||||
const [note, setNote] = useState("");
|
||||
|
||||
const listParams = useMemo(
|
||||
|
|
@ -349,59 +58,39 @@ export default function UpliftQueuePage() {
|
|||
|
||||
const { data, isLoading, isFetching, error, refetch } = useUpliftsQueue(listParams);
|
||||
const approveUplift = useApproveUplift();
|
||||
const rejectUplift = useRejectUplift();
|
||||
const requestChangesUplift = useRequestChangesUplift();
|
||||
const denyUplift = useDenyUplift();
|
||||
|
||||
const rows = data?.items ?? [];
|
||||
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 = () => {
|
||||
if (!upliftAction) return;
|
||||
if (noteRequired && !note.trim()) return;
|
||||
const id = upliftAction.item.id;
|
||||
const onDone = () => {
|
||||
closeDialog();
|
||||
void refetch();
|
||||
};
|
||||
if (upliftAction.type === "approve") {
|
||||
approveUplift.mutate({ id, note: note || undefined }, { onSuccess: onDone });
|
||||
} else if (upliftAction.type === "reject") {
|
||||
rejectUplift.mutate({ id, note: note.trim() }, { onSuccess: onDone });
|
||||
approveUplift.mutate(
|
||||
{ id: upliftAction.item.id, note: note || undefined },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setUpliftAction(null);
|
||||
setNote("");
|
||||
void refetch();
|
||||
},
|
||||
},
|
||||
);
|
||||
} else {
|
||||
requestChangesUplift.mutate({ id, note: note.trim() }, { onSuccess: onDone });
|
||||
if (!note.trim()) return;
|
||||
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 (
|
||||
<Box className="flex flex-col gap-4 p-4">
|
||||
<Stack
|
||||
|
|
@ -412,26 +101,177 @@ export default function UpliftQueuePage() {
|
|||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<UpliftQueueHeader totalCount={totalCount} isFetching={isFetching} isLoading={isLoading} />
|
||||
<UpliftQueueFilters
|
||||
status={status}
|
||||
tier={tier}
|
||||
onStatusChange={handleStatusChange}
|
||||
onTierChange={handleTierChange}
|
||||
/>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
{Boolean(error) && (
|
||||
<Alert severity="error">
|
||||
{error instanceof Error ? error.message : "Failed to load uplift queue"}
|
||||
</Alert>
|
||||
)}
|
||||
<UpliftQueueTable
|
||||
isLoading={isLoading}
|
||||
rows={rows}
|
||||
onViewWorkOrder={(r) => navigate(`/workorders/${r.workOrderId}`)}
|
||||
onDownloadEvidence={(r) => void downloadEvidence(r)}
|
||||
onDecide={handleDecide}
|
||||
/>
|
||||
<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">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
|
||||
component="div"
|
||||
count={totalCount}
|
||||
|
|
@ -440,15 +280,46 @@ export default function UpliftQueuePage() {
|
|||
rowsPerPage={pageSize}
|
||||
rowsPerPageOptions={[25]}
|
||||
/>
|
||||
<UpliftDecisionDialog
|
||||
action={upliftAction}
|
||||
note={note}
|
||||
noteRequired={noteRequired}
|
||||
decisionPending={decisionPending}
|
||||
onNoteChange={setNote}
|
||||
onClose={closeDialog}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
<Dialog open={Boolean(upliftAction)} onClose={() => setUpliftAction(null)}>
|
||||
<DialogTitle>
|
||||
{upliftAction?.type === "approve"
|
||||
? `Approve uplift to ${formatMoney(upliftAction.item.requestedNTE)}?`
|
||||
: "Deny Uplift"}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
{upliftAction?.type === "approve" && (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,82 +1,90 @@
|
|||
import { useState } from "react";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import { Alert, Box, Button, Chip, CircularProgress, Stack, Typography } from "@mui/material";
|
||||
import { toast } from "react-toastify";
|
||||
import { upliftsApi } from "@/domain/uplifts/api/uplifts-api";
|
||||
import type { UpliftRequest } from "@/domain/uplifts/types/uplift";
|
||||
import {
|
||||
useApproveUplift,
|
||||
useRejectUplift,
|
||||
useRequestChangesUplift,
|
||||
} from "@/domain/uplifts/use-cases/use-uplift-actions";
|
||||
Alert,
|
||||
Box,
|
||||
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 {
|
||||
PoDetailsPaper,
|
||||
SitePaper,
|
||||
UpliftDecisionDialog,
|
||||
UpliftHistoryPaper,
|
||||
VendorSummaryPaper,
|
||||
WorkOrdersPaper,
|
||||
type UpliftActionType,
|
||||
type VendorPoUpliftDecision,
|
||||
} from "@/app/(protected)/vendor-pos/_components/vendor-po-sections";
|
||||
import type { UpliftRequest } from "@/domain/uplifts/types/uplift";
|
||||
import { formatDateTime, toDateInputValue } from "@/lib/time-utils";
|
||||
|
||||
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 default function VendorPoViewPage() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { data: dispatch, isLoading, error, refetch } = useVendorPoDetail(id);
|
||||
|
||||
const approveUplift = useApproveUplift(id);
|
||||
const rejectUplift = useRejectUplift(id);
|
||||
const requestChangesUplift = useRequestChangesUplift(id);
|
||||
const denyUplift = useDenyUplift(id);
|
||||
|
||||
const [upliftAction, setUpliftAction] = useState<VendorPoUpliftDecision | null>(null);
|
||||
const [upliftAction, setUpliftAction] = useState<{
|
||||
type: "approve" | "deny";
|
||||
uplift: UpliftRequest;
|
||||
} | null>(null);
|
||||
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 = () => {
|
||||
if (!upliftAction) return;
|
||||
if (noteRequired && !note.trim()) return;
|
||||
const upliftId = upliftAction.uplift.id;
|
||||
const onDone = () => {
|
||||
closeUpliftDialog();
|
||||
void refetch();
|
||||
};
|
||||
if (upliftAction.type === "approve") {
|
||||
approveUplift.mutate({ id: upliftId, note: note || undefined }, { onSuccess: onDone });
|
||||
} else if (upliftAction.type === "reject") {
|
||||
rejectUplift.mutate({ id: upliftId, note: note.trim() }, { onSuccess: onDone });
|
||||
approveUplift.mutate(
|
||||
{ id: upliftAction.uplift.id, note: note || undefined },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setUpliftAction(null);
|
||||
setNote("");
|
||||
void refetch();
|
||||
},
|
||||
},
|
||||
);
|
||||
} else {
|
||||
requestChangesUplift.mutate({ id: upliftId, note: note.trim() }, { onSuccess: onDone });
|
||||
if (!note.trim()) return;
|
||||
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) {
|
||||
return (
|
||||
<Box className="flex justify-center p-10">
|
||||
|
|
@ -133,29 +141,212 @@ export default function VendorPoViewPage() {
|
|||
</Stack>
|
||||
|
||||
<Stack spacing={3}>
|
||||
<VendorSummaryPaper vendor={vendor} />
|
||||
<PoDetailsPaper dispatch={dispatch} />
|
||||
<SitePaper location={dispatch.primaryLocation} />
|
||||
<WorkOrdersPaper
|
||||
workOrders={dispatch.workOrders}
|
||||
onView={(workOrderId) => navigate(`/workorders/${workOrderId}`)}
|
||||
/>
|
||||
<UpliftHistoryPaper
|
||||
upliftRequests={dispatch.upliftRequests}
|
||||
onDownload={(uplift) => void downloadEvidence(uplift)}
|
||||
onDecide={handleDecide}
|
||||
/>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<UpliftDecisionDialog
|
||||
action={upliftAction}
|
||||
note={note}
|
||||
noteRequired={noteRequired}
|
||||
decisionPending={decisionPending}
|
||||
onNoteChange={setNote}
|
||||
onClose={closeUpliftDialog}
|
||||
onConfirm={handleUpliftConfirm}
|
||||
/>
|
||||
<Dialog open={Boolean(upliftAction)} onClose={() => setUpliftAction(null)}>
|
||||
<DialogTitle>
|
||||
{upliftAction?.type === "approve" ? "Approve Uplift" : "Deny Uplift"}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<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)}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,341 +0,0 @@
|
|||
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,21 +1,39 @@
|
|||
import { useParams } from "react-router";
|
||||
import { useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import { Alert, Box, CircularProgress } from "@mui/material";
|
||||
import { DispatchCreateModal } from "./_components/dispatch-create-modal";
|
||||
import { DispatchDetailModal } from "./_components/dispatch-detail-modal";
|
||||
import { WorkOrderActivityPanel } from "./_components/view/work-order-activity-panel";
|
||||
import { WorkOrderDispatchesAccordion } from "./_components/view/work-order-dispatches-accordion";
|
||||
import { WorkOrderVendorEditorDialog } from "./_components/view/work-order-vendor-editor-dialog";
|
||||
import { WorkOrderViewAccordions } from "./_components/view/work-order-view-accordions";
|
||||
import { WorkOrderViewDetails } from "./_components/view/work-order-view-details";
|
||||
import { WorkOrderViewHeader } from "./_components/view/work-order-view-header";
|
||||
import { useWorkOrderViewPage } from "./_hooks/use-work-order-view-page";
|
||||
import { VendorEditorDialog } from "./_components/vendor-editor-dialog";
|
||||
import { useVendorEditor } from "./_components/use-vendor-editor";
|
||||
import { WorkOrderActivity } from "./_components/work-order-activity";
|
||||
import {
|
||||
DispatchAccordion,
|
||||
WorkOrderDetailsSection,
|
||||
WorkOrderHeader,
|
||||
WorkOrderSummaryAccordions,
|
||||
} from "./_components/work-order-view-sections";
|
||||
import { useWorkOrderDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||
import {
|
||||
useChangeWorkOrderAssignment,
|
||||
useChangeWorkOrderStatus,
|
||||
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
||||
import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
|
||||
|
||||
export default function WorkOrderViewPage() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const workOrderId = id ?? "";
|
||||
const view = useWorkOrderViewPage(workOrderId);
|
||||
|
||||
if (view.isLoading) {
|
||||
const { data: wo, isLoading, error, refetch } = useWorkOrderDetail(workOrderId);
|
||||
const { data: users = [] } = useWorkOrderUsers();
|
||||
const statusMutation = useChangeWorkOrderStatus(workOrderId);
|
||||
const assignmentMutation = useChangeWorkOrderAssignment(workOrderId);
|
||||
const vendorEditor = useVendorEditor({ workOrderId, onSaved: () => void refetch() });
|
||||
|
||||
const [dispatchOpen, setDispatchOpen] = useState(false);
|
||||
const [dispatchDetailId, setDispatchDetailId] = useState<string | number | null>(null);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box className="flex justify-center p-12">
|
||||
<CircularProgress />
|
||||
|
|
@ -23,11 +41,11 @@ export default function WorkOrderViewPage() {
|
|||
);
|
||||
}
|
||||
|
||||
if (view.error || !view.wo) {
|
||||
if (error || !wo) {
|
||||
return (
|
||||
<Box className="p-4">
|
||||
<Alert severity="error">
|
||||
{view.error instanceof Error ? view.error.message : "Work order not found."}
|
||||
{error instanceof Error ? error.message : "Work order not found."}
|
||||
</Alert>
|
||||
</Box>
|
||||
);
|
||||
|
|
@ -35,67 +53,56 @@ export default function WorkOrderViewPage() {
|
|||
|
||||
return (
|
||||
<Box className="flex flex-col gap-4 p-4">
|
||||
<WorkOrderViewHeader
|
||||
wo={view.wo}
|
||||
<WorkOrderHeader
|
||||
wo={wo}
|
||||
onSelectStatus={(status) => statusMutation.mutate(status)}
|
||||
onDispatch={() => setDispatchOpen(true)}
|
||||
onEdit={() => navigate(`/workorders/edit/${id}`)}
|
||||
onBack={() => navigate("/workorders")}
|
||||
/>
|
||||
<WorkOrderDetailsSection
|
||||
wo={wo}
|
||||
users={users}
|
||||
onAssign={(payload) => assignmentMutation.mutate(payload)}
|
||||
/>
|
||||
<WorkOrderSummaryAccordions wo={wo} />
|
||||
<DispatchAccordion
|
||||
wo={wo}
|
||||
onEditVendor={(event, vendorId, vendorName) =>
|
||||
void vendorEditor.open(event, vendorId, vendorName)
|
||||
}
|
||||
onViewDispatch={setDispatchDetailId}
|
||||
/>
|
||||
<WorkOrderActivity
|
||||
workOrderId={workOrderId}
|
||||
statusAnchor={view.statusAnchor}
|
||||
onStatusAnchorChange={view.setStatusAnchor}
|
||||
onStatusChange={(status) => view.statusMutation.mutate(status)}
|
||||
onOpenDispatch={() => view.setDispatchOpen(true)}
|
||||
comments={wo.comments ?? []}
|
||||
auditLog={wo.auditLog ?? []}
|
||||
/>
|
||||
|
||||
<WorkOrderViewDetails
|
||||
wo={view.wo}
|
||||
users={view.users}
|
||||
assignAnchor={view.assignAnchor}
|
||||
onAssignAnchorChange={view.setAssignAnchor}
|
||||
onAssign={(userId, userName) => view.assignmentMutation.mutate({ userId, userName })}
|
||||
onUnassign={() => view.assignmentMutation.mutate({ userId: "", userName: "" })}
|
||||
/>
|
||||
|
||||
<WorkOrderViewAccordions wo={view.wo} />
|
||||
|
||||
<WorkOrderDispatchesAccordion
|
||||
wo={view.wo}
|
||||
onOpenDispatchDetail={view.setDispatchDetailId}
|
||||
onOpenVendorEditor={view.openVendorEditor}
|
||||
/>
|
||||
|
||||
<WorkOrderActivityPanel
|
||||
customerComments={view.customerComments}
|
||||
internalComments={view.internalComments}
|
||||
auditLog={view.auditLog}
|
||||
mentionUsers={view.mentionUsers}
|
||||
isSubmitting={view.commentMutation.isPending}
|
||||
onSubmitComment={view.submitComment}
|
||||
/>
|
||||
|
||||
{view.dispatchOpen && (
|
||||
{dispatchOpen && (
|
||||
<DispatchCreateModal
|
||||
workOrder={view.wo}
|
||||
onClose={() => view.setDispatchOpen(false)}
|
||||
onDispatched={() => void view.refetch()}
|
||||
workOrder={wo}
|
||||
onClose={() => setDispatchOpen(false)}
|
||||
onDispatched={() => void refetch()}
|
||||
/>
|
||||
)}
|
||||
{view.dispatchDetailId != null && (
|
||||
{dispatchDetailId != null && (
|
||||
<DispatchDetailModal
|
||||
dispatchId={view.dispatchDetailId}
|
||||
dispatchId={dispatchDetailId}
|
||||
workOrderId={workOrderId}
|
||||
onClose={() => view.setDispatchDetailId(null)}
|
||||
onUpdated={() => void view.refetch()}
|
||||
onClose={() => setDispatchDetailId(null)}
|
||||
onUpdated={() => void refetch()}
|
||||
/>
|
||||
)}
|
||||
|
||||
<WorkOrderVendorEditorDialog
|
||||
editor={view.vendorEditor}
|
||||
loading={view.vendorEditorLoading}
|
||||
error={view.vendorEditorError}
|
||||
isSaving={view.vendorMutation.isPending}
|
||||
onClose={() => {
|
||||
if (!view.vendorMutation.isPending) view.setVendorEditor(null);
|
||||
}}
|
||||
onSave={view.saveVendorEditor}
|
||||
onEditorChange={view.setVendorEditor}
|
||||
<VendorEditorDialog
|
||||
editor={vendorEditor.editor}
|
||||
loading={vendorEditor.loading}
|
||||
error={vendorEditor.error}
|
||||
isPending={vendorEditor.isPending}
|
||||
phoneInvalid={vendorEditor.phoneInvalid}
|
||||
isValid={vendorEditor.isValid}
|
||||
onClose={vendorEditor.close}
|
||||
onSave={vendorEditor.save}
|
||||
onFieldChange={vendorEditor.updateField}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,178 +1,82 @@
|
|||
import { Box, Button, Chip, Stack, Tooltip, Typography } from "@mui/material";
|
||||
import type { UpliftRequest } from "@/domain/uplifts/types/uplift";
|
||||
import { upliftsApi } from "@/domain/uplifts/api/uplifts-api";
|
||||
import { formatDateTime } from "@/lib/time-utils";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
type UpliftAction = "approve" | "reject" | "request-changes";
|
||||
|
||||
type DispatchUpliftsSectionProps = {
|
||||
uplifts: UpliftRequest[];
|
||||
isLocked: boolean;
|
||||
onApprove: (uplift: UpliftRequest) => void;
|
||||
onReject: (uplift: UpliftRequest) => void;
|
||||
onRequestChanges: (uplift: UpliftRequest) => void;
|
||||
onDeny: (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"> & {
|
||||
uplift: UpliftRequest;
|
||||
};
|
||||
|
||||
type UpliftCardActionsProps = Pick<
|
||||
UpliftCardProps,
|
||||
"isLocked" | "onApprove" | "onReject" | "onRequestChanges"
|
||||
> & {
|
||||
uplift: UpliftRequest;
|
||||
};
|
||||
function UpliftCard({ uplift, isLocked, onApprove, onDeny }: UpliftCardProps) {
|
||||
const canDecide = uplift.canDecide && uplift.status === "Pending" && !isLocked;
|
||||
|
||||
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 (
|
||||
<>
|
||||
<Box className="rounded border border-border p-3">
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: "center", flexWrap: "wrap" }}>
|
||||
<Chip label={uplift.status} size="small" />
|
||||
{uplift.requiredTier === 2 && (
|
||||
<Typography variant="caption">Tier 2 · Manager approval</Typography>
|
||||
)}
|
||||
<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>
|
||||
</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>
|
||||
)}
|
||||
{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) && (
|
||||
<Typography variant="body2" color="text.secondary" className="mt-1">
|
||||
Requested by {uplift.requestedByVendorName || "vendor"}
|
||||
{uplift.requestedAt ? ` · ${formatDateTime(uplift.requestedAt)}` : ""}
|
||||
</Typography>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function UpliftCardDecisionSummary({ uplift }: { uplift: UpliftRequest }) {
|
||||
if (uplift.status === "Pending") return null;
|
||||
if (!uplift.decidedByName && !uplift.decidedAt && !uplift.decisionNote) return null;
|
||||
return (
|
||||
<Box className="mt-1">
|
||||
{Boolean(uplift.decidedByName || uplift.decidedAt) && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{uplift.status} by {uplift.decidedByName || "—"}
|
||||
{uplift.decidedAt ? ` · ${formatDateTime(uplift.decidedAt)}` : ""}
|
||||
</Typography>
|
||||
)}
|
||||
{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))}
|
||||
{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">
|
||||
{uplift.status} by {uplift.decidedByName || "—"}
|
||||
{uplift.decidedAt ? ` · ${formatDateTime(uplift.decidedAt)}` : ""}
|
||||
</Typography>
|
||||
)}
|
||||
{Boolean(uplift.decisionNote) && (
|
||||
<Typography variant="body2" className="mt-0.5">
|
||||
<strong>Note:</strong> {uplift.decisionNote}
|
||||
</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` : ""}
|
||||
>
|
||||
{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}
|
||||
/>
|
||||
<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>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -181,8 +85,7 @@ export function DispatchUpliftsSection({
|
|||
uplifts,
|
||||
isLocked,
|
||||
onApprove,
|
||||
onReject,
|
||||
onRequestChanges,
|
||||
onDeny,
|
||||
}: DispatchUpliftsSectionProps) {
|
||||
return (
|
||||
<Box>
|
||||
|
|
@ -199,8 +102,7 @@ export function DispatchUpliftsSection({
|
|||
uplift={uplift}
|
||||
isLocked={isLocked}
|
||||
onApprove={onApprove}
|
||||
onReject={onReject}
|
||||
onRequestChanges={onRequestChanges}
|
||||
onDeny={onDeny}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
|
|
|
|||
|
|
@ -99,10 +99,7 @@ export function DispatchDetailModal({
|
|||
uplifts={controller.uplifts}
|
||||
isLocked={controller.isLocked}
|
||||
onApprove={(uplift) => controller.setUpliftAction({ type: "approve", uplift })}
|
||||
onReject={(uplift) => controller.setUpliftAction({ type: "reject", uplift })}
|
||||
onRequestChanges={(uplift) =>
|
||||
controller.setUpliftAction({ type: "request-changes", uplift })
|
||||
}
|
||||
onDeny={(uplift) => controller.setUpliftAction({ type: "deny", uplift })}
|
||||
/>
|
||||
|
||||
<DispatchVendorCommunication
|
||||
|
|
@ -154,9 +151,7 @@ export function DispatchDetailModal({
|
|||
onClose={() => controller.setUpliftAction(null)}
|
||||
onConfirm={controller.handleUpliftConfirm}
|
||||
isPending={
|
||||
controller.approveUpliftMutation.isPending ||
|
||||
controller.rejectUpliftMutation.isPending ||
|
||||
controller.requestChangesUpliftMutation.isPending
|
||||
controller.approveUpliftMutation.isPending || controller.denyUpliftMutation.isPending
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
|
||||
type UpliftDecisionDialogProps = {
|
||||
open: boolean;
|
||||
type: "approve" | "reject" | "request-changes";
|
||||
type: "approve" | "deny";
|
||||
requestedNTE: number;
|
||||
currentNTE: number | null;
|
||||
onClose: () => void;
|
||||
|
|
@ -19,13 +19,6 @@ type UpliftDecisionDialogProps = {
|
|||
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({
|
||||
open,
|
||||
type,
|
||||
|
|
@ -36,7 +29,6 @@ export function UpliftDecisionDialog({
|
|||
isPending = false,
|
||||
}: UpliftDecisionDialogProps) {
|
||||
const [note, setNote] = useState("");
|
||||
const noteRequired = type === "reject" || type === "request-changes";
|
||||
|
||||
const handleClose = () => {
|
||||
setNote("");
|
||||
|
|
@ -44,14 +36,15 @@ export function UpliftDecisionDialog({
|
|||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (noteRequired && !note.trim()) return;
|
||||
onConfirm(note.trim());
|
||||
setNote("");
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose}>
|
||||
<DialogTitle>{titleFor(type, requestedNTE)}</DialogTitle>
|
||||
<DialogTitle>
|
||||
{type === "approve" ? `Approve uplift to $${requestedNTE.toFixed(2)}?` : "Deny Uplift"}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
{type === "approve" && (
|
||||
<Typography variant="body2" className="mt-1 mb-3">
|
||||
|
|
@ -64,8 +57,7 @@ export function UpliftDecisionDialog({
|
|||
multiline
|
||||
minRows={2}
|
||||
className="mt-2"
|
||||
label={noteRequired ? "Note (required)" : "Optional note"}
|
||||
required={noteRequired}
|
||||
label={type === "deny" ? "Denial reason (required)" : "Optional note"}
|
||||
value={note}
|
||||
onChange={(event) => setNote(event.target.value)}
|
||||
/>
|
||||
|
|
@ -74,8 +66,8 @@ export function UpliftDecisionDialog({
|
|||
<Button onClick={handleClose}>Cancel</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color={type === "approve" ? "success" : "primary"}
|
||||
disabled={noteRequired ? !note.trim() || isPending : isPending}
|
||||
color={type === "approve" ? "success" : "error"}
|
||||
disabled={type === "deny" ? !note.trim() || isPending : isPending}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
Confirm
|
||||
|
|
|
|||
|
|
@ -1,107 +0,0 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { Box, Button, Paper, Tab, Tabs } from "@mui/material";
|
||||
import { CommentMentionComposer } from "@/components/common/comment-mention-composer";
|
||||
import { CommentTimeline } from "@/components/common/comment-timeline";
|
||||
import { mapWorkOrderCommentsToTimeline } from "@/components/common/comment-timeline-mappers";
|
||||
import type {
|
||||
WorkOrderAuditEntry,
|
||||
WorkOrderComment,
|
||||
} from "@/domain/work-orders/types/work-order-detail";
|
||||
import { WorkOrderAuditTimeline } from "@/app/(protected)/workorders/_components/view/work-order-audit-timeline";
|
||||
import { Text } from "@/components/ui/text";
|
||||
|
||||
export type WorkOrderActivityTab = "customer" | "internal" | "audit";
|
||||
|
||||
type MentionUser = { id: string | number; name: string };
|
||||
|
||||
type WorkOrderActivityPanelProps = {
|
||||
customerComments: WorkOrderComment[];
|
||||
internalComments: WorkOrderComment[];
|
||||
auditLog: WorkOrderAuditEntry[];
|
||||
mentionUsers: MentionUser[];
|
||||
isSubmitting: boolean;
|
||||
onSubmitComment: (text: string) => void | Promise<void>;
|
||||
};
|
||||
|
||||
export function WorkOrderActivityPanel({
|
||||
customerComments,
|
||||
internalComments,
|
||||
auditLog,
|
||||
mentionUsers,
|
||||
isSubmitting,
|
||||
onSubmitComment,
|
||||
}: WorkOrderActivityPanelProps) {
|
||||
const [activeTab, setActiveTab] = useState<WorkOrderActivityTab>("customer");
|
||||
const [commentText, setCommentText] = useState("");
|
||||
|
||||
const customerTimelineItems = useMemo(
|
||||
() => mapWorkOrderCommentsToTimeline(customerComments),
|
||||
[customerComments],
|
||||
);
|
||||
const internalTimelineItems = useMemo(
|
||||
() => mapWorkOrderCommentsToTimeline(internalComments),
|
||||
[internalComments],
|
||||
);
|
||||
|
||||
return (
|
||||
<Paper variant="outlined" className="p-4">
|
||||
<Text as="p" variant="heading" className="mb-3 font-semibold">
|
||||
Activity
|
||||
</Text>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={(_event, value: WorkOrderActivityTab) => setActiveTab(value)}
|
||||
className="mb-3"
|
||||
>
|
||||
<Tab value="customer" label={`Customer (${customerComments.length})`} />
|
||||
<Tab value="internal" label={`Internal (${internalComments.length})`} />
|
||||
<Tab value="audit" label={`Audit Log (${auditLog.length})`} />
|
||||
</Tabs>
|
||||
|
||||
{activeTab === "customer" && (
|
||||
<CommentTimeline items={customerTimelineItems} mentionUsers={mentionUsers} />
|
||||
)}
|
||||
{activeTab === "internal" && (
|
||||
<CommentTimeline items={internalTimelineItems} mentionUsers={mentionUsers} />
|
||||
)}
|
||||
{activeTab === "audit" && <WorkOrderAuditTimeline entries={auditLog} />}
|
||||
|
||||
{activeTab === "internal" && (
|
||||
<Box
|
||||
component="form"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
const trimmed = commentText.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
void (async () => {
|
||||
try {
|
||||
await onSubmitComment(trimmed);
|
||||
setCommentText("");
|
||||
} catch {
|
||||
// Mutation toast handles failure; keep the draft for retry.
|
||||
}
|
||||
})();
|
||||
}}
|
||||
className="mt-4 flex flex-col gap-2"
|
||||
>
|
||||
<CommentMentionComposer
|
||||
value={commentText}
|
||||
onChange={setCommentText}
|
||||
candidates={mentionUsers}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
className="self-end"
|
||||
disabled={isSubmitting || !commentText.trim()}
|
||||
>
|
||||
{isSubmitting ? "Sending..." : "Send"}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
import { useMemo } from "react";
|
||||
import { Box, Stack, Typography } from "@mui/material";
|
||||
import type { WorkOrderAuditEntry } from "@/domain/work-orders/types/work-order";
|
||||
import { formatDateTime } from "@/lib/time-utils";
|
||||
|
||||
export function WorkOrderAuditTimeline({ entries }: { entries: WorkOrderAuditEntry[] }) {
|
||||
const sorted = useMemo(
|
||||
() =>
|
||||
[...entries].sort(
|
||||
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
|
||||
),
|
||||
[entries],
|
||||
);
|
||||
|
||||
if (!sorted.length) {
|
||||
return <Typography sx={{ color: "text.secondary" }}>No changes recorded.</Typography>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack spacing={2} className="max-h-72 overflow-y-auto">
|
||||
{sorted.map((entry) => (
|
||||
<Box key={String(entry.id)} className="rounded border border-border p-3">
|
||||
<Stack direction="row" className="mb-1" sx={{ justifyContent: "space-between" }}>
|
||||
<Typography variant="caption" className="font-semibold">
|
||||
{entry.userName || "System"}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
{entry.createdAt ? formatDateTime(entry.createdAt) : ""}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography variant="body2">
|
||||
<strong>{entry.fieldName}</strong> {entry.oldValue} → {entry.newValue}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
import { Box, Typography } from "@mui/material";
|
||||
|
||||
export function WorkOrderDetailField({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", display: "block" }}>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="body2">{value}</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
import {
|
||||
Accordion,
|
||||
AccordionDetails,
|
||||
AccordionSummary,
|
||||
Button,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail";
|
||||
import { formatDateTime, timeSince, waitTimeColor } from "@/lib/time-utils";
|
||||
|
||||
type WorkOrderDispatchesAccordionProps = {
|
||||
wo: WorkOrderDetail;
|
||||
onOpenDispatchDetail: (dispatchId: string | number) => void;
|
||||
onOpenVendorEditor: (
|
||||
event: React.MouseEvent,
|
||||
vendorId: string | number,
|
||||
vendorName: string,
|
||||
) => void;
|
||||
};
|
||||
|
||||
export function WorkOrderDispatchesAccordion({
|
||||
wo,
|
||||
onOpenDispatchDetail,
|
||||
onOpenVendorEditor,
|
||||
}: WorkOrderDispatchesAccordionProps) {
|
||||
return (
|
||||
<Accordion defaultExpanded={Boolean(wo.dispatches.length)}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography sx={{ fontWeight: 600 }}>Dispatches ({wo.dispatches.length})</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
{wo.dispatches.length ? (
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Dispatch #</TableCell>
|
||||
<TableCell>Vendor</TableCell>
|
||||
<TableCell>NTE</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell>Date</TableCell>
|
||||
<TableCell>Wait</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{wo.dispatches.map((dispatch) => {
|
||||
const waitColor = waitTimeColor(dispatch.dispatchedAt);
|
||||
return (
|
||||
<TableRow
|
||||
key={String(dispatch.id)}
|
||||
hover
|
||||
className="cursor-pointer"
|
||||
onClick={() => onOpenDispatchDetail(dispatch.id)}
|
||||
>
|
||||
<TableCell>{dispatch.dispatchNumber || dispatch.poNumber}</TableCell>
|
||||
<TableCell>{dispatch.vendorName}</TableCell>
|
||||
<TableCell>
|
||||
{dispatch.nteAmount != null ? `$${dispatch.nteAmount.toFixed(2)}` : "—"}
|
||||
</TableCell>
|
||||
<TableCell>{dispatch.status}</TableCell>
|
||||
<TableCell>
|
||||
{dispatch.dispatchedAt ? formatDateTime(dispatch.dispatchedAt) : "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{dispatch.status === "Sent" ? (
|
||||
<Typography
|
||||
variant="caption"
|
||||
className={
|
||||
waitColor === "green"
|
||||
? "text-green-600"
|
||||
: waitColor === "yellow"
|
||||
? "text-yellow-600"
|
||||
: "text-red-600"
|
||||
}
|
||||
>
|
||||
{timeSince(dispatch.dispatchedAt)}
|
||||
</Typography>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Button
|
||||
size="small"
|
||||
onClick={(event) =>
|
||||
void onOpenVendorEditor(event, dispatch.vendorId, dispatch.vendorName)
|
||||
}
|
||||
disabled={dispatch.vendorId === ""}
|
||||
>
|
||||
Edit vendor
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
) : (
|
||||
<Typography sx={{ color: "text.secondary" }}>No dispatches yet.</Typography>
|
||||
)}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,114 +0,0 @@
|
|||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Stack,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import type { VendorEditorState } from "./work-order-vendor-editor-types";
|
||||
import { formatPhoneMask, isVendorEditorValid } from "./work-order-vendor-editor-utils";
|
||||
|
||||
type WorkOrderVendorEditorDialogProps = {
|
||||
editor: VendorEditorState | null;
|
||||
loading: boolean;
|
||||
error: string;
|
||||
isSaving: boolean;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
onEditorChange: (
|
||||
updater: (current: VendorEditorState | null) => VendorEditorState | null,
|
||||
) => void;
|
||||
};
|
||||
|
||||
export function WorkOrderVendorEditorDialog({
|
||||
editor,
|
||||
loading,
|
||||
error,
|
||||
isSaving,
|
||||
onClose,
|
||||
onSave,
|
||||
onEditorChange,
|
||||
}: WorkOrderVendorEditorDialogProps) {
|
||||
return (
|
||||
<Dialog open={editor !== null} onClose={onClose} fullWidth maxWidth="sm">
|
||||
<DialogTitle>Edit assigned vendor</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} className="pt-2">
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{editor?.vendorName}
|
||||
</Typography>
|
||||
{Boolean(error) && <Alert severity="error">{error}</Alert>}
|
||||
{loading ? (
|
||||
<Box className="flex justify-center p-6">
|
||||
<CircularProgress size={28} />
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<TextField
|
||||
label="Technician name"
|
||||
value={editor?.contactName ?? ""}
|
||||
onChange={(event) =>
|
||||
onEditorChange((current) =>
|
||||
current ? { ...current, contactName: event.target.value } : current,
|
||||
)
|
||||
}
|
||||
slotProps={{ htmlInput: { maxLength: 200 } }}
|
||||
/>
|
||||
<TextField
|
||||
label="Phone"
|
||||
value={editor?.phone ?? ""}
|
||||
onChange={(event) =>
|
||||
onEditorChange((current) =>
|
||||
current ? { ...current, phone: formatPhoneMask(event.target.value) } : current,
|
||||
)
|
||||
}
|
||||
error={Boolean(editor?.phone && !/^\(\d{3}\) \d{3}-\d{4}$/.test(editor.phone))}
|
||||
helperText="Use (XXX) XXX-XXXX"
|
||||
/>
|
||||
<TextField
|
||||
label="Email"
|
||||
type="email"
|
||||
value={editor?.email ?? ""}
|
||||
onChange={(event) =>
|
||||
onEditorChange((current) =>
|
||||
current ? { ...current, email: event.target.value } : current,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
label="Notes"
|
||||
multiline
|
||||
minRows={3}
|
||||
value={editor?.notes ?? ""}
|
||||
onChange={(event) =>
|
||||
onEditorChange((current) =>
|
||||
current ? { ...current, notes: event.target.value } : current,
|
||||
)
|
||||
}
|
||||
slotProps={{ htmlInput: { maxLength: 2000 } }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} disabled={isSaving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={onSave}
|
||||
disabled={!editor || loading || isSaving || !isVendorEditorValid(editor)}
|
||||
>
|
||||
{isSaving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
export interface VendorEditorState {
|
||||
vendorId: string | number;
|
||||
vendorName: string;
|
||||
contactName: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
notes: string;
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
import type { VendorEditorState } from "./work-order-vendor-editor-types";
|
||||
|
||||
export function formatPhoneMask(input: string): string {
|
||||
const digits = input.replace(/\D/g, "").slice(0, 10);
|
||||
if (digits.length <= 3) return digits;
|
||||
if (digits.length <= 6) return `(${digits.slice(0, 3)}) ${digits.slice(3)}`;
|
||||
return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
|
||||
}
|
||||
|
||||
export function isVendorEditorValid(editor: VendorEditorState): boolean {
|
||||
const phoneValid = editor.phone === "" || /^\(\d{3}\) \d{3}-\d{4}$/.test(editor.phone);
|
||||
const emailValid = editor.email === "" || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(editor.email);
|
||||
return (
|
||||
phoneValid && emailValid && editor.notes.length <= 2000 && editor.contactName.length <= 200
|
||||
);
|
||||
}
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
import { Accordion, AccordionDetails, AccordionSummary, Box, Typography } from "@mui/material";
|
||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail";
|
||||
import { WorkOrderDetailField } from "@/app/(protected)/workorders/_components/view/work-order-detail-field";
|
||||
import { formatDateTime } from "@/lib/time-utils";
|
||||
|
||||
export function WorkOrderViewAccordions({ wo }: { wo: WorkOrderDetail }) {
|
||||
return (
|
||||
<>
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography sx={{ fontWeight: 600 }}>Classification</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<WorkOrderDetailField label="Problem" value={wo.problem || "—"} />
|
||||
<WorkOrderDetailField label="Trade" value={wo.trade || "—"} />
|
||||
<WorkOrderDetailField label="Sub-Trade" value={wo.subTrade || "—"} />
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography sx={{ fontWeight: 600 }}>Schedule</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<WorkOrderDetailField
|
||||
label="Created"
|
||||
value={wo.createdDateFull ? formatDateTime(wo.createdDateFull) : "—"}
|
||||
/>
|
||||
<WorkOrderDetailField label="Due Date" value={wo.dueDate || "—"} />
|
||||
<WorkOrderDetailField label="Scheduled" value={wo.scheduledDate || "—"} />
|
||||
<WorkOrderDetailField label="Completed" value={wo.completedDate || "—"} />
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography sx={{ fontWeight: 600 }}>Financial</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<WorkOrderDetailField
|
||||
label="Vendor NTE"
|
||||
value={wo.vendorNTE ? `$${parseFloat(wo.vendorNTE).toFixed(2)}` : "—"}
|
||||
/>
|
||||
<WorkOrderDetailField label="Customer PO" value={wo.po || "—"} />
|
||||
<WorkOrderDetailField label="Customer NTE" value="Not connected" />
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography sx={{ fontWeight: 600 }}>Description</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Typography>{wo.description || "No description provided."}</Typography>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
import { Box, Button, Menu, MenuItem, Paper, Typography } from "@mui/material";
|
||||
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
||||
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail";
|
||||
import { WorkOrderDetailField } from "@/app/(protected)/workorders/_components/view/work-order-detail-field";
|
||||
import { Text } from "@/components/ui/text";
|
||||
|
||||
type WorkOrderViewDetailsProps = {
|
||||
wo: WorkOrderDetail;
|
||||
users: WorkOrderUser[];
|
||||
assignAnchor: HTMLElement | null;
|
||||
onAssignAnchorChange: (anchor: HTMLElement | null) => void;
|
||||
onAssign: (userId: string | number, userName: string) => void;
|
||||
onUnassign: () => void;
|
||||
};
|
||||
|
||||
export function WorkOrderViewDetails({
|
||||
wo,
|
||||
users,
|
||||
assignAnchor,
|
||||
onAssignAnchorChange,
|
||||
onAssign,
|
||||
onUnassign,
|
||||
}: WorkOrderViewDetailsProps) {
|
||||
return (
|
||||
<Paper variant="outlined" className="p-4">
|
||||
<Text as="p" variant="heading" className="mb-3 font-semibold">
|
||||
Details
|
||||
</Text>
|
||||
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<WorkOrderDetailField label="Internal WO" value={wo.internalWONumber} />
|
||||
<WorkOrderDetailField label="Customer WO" value={wo.workOrderNumber || "—"} />
|
||||
<WorkOrderDetailField label="Priority" value={wo.priority || "—"} />
|
||||
<WorkOrderDetailField label="Location" value={wo.location || "—"} />
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", display: "block" }}>
|
||||
Assigned To
|
||||
</Typography>
|
||||
<Button size="small" onClick={(event) => onAssignAnchorChange(event.currentTarget)}>
|
||||
{wo.assignedTo || "Unassigned"}
|
||||
</Button>
|
||||
<Menu
|
||||
anchorEl={assignAnchor}
|
||||
open={Boolean(assignAnchor)}
|
||||
onClose={() => onAssignAnchorChange(null)}
|
||||
>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
onAssignAnchorChange(null);
|
||||
onUnassign();
|
||||
}}
|
||||
>
|
||||
Unassigned
|
||||
</MenuItem>
|
||||
{users.map((user) => (
|
||||
<MenuItem
|
||||
key={String(user.id)}
|
||||
selected={wo.assignedTo === user.name}
|
||||
onClick={() => {
|
||||
onAssignAnchorChange(null);
|
||||
onAssign(user.id, user.name);
|
||||
}}
|
||||
>
|
||||
{user.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</Box>
|
||||
<WorkOrderDetailField label="Source" value={wo.source || "—"} />
|
||||
<WorkOrderDetailField label="TT" value={wo.tt || "—"} />
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
import { Link as RouterLink, useNavigate } from "react-router";
|
||||
import { Breadcrumbs, Button, Chip, Link, Menu, MenuItem, Stack, Typography } from "@mui/material";
|
||||
import { WORK_ORDER_STATUS_OPTIONS } from "@/domain/work-orders/types/work-order";
|
||||
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail";
|
||||
|
||||
const STATUS_COLORS: Record<
|
||||
string,
|
||||
"default" | "primary" | "secondary" | "error" | "info" | "success" | "warning"
|
||||
> = {
|
||||
Open: "primary",
|
||||
"In Progress": "info",
|
||||
"On Hold": "warning",
|
||||
Done: "success",
|
||||
Cancelled: "default",
|
||||
};
|
||||
|
||||
type WorkOrderViewHeaderProps = {
|
||||
wo: WorkOrderDetail;
|
||||
workOrderId: string;
|
||||
statusAnchor: HTMLElement | null;
|
||||
onStatusAnchorChange: (anchor: HTMLElement | null) => void;
|
||||
onStatusChange: (status: string) => void;
|
||||
onOpenDispatch: () => void;
|
||||
};
|
||||
|
||||
export function WorkOrderViewHeader({
|
||||
wo,
|
||||
workOrderId,
|
||||
statusAnchor,
|
||||
onStatusAnchorChange,
|
||||
onStatusChange,
|
||||
onOpenDispatch,
|
||||
}: WorkOrderViewHeaderProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Breadcrumbs>
|
||||
<Link component={RouterLink} to="/workorders" underline="hover">
|
||||
Work Orders
|
||||
</Link>
|
||||
<Typography sx={{ color: "text.primary" }}>{wo.internalWONumber}</Typography>
|
||||
</Breadcrumbs>
|
||||
<Stack
|
||||
direction={{ xs: "column", md: "row" }}
|
||||
spacing={2}
|
||||
sx={{ justifyContent: "space-between", alignItems: { xs: "stretch", md: "flex-start" } }}
|
||||
>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="h5" component="h1">
|
||||
{wo.title}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={wo.status}
|
||||
color={STATUS_COLORS[wo.status] ?? "default"}
|
||||
onClick={(event) => onStatusAnchorChange(event.currentTarget)}
|
||||
className="w-fit cursor-pointer"
|
||||
/>
|
||||
<Menu
|
||||
anchorEl={statusAnchor}
|
||||
open={Boolean(statusAnchor)}
|
||||
onClose={() => onStatusAnchorChange(null)}
|
||||
>
|
||||
{WORK_ORDER_STATUS_OPTIONS.map((status) => (
|
||||
<MenuItem
|
||||
key={status}
|
||||
selected={status === wo.status}
|
||||
onClick={() => {
|
||||
onStatusAnchorChange(null);
|
||||
if (status !== wo.status) {
|
||||
onStatusChange(status);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{status}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" spacing={1} sx={{ flexWrap: "wrap" }}>
|
||||
<Button variant="contained" color="success" onClick={onOpenDispatch}>
|
||||
Dispatch
|
||||
</Button>
|
||||
<Button variant="contained" onClick={() => navigate(`/workorders/edit/${workOrderId}`)}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button variant="outlined" onClick={() => navigate("/workorders")}>
|
||||
Back to List
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,8 +1,7 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
useApproveUplift,
|
||||
useRejectUplift,
|
||||
useRequestChangesUplift,
|
||||
useDenyUplift,
|
||||
useUpliftsForDispatch,
|
||||
} from "@/domain/uplifts/use-cases/use-uplift-actions";
|
||||
import type { UpliftRequest } from "@/domain/uplifts/types/uplift";
|
||||
|
|
@ -69,7 +68,7 @@ export function useDispatchDetailController(
|
|||
const [sendEmail, setSendEmail] = useState(true);
|
||||
const [sigCaptureType, setSigCaptureType] = useState<string | null>(null);
|
||||
const [upliftAction, setUpliftAction] = useState<{
|
||||
type: "approve" | "reject" | "request-changes";
|
||||
type: "approve" | "deny";
|
||||
uplift: UpliftRequest;
|
||||
} | null>(null);
|
||||
|
||||
|
|
@ -80,8 +79,7 @@ export function useDispatchDetailController(
|
|||
const addSignoffMutation = useAddDispatchSignoff(dispatchId);
|
||||
const verifyMutation = useVerifyDispatch(dispatchId, workOrderId);
|
||||
const approveUpliftMutation = useApproveUplift(dispatchId);
|
||||
const rejectUpliftMutation = useRejectUplift(dispatchId);
|
||||
const requestChangesUpliftMutation = useRequestChangesUplift(dispatchId);
|
||||
const denyUpliftMutation = useDenyUplift(dispatchId);
|
||||
|
||||
const userName =
|
||||
(typeof user?.name === "string" ? user.name : undefined) ??
|
||||
|
|
@ -145,19 +143,18 @@ export function useDispatchDetailController(
|
|||
return;
|
||||
}
|
||||
|
||||
const id = upliftAction.uplift.id;
|
||||
if (upliftAction.type === "approve") {
|
||||
approveUpliftMutation.mutate({ id, note }, { onSuccess: () => setUpliftAction(null) });
|
||||
approveUpliftMutation.mutate(
|
||||
{ id: upliftAction.uplift.id, note },
|
||||
{ onSuccess: () => setUpliftAction(null) },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!note.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mutation =
|
||||
upliftAction.type === "reject" ? rejectUpliftMutation : requestChangesUpliftMutation;
|
||||
mutation.mutate({ id, note }, { onSuccess: () => setUpliftAction(null) });
|
||||
denyUpliftMutation.mutate(
|
||||
{ id: upliftAction.uplift.id, note },
|
||||
{ onSuccess: () => setUpliftAction(null) },
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
|
|
@ -192,8 +189,7 @@ export function useDispatchDetailController(
|
|||
addSignoffMutation,
|
||||
verifyMutation,
|
||||
approveUpliftMutation,
|
||||
rejectUpliftMutation,
|
||||
requestChangesUpliftMutation,
|
||||
denyUpliftMutation,
|
||||
addCommentMutation,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,149 +0,0 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import type { VendorEditorState } from "@/app/(protected)/workorders/_components/view/work-order-vendor-editor-types";
|
||||
import { isVendorEditorValid } from "@/app/(protected)/workorders/_components/view/work-order-vendor-editor-utils";
|
||||
import { useWorkOrderDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||
import {
|
||||
useAddWorkOrderComment,
|
||||
useChangeWorkOrderAssignment,
|
||||
useChangeWorkOrderStatus,
|
||||
useUpdateWorkOrderVendor,
|
||||
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
||||
import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
|
||||
import { vendorsApi } from "@/domain/vendors/api/vendors-api";
|
||||
import { extractMentionIds } from "@/domain/work-orders/utils/comment-mentions";
|
||||
|
||||
export function useWorkOrderViewPage(workOrderId: string) {
|
||||
const { data: wo, isLoading, error, refetch } = useWorkOrderDetail(workOrderId);
|
||||
const { data: users = [] } = useWorkOrderUsers();
|
||||
const statusMutation = useChangeWorkOrderStatus(workOrderId);
|
||||
const assignmentMutation = useChangeWorkOrderAssignment(workOrderId);
|
||||
const commentMutation = useAddWorkOrderComment(workOrderId);
|
||||
const vendorMutation = useUpdateWorkOrderVendor(workOrderId);
|
||||
|
||||
const [dispatchOpen, setDispatchOpen] = useState(false);
|
||||
const [dispatchDetailId, setDispatchDetailId] = useState<string | number | null>(null);
|
||||
const [statusAnchor, setStatusAnchor] = useState<HTMLElement | null>(null);
|
||||
const [assignAnchor, setAssignAnchor] = useState<HTMLElement | null>(null);
|
||||
const [vendorEditor, setVendorEditor] = useState<VendorEditorState | null>(null);
|
||||
const [vendorEditorLoading, setVendorEditorLoading] = useState(false);
|
||||
const [vendorEditorError, setVendorEditorError] = useState("");
|
||||
|
||||
const customerComments = useMemo(
|
||||
() => (wo?.comments ?? []).filter((comment) => comment.commentType === "customer"),
|
||||
[wo?.comments],
|
||||
);
|
||||
const internalComments = useMemo(
|
||||
() => (wo?.comments ?? []).filter((comment) => comment.commentType === "internal"),
|
||||
[wo?.comments],
|
||||
);
|
||||
const auditLog = wo?.auditLog ?? [];
|
||||
const mentionUsers = useMemo(
|
||||
() => users.map((user) => ({ id: user.id, name: user.name })),
|
||||
[users],
|
||||
);
|
||||
|
||||
const submitComment = async (text: string) => {
|
||||
const mentions = extractMentionIds(text);
|
||||
await commentMutation.mutateAsync({
|
||||
text,
|
||||
mentions: mentions.length > 0 ? mentions : undefined,
|
||||
commentType: "internal",
|
||||
});
|
||||
};
|
||||
|
||||
const openVendorEditor = async (
|
||||
event: React.MouseEvent,
|
||||
vendorId: string | number,
|
||||
vendorName: string,
|
||||
) => {
|
||||
event.stopPropagation();
|
||||
setVendorEditorError("");
|
||||
setVendorEditorLoading(true);
|
||||
setVendorEditor({
|
||||
vendorId,
|
||||
vendorName,
|
||||
contactName: "",
|
||||
phone: "",
|
||||
email: "",
|
||||
notes: "",
|
||||
});
|
||||
|
||||
try {
|
||||
const vendor = await vendorsApi.getById(vendorId);
|
||||
setVendorEditor({
|
||||
vendorId,
|
||||
vendorName: vendor.companyName || vendorName,
|
||||
contactName: vendor.contactName,
|
||||
phone: vendor.phone,
|
||||
email: vendor.email,
|
||||
notes: vendor.notes,
|
||||
});
|
||||
} catch (vendorError) {
|
||||
setVendorEditorError(
|
||||
vendorError instanceof Error ? vendorError.message : "Unable to load vendor details",
|
||||
);
|
||||
} finally {
|
||||
setVendorEditorLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveVendorEditor = () => {
|
||||
if (!vendorEditor || !isVendorEditorValid(vendorEditor)) return;
|
||||
const numericWorkOrderId = Number(workOrderId);
|
||||
if (!Number.isInteger(numericWorkOrderId) || numericWorkOrderId <= 0) {
|
||||
setVendorEditorError("A valid work order is required");
|
||||
return;
|
||||
}
|
||||
|
||||
vendorMutation.mutate(
|
||||
{
|
||||
vendorId: vendorEditor.vendorId,
|
||||
payload: {
|
||||
workOrderId: numericWorkOrderId,
|
||||
contactName: vendorEditor.contactName.trim(),
|
||||
phone: vendorEditor.phone,
|
||||
email: vendorEditor.email.trim(),
|
||||
notes: vendorEditor.notes.trim(),
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setVendorEditor(null);
|
||||
void refetch();
|
||||
},
|
||||
onError: (vendorError) => setVendorEditorError(vendorError.message),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
wo,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
users,
|
||||
customerComments,
|
||||
internalComments,
|
||||
auditLog,
|
||||
mentionUsers,
|
||||
submitComment,
|
||||
dispatchOpen,
|
||||
setDispatchOpen,
|
||||
dispatchDetailId,
|
||||
setDispatchDetailId,
|
||||
statusAnchor,
|
||||
setStatusAnchor,
|
||||
assignAnchor,
|
||||
setAssignAnchor,
|
||||
vendorEditor,
|
||||
setVendorEditor,
|
||||
vendorEditorLoading,
|
||||
vendorEditorError,
|
||||
openVendorEditor,
|
||||
saveVendorEditor,
|
||||
statusMutation,
|
||||
assignmentMutation,
|
||||
commentMutation,
|
||||
vendorMutation,
|
||||
};
|
||||
}
|
||||
|
|
@ -31,11 +31,6 @@ export default function VendorPortalDispatchPage() {
|
|||
await queryClient.invalidateQueries({ queryKey: queryKeys.vendorPortal.dispatches(token) });
|
||||
}, [refetch, queryClient, token]);
|
||||
|
||||
const pollEvidenceDocument = useCallback(
|
||||
(documentId: number) => vendorPortalApi.getDocumentStatus(token, dispatchId, documentId),
|
||||
[token, dispatchId],
|
||||
);
|
||||
|
||||
const runAction = async (action: () => Promise<void>, confirmMsg?: string) => {
|
||||
if (confirmMsg && !window.confirm(confirmMsg)) return;
|
||||
setActionError("");
|
||||
|
|
@ -114,7 +109,6 @@ export default function VendorPortalDispatchPage() {
|
|||
data={data}
|
||||
locked={locked}
|
||||
refresh={refresh}
|
||||
pollEvidenceDocument={pollEvidenceDocument}
|
||||
updateLocal={updateLocal}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -4,10 +4,7 @@ import { CommentsSection } from "@/app/v/[token]/dispatch/_components/comments-s
|
|||
import { SignoffSection } from "@/app/v/[token]/dispatch/_components/signoff-section";
|
||||
import { UpliftRequestsSection } from "@/app/v/[token]/dispatch/_components/uplift-requests-section";
|
||||
import { VendorPortalDocuments } from "@/app/v/_components/vendor-portal-documents";
|
||||
import type {
|
||||
VendorPortalDispatchDetail,
|
||||
VendorPortalDocumentStatus,
|
||||
} from "@/domain/vendor-portal/types/vendor-portal";
|
||||
import type { VendorPortalDispatchDetail } from "@/domain/vendor-portal/types/vendor-portal";
|
||||
|
||||
type DispatchDetailSectionsProps = {
|
||||
token: string;
|
||||
|
|
@ -15,7 +12,6 @@ type DispatchDetailSectionsProps = {
|
|||
data: VendorPortalDispatchDetail;
|
||||
locked: boolean;
|
||||
refresh: () => Promise<void>;
|
||||
pollEvidenceDocument: (documentId: number) => Promise<VendorPortalDocumentStatus | undefined>;
|
||||
updateLocal: (
|
||||
updater: (current: VendorPortalDispatchDetail) => VendorPortalDispatchDetail,
|
||||
) => void;
|
||||
|
|
@ -27,7 +23,6 @@ export function DispatchDetailSections({
|
|||
data,
|
||||
locked,
|
||||
refresh,
|
||||
pollEvidenceDocument,
|
||||
updateLocal,
|
||||
}: DispatchDetailSectionsProps) {
|
||||
return (
|
||||
|
|
@ -70,7 +65,6 @@ export function DispatchDetailSections({
|
|||
upliftRequests={data.upliftRequests ?? []}
|
||||
locked={locked}
|
||||
onChanged={refresh}
|
||||
pollDocument={pollEvidenceDocument}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,136 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,148 +0,0 @@
|
|||
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,20 +1,7 @@
|
|||
import { useCallback, useState } from "react";
|
||||
import { VendorPortalFeedback } from "@/app/v/_components/vendor-portal-feedback";
|
||||
import { useState } from "react";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
|
||||
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"]);
|
||||
import type { VendorPortalUpliftRequest } from "@/domain/vendor-portal/types/vendor-portal";
|
||||
|
||||
type UpliftRequestsSectionProps = {
|
||||
token: string;
|
||||
|
|
@ -23,141 +10,20 @@ type UpliftRequestsSectionProps = {
|
|||
upliftRequests: VendorPortalUpliftRequest[];
|
||||
locked: boolean;
|
||||
onChanged: () => Promise<void>;
|
||||
pollDocument: (documentId: number) => Promise<VendorPortalDocumentStatus | undefined>;
|
||||
};
|
||||
|
||||
type UseVendorUpliftActionsArgs = {
|
||||
token: string;
|
||||
dispatchId: number;
|
||||
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.";
|
||||
function validateUpliftRequest(requested: string, currentNte: number | undefined, reason: string) {
|
||||
const amount = Number.parseFloat(requested);
|
||||
if (!Number.isFinite(amount) || amount <= 0) {
|
||||
return "Enter a valid requested NTE amount.";
|
||||
}
|
||||
return "Security scan timed out. Please try again once the file finishes scanning.";
|
||||
}
|
||||
|
||||
function useVendorUpliftActions({
|
||||
token,
|
||||
dispatchId,
|
||||
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,
|
||||
};
|
||||
if (currentNte != null && amount <= currentNte) {
|
||||
return `Requested NTE must be greater than the current NTE ($${currentNte.toFixed(2)}).`;
|
||||
}
|
||||
if (!reason.trim()) {
|
||||
return "Please include a reason for the uplift request.";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function UpliftRequestsSection({
|
||||
|
|
@ -167,56 +33,136 @@ export function UpliftRequestsSection({
|
|||
upliftRequests,
|
||||
locked,
|
||||
onChanged,
|
||||
pollDocument,
|
||||
}: UpliftRequestsSectionProps) {
|
||||
const evidence = useVendorUpliftEvidence({ pollDocument });
|
||||
const {
|
||||
submitting,
|
||||
formError,
|
||||
infoMessage,
|
||||
requestError,
|
||||
errorRequestId,
|
||||
handleCreate,
|
||||
handleWithdraw,
|
||||
handleRevise,
|
||||
} = useVendorUpliftActions({ token, dispatchId, currentNte, onChanged, evidence });
|
||||
const [requested, setRequested] = useState("");
|
||||
const [reason, setReason] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
const activeRequest = upliftRequests.find((request) => ACTIVE_STATUSES.has(request.status));
|
||||
const canShowForm = !locked && !activeRequest;
|
||||
const pending = upliftRequests.find((request) => request.status === "Pending");
|
||||
|
||||
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 (
|
||||
<>
|
||||
{upliftRequests.length === 0 && !activeRequest && (
|
||||
<VendorPortalFeedback>
|
||||
{upliftRequests.length === 0 && !pending && (
|
||||
<div className="vp-muted" style={{ marginBottom: 12 }}>
|
||||
No uplift requests yet. Use the form below if the work will exceed the current NTE.
|
||||
</VendorPortalFeedback>
|
||||
</div>
|
||||
)}
|
||||
{Boolean(infoMessage) && <VendorPortalFeedback>{infoMessage}</VendorPortalFeedback>}
|
||||
|
||||
{upliftRequests.map((request) => (
|
||||
<UpliftRequestCard
|
||||
key={request.id}
|
||||
request={request}
|
||||
locked={locked}
|
||||
canAct={!locked}
|
||||
uploadingPercent={evidence.uploadingPercent}
|
||||
submitting={submitting}
|
||||
errorMessage={errorRequestId === request.id ? requestError : ""}
|
||||
onWithdraw={(r) => void handleWithdraw(r)}
|
||||
onRevise={(r, values) => void handleRevise(r, values)}
|
||||
onCancelRevise={evidence.reset}
|
||||
/>
|
||||
<div key={request.id} 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={`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>
|
||||
|
||||
{canShowForm && (
|
||||
<UpliftRequestForm
|
||||
mode="create"
|
||||
currentNte={currentNte}
|
||||
submitting={submitting}
|
||||
uploadingPercent={evidence.uploadingPercent}
|
||||
errorMessage={formError}
|
||||
onSubmit={(values) => void handleCreate(values)}
|
||||
/>
|
||||
{!locked && !pending && (
|
||||
<form onSubmit={(event) => void submit(event)} style={{ marginTop: 12 }}>
|
||||
<div className="vp-row">
|
||||
<strong>Current NTE</strong>
|
||||
<span>${Number(currentNte ?? 0).toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="vp-row">
|
||||
<strong>New total NTE</strong>
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,72 +0,0 @@
|
|||
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,56 +300,6 @@
|
|||
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 {
|
||||
background: var(--background);
|
||||
border-bottom: 1px solid var(--border);
|
||||
|
|
|
|||
|
|
@ -1,44 +0,0 @@
|
|||
import { useMemo } from "react";
|
||||
import { Box } from "@mui/material";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import {
|
||||
buildCommentBodySegments,
|
||||
type MentionLookup,
|
||||
} from "@/domain/work-orders/utils/comment-mentions";
|
||||
|
||||
type CommentBodyWithMentionsProps = {
|
||||
text: string;
|
||||
mentions?: string[];
|
||||
users?: MentionLookup[];
|
||||
};
|
||||
|
||||
export function CommentBodyWithMentions({ text, mentions, users }: CommentBodyWithMentionsProps) {
|
||||
const segments = useMemo(
|
||||
() => buildCommentBodySegments(text, mentions, users),
|
||||
[text, mentions, users],
|
||||
);
|
||||
|
||||
if (segments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Text variant="description" as="span">
|
||||
{segments.map((segment, index) => {
|
||||
if (segment.type === "text") {
|
||||
return <span key={`text-${index}`}>{segment.value}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="span"
|
||||
key={`mention-${segment.userId}-${index}`}
|
||||
className="font-semibold text-primary"
|
||||
>
|
||||
@{segment.label}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,205 +0,0 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Box,
|
||||
ClickAwayListener,
|
||||
List,
|
||||
ListItemButton,
|
||||
Paper,
|
||||
Popper,
|
||||
TextField,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
filterMentionCandidates,
|
||||
findActiveMentionQuery,
|
||||
insertMentionToken,
|
||||
type MentionLookup,
|
||||
} from "@/domain/work-orders/utils/comment-mentions";
|
||||
|
||||
type CommentMentionComposerProps = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
candidates: MentionLookup[];
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
rows?: number;
|
||||
onSubmit?: () => void;
|
||||
};
|
||||
|
||||
type MentionCandidateMenuProps = {
|
||||
open: boolean;
|
||||
anchorEl: HTMLElement | null;
|
||||
candidates: MentionLookup[];
|
||||
highlightIndex: number;
|
||||
onSelect: (user: MentionLookup) => void;
|
||||
};
|
||||
|
||||
function MentionCandidateMenu({
|
||||
open,
|
||||
anchorEl,
|
||||
candidates,
|
||||
highlightIndex,
|
||||
onSelect,
|
||||
}: MentionCandidateMenuProps) {
|
||||
return (
|
||||
<Popper open={open} anchorEl={anchorEl} placement="bottom-start" className="z-50">
|
||||
<Paper elevation={4} className="mt-1 max-h-48 overflow-y-auto">
|
||||
<List dense disablePadding>
|
||||
{candidates.map((candidate, index) => (
|
||||
<ListItemButton
|
||||
key={String(candidate.id)}
|
||||
selected={index === highlightIndex}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => onSelect(candidate)}
|
||||
>
|
||||
@{candidate.name}
|
||||
</ListItemButton>
|
||||
))}
|
||||
</List>
|
||||
</Paper>
|
||||
</Popper>
|
||||
);
|
||||
}
|
||||
|
||||
export function CommentMentionComposer({
|
||||
value,
|
||||
onChange,
|
||||
candidates,
|
||||
placeholder = "Type a comment...",
|
||||
disabled = false,
|
||||
rows = 2,
|
||||
onSubmit,
|
||||
}: CommentMentionComposerProps) {
|
||||
const inputRef = useRef<HTMLTextAreaElement | HTMLInputElement>(null);
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||
const [cursor, setCursor] = useState(0);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [highlightIndex, setHighlightIndex] = useState(0);
|
||||
|
||||
const activeQuery = useMemo(() => findActiveMentionQuery(value, cursor), [value, cursor]);
|
||||
|
||||
const filteredCandidates = useMemo(
|
||||
() => filterMentionCandidates(candidates, activeQuery?.query ?? ""),
|
||||
[activeQuery?.query, candidates],
|
||||
);
|
||||
|
||||
const showMenu = Boolean(menuOpen && activeQuery && filteredCandidates.length > 0);
|
||||
|
||||
useEffect(() => {
|
||||
setHighlightIndex(0);
|
||||
}, [activeQuery?.query, activeQuery?.start]);
|
||||
|
||||
const syncCursor = useCallback((textOverride?: string) => {
|
||||
const element = inputRef.current;
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
const text = textOverride ?? element.value;
|
||||
const nextCursor = element.selectionStart ?? text.length;
|
||||
setCursor(nextCursor);
|
||||
setMenuOpen(Boolean(findActiveMentionQuery(text, nextCursor)));
|
||||
}, []);
|
||||
|
||||
const handleChange = (nextValue: string) => {
|
||||
onChange(nextValue);
|
||||
requestAnimationFrame(() => {
|
||||
syncCursor(nextValue);
|
||||
});
|
||||
};
|
||||
|
||||
const applyMention = useCallback(
|
||||
(user: MentionLookup) => {
|
||||
if (!activeQuery) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { nextText, nextCursor } = insertMentionToken(value, activeQuery.start, cursor, user);
|
||||
onChange(nextText);
|
||||
setMenuOpen(false);
|
||||
setHighlightIndex(0);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const element = inputRef.current;
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
element.focus();
|
||||
element.setSelectionRange(nextCursor, nextCursor);
|
||||
setCursor(nextCursor);
|
||||
});
|
||||
},
|
||||
[activeQuery, cursor, onChange, value],
|
||||
);
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (showMenu) {
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setHighlightIndex((current) => (current + 1) % filteredCandidates.length);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setHighlightIndex(
|
||||
(current) => (current - 1 + filteredCandidates.length) % filteredCandidates.length,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
const selected = filteredCandidates[highlightIndex];
|
||||
if (selected) {
|
||||
applyMention(selected);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
setMenuOpen(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === "Enter" && (event.metaKey || event.ctrlKey) && onSubmit) {
|
||||
event.preventDefault();
|
||||
onSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ClickAwayListener onClickAway={() => setMenuOpen(false)}>
|
||||
<Box
|
||||
className="relative"
|
||||
ref={(node: HTMLDivElement | null) => {
|
||||
setAnchorEl(node);
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
inputRef={inputRef}
|
||||
multiline
|
||||
rows={rows}
|
||||
fullWidth
|
||||
value={value}
|
||||
onChange={(event) => handleChange(event.target.value)}
|
||||
onClick={() => syncCursor()}
|
||||
onKeyUp={() => syncCursor()}
|
||||
onSelect={() => syncCursor()}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<MentionCandidateMenu
|
||||
open={showMenu}
|
||||
anchorEl={anchorEl}
|
||||
candidates={filteredCandidates}
|
||||
highlightIndex={highlightIndex}
|
||||
onSelect={applyMention}
|
||||
/>
|
||||
</Box>
|
||||
</ClickAwayListener>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,7 +6,6 @@ type CommentSource = {
|
|||
commenter?: string | null;
|
||||
createdDate?: string;
|
||||
commenttext: string;
|
||||
mentions?: string[];
|
||||
};
|
||||
|
||||
export function mapWorkOrderCommentsToTimeline(comments: CommentSource[]): CommentTimelineItem[] {
|
||||
|
|
@ -15,7 +14,6 @@ export function mapWorkOrderCommentsToTimeline(comments: CommentSource[]): Comme
|
|||
author: comment.userName || comment.commenter || "System",
|
||||
createdAt: comment.createdDate,
|
||||
body: comment.commenttext,
|
||||
mentions: comment.mentions,
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
import { useMemo } from "react";
|
||||
import { Box, Stack } from "@mui/material";
|
||||
import { CommentBodyWithMentions } from "@/components/common/comment-body-with-mentions";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import type { MentionLookup } from "@/domain/work-orders/utils/comment-mentions";
|
||||
import { Box, Stack, Typography } from "@mui/material";
|
||||
import { formatDateTime } from "@/lib/time-utils";
|
||||
|
||||
export type CommentTimelineItem = {
|
||||
|
|
@ -10,20 +7,18 @@ export type CommentTimelineItem = {
|
|||
author: string;
|
||||
createdAt?: string;
|
||||
body: string;
|
||||
mentions?: string[];
|
||||
};
|
||||
|
||||
type CommentTimelineProps = {
|
||||
items: CommentTimelineItem[];
|
||||
emptyMessage?: string;
|
||||
maxHeightClass?: string;
|
||||
mentionUsers?: MentionLookup[];
|
||||
};
|
||||
|
||||
export function CommentTimeline({
|
||||
items,
|
||||
emptyMessage = "No comments yet.",
|
||||
maxHeightClass = "max-h-72",
|
||||
mentionUsers,
|
||||
}: CommentTimelineProps) {
|
||||
const sorted = useMemo(
|
||||
() =>
|
||||
|
|
@ -36,7 +31,7 @@ export function CommentTimeline({
|
|||
);
|
||||
|
||||
if (!sorted.length) {
|
||||
return <Text tone="muted">{emptyMessage}</Text>;
|
||||
return <Typography color="text.secondary">{emptyMessage}</Typography>;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -44,14 +39,14 @@ export function CommentTimeline({
|
|||
{sorted.map((item) => (
|
||||
<Box key={String(item.id)} className="rounded border border-border p-3">
|
||||
<Stack direction="row" className="mb-1" sx={{ justifyContent: "space-between" }}>
|
||||
<Text variant="caption" className="font-semibold">
|
||||
<Typography variant="caption" className="font-semibold">
|
||||
{item.author}
|
||||
</Text>
|
||||
<Text variant="caption" tone="muted">
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{item.createdAt ? formatDateTime(item.createdAt) : ""}
|
||||
</Text>
|
||||
</Typography>
|
||||
</Stack>
|
||||
<CommentBodyWithMentions text={item.body} mentions={item.mentions} users={mentionUsers} />
|
||||
<Typography variant="body2">{item.body}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { HTTPError } from "ky";
|
||||
import { API_PATHS } from "@/api/api-paths";
|
||||
import { api, apiGet, apiPost } from "@/api/api";
|
||||
import { apiGet, apiPost } from "@/api/api";
|
||||
import { handleApiResponse } from "@/api/handle-api-response";
|
||||
import { mapUpliftQueueItems, mapUpliftRequests } from "@/domain/uplifts/mappers/uplift-mapper";
|
||||
import { mapUpliftQueueItems } from "@/domain/uplifts/mappers/uplift-mapper";
|
||||
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";
|
||||
|
||||
function buildQueueParams(params: UpliftsQueueParams): Record<string, string | number> {
|
||||
|
|
@ -16,26 +16,6 @@ function buildQueueParams(params: UpliftsQueueParams): Record<string, string | n
|
|||
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 = {
|
||||
list: async (params: UpliftsQueueParams = {}): Promise<UpliftsQueueResult> => {
|
||||
const data = await apiGet<unknown>(API_PATHS.rest.uplifts, {
|
||||
|
|
@ -77,35 +57,7 @@ export const upliftsApi = {
|
|||
await apiPost<unknown>(`${API_PATHS.rest.uplifts}/${id}/approve`, { note });
|
||||
},
|
||||
|
||||
reject: async (id: string | number, note: string): Promise<void> => {
|
||||
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);
|
||||
deny: async (id: string | number, note: string): Promise<void> => {
|
||||
await apiPost<unknown>(`${API_PATHS.rest.uplifts}/${id}/deny`, { note });
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { UpliftQueueItem, UpliftRequest } from "@/domain/uplifts/types/uplift";
|
||||
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
|
||||
import { mapUpliftRequest } from "@/domain/work-orders/mappers/work-order-mapper";
|
||||
|
||||
function asRecord(raw: unknown): Record<string, unknown> {
|
||||
return typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
|
||||
|
|
@ -13,21 +14,6 @@ function readString(record: Record<string, unknown>, ...keys: string[]): string
|
|||
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(
|
||||
record: Record<string, unknown>,
|
||||
...keys: string[]
|
||||
|
|
@ -39,36 +25,6 @@ function readOptionalId(
|
|||
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 {
|
||||
const base = mapUpliftRequest(raw);
|
||||
const item = asRecord(raw);
|
||||
|
|
|
|||
|
|
@ -1,19 +1,3 @@
|
|||
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 {
|
||||
id: string | number;
|
||||
status: string;
|
||||
|
|
@ -28,13 +12,6 @@ export interface UpliftRequest {
|
|||
decisionNote: string;
|
||||
requiredTier: number;
|
||||
canDecide: boolean;
|
||||
expiresAt: string;
|
||||
notificationStatus: string;
|
||||
notificationError: string;
|
||||
evidenceDocumentId: string | number | null;
|
||||
evidenceFileName: string;
|
||||
evidenceContentType: string;
|
||||
evidenceSizeBytes: number | null;
|
||||
}
|
||||
|
||||
export interface UpliftQueueItem extends UpliftRequest {
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ export interface ApproveUpliftInput {
|
|||
note?: string;
|
||||
}
|
||||
|
||||
export interface DecisionUpliftInput {
|
||||
export interface DenyUpliftInput {
|
||||
id: string | number;
|
||||
note: string;
|
||||
}
|
||||
|
|
@ -61,38 +61,19 @@ export function useApproveUplift(
|
|||
});
|
||||
}
|
||||
|
||||
export function useRejectUplift(
|
||||
export function useDenyUplift(
|
||||
dispatchId?: string | number,
|
||||
): UseMutationResult<void, Error, DecisionUpliftInput> {
|
||||
): UseMutationResult<void, Error, DenyUpliftInput> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, note }: { id: string | number; note: string }) =>
|
||||
upliftsApi.reject(id, note),
|
||||
mutationFn: ({ id, note }: { id: string | number; note: string }) => upliftsApi.deny(id, note),
|
||||
onSuccess: () => {
|
||||
invalidateUpliftQueries(queryClient, dispatchId);
|
||||
toast.success("Uplift rejected");
|
||||
toast.success("Uplift denied");
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
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");
|
||||
toast.error(error.message || "Failed to deny uplift");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,12 +6,8 @@ import type {
|
|||
VendorPortalDispatchDetail,
|
||||
VendorPortalDispatchSummary,
|
||||
VendorPortalDocument,
|
||||
VendorPortalDocumentStatus,
|
||||
VendorPortalSession,
|
||||
VendorPortalSignoff,
|
||||
VendorUpliftRequestBody,
|
||||
VendorUpliftRequestOutcome,
|
||||
VendorUpliftReviseBody,
|
||||
} from "@/domain/vendor-portal/types/vendor-portal";
|
||||
|
||||
function resolveVendorPortalUrl(path: string): string {
|
||||
|
|
@ -19,14 +15,11 @@ function resolveVendorPortalUrl(path: string): string {
|
|||
return `${base}/${API_PATHS.vendorPortal.base}${path}`;
|
||||
}
|
||||
|
||||
export const VENDOR_EVIDENCE_PURPOSE = "UpliftEvidence" as const;
|
||||
|
||||
type UploadVendorDocumentOptions = {
|
||||
token: string;
|
||||
dispatchId: string | number;
|
||||
file: File;
|
||||
replacesDocumentId?: number;
|
||||
purpose?: string;
|
||||
onProgress: (percent: number) => void;
|
||||
};
|
||||
|
||||
|
|
@ -35,7 +28,6 @@ function uploadVendorDocument({
|
|||
dispatchId,
|
||||
file,
|
||||
replacesDocumentId,
|
||||
purpose,
|
||||
onProgress,
|
||||
}: UploadVendorDocumentOptions): Promise<VendorPortalDocument> {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
|
@ -59,7 +51,6 @@ function uploadVendorDocument({
|
|||
if (replacesDocumentId !== undefined) {
|
||||
body.append("replacesDocumentId", String(replacesDocumentId));
|
||||
}
|
||||
body.append("purpose", purpose ?? "Completion");
|
||||
request.send(body);
|
||||
});
|
||||
}
|
||||
|
|
@ -115,23 +106,6 @@ async function vendorPortalCall<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 = {
|
||||
session: (token: string) =>
|
||||
vendorPortalCall<VendorPortalSession>(token, API_PATHS.vendorPortal.session),
|
||||
|
|
@ -212,39 +186,21 @@ export const vendorPortalApi = {
|
|||
{ method: "POST", body: { commentText } },
|
||||
),
|
||||
|
||||
requestUplift: (token: string, dispatchId: string | number, payload: VendorUpliftRequestBody) =>
|
||||
vendorPortalCall<VendorUpliftRequestOutcome>(
|
||||
token,
|
||||
`${API_PATHS.vendorPortal.dispatches}/${dispatchId}/uplift-request`,
|
||||
{
|
||||
method: "POST",
|
||||
body: payload,
|
||||
},
|
||||
).then(toVendorUpliftOutcome),
|
||||
|
||||
withdrawUplift: (token: string, dispatchId: string | number, requestId: string | number) =>
|
||||
vendorPortalCall<void>(
|
||||
token,
|
||||
`${API_PATHS.vendorPortal.dispatches}/${dispatchId}/uplift-request/${requestId}/withdraw`,
|
||||
{ method: "POST" },
|
||||
),
|
||||
|
||||
reviseUplift: (
|
||||
requestUplift: (
|
||||
token: string,
|
||||
dispatchId: string | number,
|
||||
requestId: string | number,
|
||||
payload: VendorUpliftReviseBody,
|
||||
id: string | number,
|
||||
payload: { requestedNTE: number; reason: string },
|
||||
) =>
|
||||
vendorPortalCall<void>(token, `${API_PATHS.vendorPortal.dispatches}/${id}/uplift-request`, {
|
||||
method: "POST",
|
||||
body: payload,
|
||||
}),
|
||||
|
||||
cancelUplift: (token: string, id: string | number, requestId: string | number) =>
|
||||
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`,
|
||||
`${API_PATHS.vendorPortal.dispatches}/${id}/uplift-request/${requestId}/cancel`,
|
||||
{ method: "POST" },
|
||||
),
|
||||
|
||||
uploadDocument: uploadVendorDocument,
|
||||
|
|
|
|||
|
|
@ -1,6 +0,0 @@
|
|||
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();
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
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,18 +33,12 @@ export interface VendorPortalDocument {
|
|||
rejectionReason?: string;
|
||||
version: number;
|
||||
replacesDocumentId?: number;
|
||||
purpose?: string;
|
||||
createdDate?: string;
|
||||
scannedAt?: string;
|
||||
reviewedAt?: string;
|
||||
canDownload: boolean;
|
||||
}
|
||||
|
||||
export type VendorPortalDocumentStatus = Pick<
|
||||
VendorPortalDocument,
|
||||
"id" | "originalFileName" | "scanStatus" | "reviewStatus" | "purpose"
|
||||
>;
|
||||
|
||||
export interface VendorPortalChecklistItem {
|
||||
id: number;
|
||||
itemText: string;
|
||||
|
|
@ -71,29 +65,6 @@ export interface VendorPortalUpliftRequest {
|
|||
decidedByName?: string;
|
||||
decisionNote?: string;
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import type {
|
|||
VendorPoVendor,
|
||||
VendorPoWorkOrder,
|
||||
} from "@/domain/vendor-pos/types/vendor-po";
|
||||
import { mapUpliftRequests } from "@/domain/uplifts/mappers/uplift-mapper";
|
||||
import { mapUpliftRequests } from "@/domain/work-orders/mappers/work-order-mapper";
|
||||
|
||||
function asRecord(raw: unknown): Record<string, unknown> {
|
||||
return typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
|
||||
|
|
|
|||
|
|
@ -20,11 +20,6 @@ import type {
|
|||
} from "@/domain/work-orders/types/work-order-board-detail";
|
||||
import type { WorkOrderComment } from "@/domain/work-orders/types/work-order";
|
||||
|
||||
export interface CreateWorkOrderCommentPayload {
|
||||
text: string;
|
||||
mentions?: string[];
|
||||
}
|
||||
|
||||
async function readResponseJson(response: Response): Promise<unknown> {
|
||||
try {
|
||||
return await response.json();
|
||||
|
|
@ -62,17 +57,8 @@ export const workOrderBoardDocumentsApi = {
|
|||
return Array.isArray(items) ? items.map(mapBoardDetailComment) : [];
|
||||
},
|
||||
|
||||
createComment: async (
|
||||
workOrderId: string | number,
|
||||
text: string,
|
||||
mentions?: string[],
|
||||
): Promise<WorkOrderComment> => {
|
||||
const payload: CreateWorkOrderCommentPayload = { text };
|
||||
if (mentions && mentions.length > 0) {
|
||||
payload.mentions = mentions.map(String);
|
||||
}
|
||||
|
||||
const data = await apiPost<unknown>(API_PATHS.workOrder.comments(workOrderId), payload);
|
||||
createComment: async (workOrderId: string | number, text: string): Promise<WorkOrderComment> => {
|
||||
const data = await apiPost<unknown>(API_PATHS.workOrder.comments(workOrderId), { text });
|
||||
return mapBoardDetailComment(handleApiResponse(data));
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -61,33 +61,6 @@ function readBoolean(record: Record<string, unknown>, ...keys: string[]): boolea
|
|||
return false;
|
||||
}
|
||||
|
||||
function readStringArray(record: Record<string, unknown>, ...keys: string[]): string[] {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (!Array.isArray(value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const mapped = value
|
||||
.map((entry) => {
|
||||
if (typeof entry === "string") {
|
||||
return entry;
|
||||
}
|
||||
if (typeof entry === "number" || typeof entry === "boolean") {
|
||||
return String(entry);
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
if (mapped.length > 0) {
|
||||
return mapped;
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function mapDocStatus(raw: unknown): CompletionDocStatus {
|
||||
const numeric = typeof raw === "number" ? raw : Number(raw);
|
||||
if (numeric === DOC_STATUS_API.Yes) return "Yes";
|
||||
|
|
@ -120,7 +93,6 @@ export function mapBoardDetailComment(raw: unknown): WorkOrderComment {
|
|||
authorId,
|
||||
commenter: readString(item, "commenter", "Commenter") || null,
|
||||
recordType: readString(item, "recordType", "RecordType") || null,
|
||||
mentions: readStringArray(item, "mentions", "Mentions"),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import type {
|
|||
WorkOrderFormValues,
|
||||
WorkOrderListItem,
|
||||
} 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 { WORK_ORDER_STATUS_OPTIONS } from "@/domain/work-orders/types/work-order-common";
|
||||
import { toDateInputValue } from "@/lib/time-utils";
|
||||
|
|
@ -214,12 +215,6 @@ export { mapWorkOrderTableRow, mapWorkOrderTableRows } from "./work-order-table-
|
|||
|
||||
export function mapWorkOrderComment(raw: unknown): WorkOrderComment {
|
||||
const item = asRecord(raw);
|
||||
const mentionsRaw = item.mentions ?? item.Mentions;
|
||||
const mentions = Array.isArray(mentionsRaw)
|
||||
? mentionsRaw
|
||||
.map((entry) => (typeof entry === "string" ? entry : String(entry)))
|
||||
.filter(Boolean)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
id: readId(item),
|
||||
|
|
@ -229,7 +224,6 @@ export function mapWorkOrderComment(raw: unknown): WorkOrderComment {
|
|||
userName: readString(item, "userName", "UserName"),
|
||||
commenter: readString(item, "commenter", "Commenter") || null,
|
||||
recordType: readString(item, "recordType", "RecordType") || null,
|
||||
mentions,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -461,3 +455,27 @@ export {
|
|||
mapDispatchWorkOrderRef,
|
||||
mapUpdateDispatchPayload,
|
||||
} 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -94,7 +94,6 @@ export interface WorkOrderBoardDetailCommentApi {
|
|||
text?: string | null;
|
||||
time?: string | null;
|
||||
documents?: unknown;
|
||||
mentions?: string[] | null;
|
||||
}
|
||||
|
||||
export interface WorkOrderBoardDetailAuditApi {
|
||||
|
|
|
|||
|
|
@ -11,8 +11,6 @@ export interface WorkOrderComment {
|
|||
authorId?: string | null;
|
||||
commenter: string | null;
|
||||
recordType: string | null;
|
||||
/** Mentioned user ids when returned by board comments API. */
|
||||
mentions?: string[];
|
||||
}
|
||||
|
||||
export interface WorkOrderAuditEntry {
|
||||
|
|
|
|||
|
|
@ -38,9 +38,8 @@ function invalidateBoardDetail(
|
|||
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.detail(workOrderId) });
|
||||
}
|
||||
|
||||
export interface AddWorkOrderCommentInput {
|
||||
interface AddWorkOrderCommentInput {
|
||||
text: string;
|
||||
mentions?: string[];
|
||||
commentType?: "customer" | "internal";
|
||||
}
|
||||
|
||||
|
|
@ -79,12 +78,12 @@ export interface AddDispatchSignoffInput {
|
|||
signatureMethod: string;
|
||||
}
|
||||
|
||||
export interface ChangeWorkOrderAssignmentInput {
|
||||
interface ChangeWorkOrderAssignmentInput {
|
||||
userId: string | number;
|
||||
userName: string;
|
||||
}
|
||||
|
||||
export interface UpdateWorkOrderVendorInput {
|
||||
interface UpdateWorkOrderVendorInput {
|
||||
vendorId: string | number;
|
||||
payload: WorkOrderVendorUpdatePayload;
|
||||
}
|
||||
|
|
@ -158,8 +157,8 @@ export function useAddWorkOrderComment(
|
|||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ text, mentions }: AddWorkOrderCommentInput) =>
|
||||
workOrdersApi.createComment(workOrderId, text, mentions),
|
||||
mutationFn: ({ text }: AddWorkOrderCommentInput) =>
|
||||
workOrdersApi.createComment(workOrderId, text),
|
||||
onSuccess: () => {
|
||||
invalidateBoardDetail(queryClient, workOrderId);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,188 +0,0 @@
|
|||
/** Encoded mention token stored in comment text: `@[userId:Display Name]`. */
|
||||
export const MENTION_TOKEN_PATTERN = /@\[([^:\]]+):([^\]]+)\]/g;
|
||||
|
||||
export type CommentBodySegment =
|
||||
{ type: "text"; value: string } | { type: "mention"; userId: string; label: string };
|
||||
|
||||
export type MentionLookup = {
|
||||
id: string | number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export function encodeMentionToken(userId: string | number, userName: string): string {
|
||||
const trimmedName = userName.trim();
|
||||
return `@[${String(userId)}:${trimmedName}]`;
|
||||
}
|
||||
|
||||
export function extractMentionIds(text: string): string[] {
|
||||
const ids: string[] = [];
|
||||
const pattern = new RegExp(MENTION_TOKEN_PATTERN.source, "g");
|
||||
|
||||
for (const match of text.matchAll(pattern)) {
|
||||
const userId = match[1];
|
||||
if (userId && !ids.includes(userId)) {
|
||||
ids.push(userId);
|
||||
}
|
||||
}
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function toDisplayText(text: string): string {
|
||||
return text.replace(MENTION_TOKEN_PATTERN, (_full, _userId: string, userName: string) => {
|
||||
return `@${userName}`;
|
||||
});
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function pushTextSegment(segments: CommentBodySegment[], value: string): void {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const last = segments.length > 0 ? segments[segments.length - 1] : undefined;
|
||||
if (last?.type === "text") {
|
||||
last.value += value;
|
||||
return;
|
||||
}
|
||||
|
||||
segments.push({ type: "text", value });
|
||||
}
|
||||
|
||||
function resolveMentionNames(
|
||||
mentionIds: string[] | undefined,
|
||||
users: MentionLookup[] | undefined,
|
||||
): Map<string, string> {
|
||||
const byId = new Map<string, string>();
|
||||
const usersById = new Map((users ?? []).map((user) => [String(user.id), user.name] as const));
|
||||
|
||||
// Only ids that were actually mentioned — never seed the full dispatcher roster.
|
||||
for (const mentionId of mentionIds ?? []) {
|
||||
const id = String(mentionId);
|
||||
byId.set(id, usersById.get(id) ?? id);
|
||||
}
|
||||
|
||||
return byId;
|
||||
}
|
||||
|
||||
function appendPlainMentionMatches(
|
||||
segments: CommentBodySegment[],
|
||||
plainText: string,
|
||||
mentionNames: string[],
|
||||
): void {
|
||||
if (!plainText || mentionNames.length === 0) {
|
||||
pushTextSegment(segments, plainText);
|
||||
return;
|
||||
}
|
||||
|
||||
const sortedNames = [...mentionNames].sort((a, b) => b.length - a.length);
|
||||
const namePattern = sortedNames.map((name) => escapeRegExp(name)).join("|");
|
||||
// Require a non-word boundary after the name so "@Ann" does not match inside "@Annual".
|
||||
const mentionRegex = new RegExp(`@(${namePattern})(?![\\w])`, "g");
|
||||
|
||||
let cursor = 0;
|
||||
for (const match of plainText.matchAll(mentionRegex)) {
|
||||
const index = match.index ?? 0;
|
||||
pushTextSegment(segments, plainText.slice(cursor, index));
|
||||
|
||||
const label = match[1] ?? "";
|
||||
segments.push({ type: "mention", userId: label, label });
|
||||
cursor = index + match[0].length;
|
||||
}
|
||||
|
||||
pushTextSegment(segments, plainText.slice(cursor));
|
||||
}
|
||||
|
||||
export function buildCommentBodySegments(
|
||||
text: string,
|
||||
mentionIds?: string[],
|
||||
users?: MentionLookup[],
|
||||
): CommentBodySegment[] {
|
||||
if (!text) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const segments: CommentBodySegment[] = [];
|
||||
const encodedPattern = new RegExp(MENTION_TOKEN_PATTERN.source, "g");
|
||||
const mentionNameById = resolveMentionNames(mentionIds, users);
|
||||
const encodedLabels: string[] = [];
|
||||
for (const match of text.matchAll(new RegExp(MENTION_TOKEN_PATTERN.source, "g"))) {
|
||||
const label = match[2]?.trim();
|
||||
if (label) encodedLabels.push(label);
|
||||
}
|
||||
const mentionNames = [...new Set([...mentionNameById.values(), ...encodedLabels])].filter(
|
||||
Boolean,
|
||||
);
|
||||
let cursor = 0;
|
||||
|
||||
for (const match of text.matchAll(encodedPattern)) {
|
||||
const index = match.index ?? 0;
|
||||
const plainBetween = text.slice(cursor, index);
|
||||
appendPlainMentionMatches(segments, plainBetween, mentionNames);
|
||||
|
||||
const userId = match[1] ?? "";
|
||||
const label = match[2] ?? userId;
|
||||
segments.push({ type: "mention", userId, label });
|
||||
cursor = index + match[0].length;
|
||||
}
|
||||
|
||||
const trailingPlain = text.slice(cursor);
|
||||
appendPlainMentionMatches(segments, trailingPlain, mentionNames);
|
||||
|
||||
return segments.length > 0 ? segments : [{ type: "text", value: text }];
|
||||
}
|
||||
|
||||
export type ActiveMentionQuery = {
|
||||
start: number;
|
||||
query: string;
|
||||
};
|
||||
|
||||
export function findActiveMentionQuery(text: string, cursor: number): ActiveMentionQuery | null {
|
||||
const beforeCursor = text.slice(0, cursor);
|
||||
const atIndex = beforeCursor.lastIndexOf("@");
|
||||
|
||||
if (atIndex < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const prefix = beforeCursor.slice(0, atIndex);
|
||||
const lastPrefixChar = prefix.length > 0 ? prefix[prefix.length - 1] : "";
|
||||
if (prefix.length > 0 && !/\s/.test(lastPrefixChar)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const query = beforeCursor.slice(atIndex + 1);
|
||||
if (/[\n\r\t[\]]/.test(query)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { start: atIndex, query };
|
||||
}
|
||||
|
||||
export function filterMentionCandidates(
|
||||
candidates: MentionLookup[],
|
||||
query: string,
|
||||
): MentionLookup[] {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
const filtered = normalized
|
||||
? candidates.filter((candidate) => candidate.name.toLowerCase().includes(normalized))
|
||||
: candidates;
|
||||
|
||||
return filtered.slice(0, 8);
|
||||
}
|
||||
|
||||
export function insertMentionToken(
|
||||
text: string,
|
||||
queryStart: number,
|
||||
cursor: number,
|
||||
user: MentionLookup,
|
||||
): { nextText: string; nextCursor: number } {
|
||||
const token = `${encodeMentionToken(user.id, user.name)} `;
|
||||
const nextText = `${text.slice(0, queryStart)}${token}${text.slice(cursor)}`;
|
||||
const nextCursor = queryStart + token.length;
|
||||
|
||||
return { nextText, nextCursor };
|
||||
}
|
||||
|
|
@ -88,7 +88,6 @@ export const queryKeys = {
|
|||
[...queryKeys.uplifts.all, "queue", params ?? {}] as const,
|
||||
forDispatch: (dispatchId: string | number) =>
|
||||
[...queryKeys.uplifts.all, "dispatch", dispatchId] as const,
|
||||
evidence: (id: string | number) => [...queryKeys.uplifts.all, "evidence", id] as const,
|
||||
},
|
||||
vendorPos: {
|
||||
all: ["vendorPos"] as const,
|
||||
|
|
|
|||
|
|
@ -55,7 +55,6 @@ function renderSections({
|
|||
data={data}
|
||||
locked={locked}
|
||||
refresh={vi.fn()}
|
||||
pollEvidenceDocument={vi.fn(async () => undefined)}
|
||||
updateLocal={vi.fn()}
|
||||
/>,
|
||||
{ withAuth: false },
|
||||
|
|
|
|||
|
|
@ -1,48 +0,0 @@
|
|||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
import { screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useState } from "react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { CommentMentionComposer } from "@/components/common/comment-mention-composer";
|
||||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
const candidates = [{ id: "1", name: "Alice Dispatcher" }];
|
||||
|
||||
function MentionComposerHarness() {
|
||||
const [value, setValue] = useState("");
|
||||
|
||||
return <CommentMentionComposer value={value} onChange={setValue} candidates={candidates} />;
|
||||
}
|
||||
|
||||
describe("CommentMentionComposer", () => {
|
||||
it("keeps the suggestion menu visible after typing @", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<MentionComposerHarness />, { withAuth: false });
|
||||
|
||||
await user.type(screen.getByRole("textbox"), "@");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "@Alice Dispatcher" })).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -380,15 +380,6 @@ describe("workOrdersApi.createComment", () => {
|
|||
expect(comment.authorId).toBe("guid");
|
||||
expect(comment.createdDate).toBe("2026-06-01T12:00:00.0000000Z");
|
||||
});
|
||||
|
||||
it("POSTs mentions when provided", async () => {
|
||||
await workOrdersApi.createComment(55, "Ping @[u1:Jane]", ["u1"]);
|
||||
|
||||
expect(apiPost).toHaveBeenCalledWith(API_PATHS.workOrder.comments(55), {
|
||||
text: "Ping @[u1:Jane]",
|
||||
mentions: ["u1"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("workOrdersApi.updateComment", () => {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
mapUpliftRequest,
|
||||
mapWorkOrderDispatchSummary,
|
||||
mapWorkOrderTableRow,
|
||||
} from "@/domain/work-orders/mappers/work-order-mapper";
|
||||
import { mapUpliftRequest } from "@/domain/uplifts/mappers/uplift-mapper";
|
||||
|
||||
describe("mapWorkOrderDispatchSummary", () => {
|
||||
it("preserves the assigned vendor identity needed by inline editing", () => {
|
||||
|
|
@ -39,12 +39,6 @@ describe("mapUpliftRequest", () => {
|
|||
DecisionNote: "Approved per contract",
|
||||
RequiredTier: 2,
|
||||
CanDecide: false,
|
||||
ExpiresAt: "2026-02-05T08:00:00Z",
|
||||
NotificationStatus: "Sent",
|
||||
EvidenceDocumentId: 77,
|
||||
EvidenceFileName: "invoice.pdf",
|
||||
EvidenceContentType: "application/pdf",
|
||||
EvidenceSizeBytes: 2048,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
|
|
@ -61,26 +55,16 @@ describe("mapUpliftRequest", () => {
|
|||
decisionNote: "Approved per contract",
|
||||
requiredTier: 2,
|
||||
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 and evidence fields", () => {
|
||||
it("defaults missing numeric fields", () => {
|
||||
const result = mapUpliftRequest({ id: 1, status: "Pending" });
|
||||
|
||||
expect(result.requestedNTE).toBe(0);
|
||||
expect(result.delta).toBe(0);
|
||||
expect(result.requiredTier).toBe(1);
|
||||
expect(result.canDecide).toBe(false);
|
||||
expect(result.evidenceDocumentId).toBeNull();
|
||||
expect(result.evidenceFileName).toBe("");
|
||||
expect(result.evidenceSizeBytes).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,118 +0,0 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildCommentBodySegments,
|
||||
encodeMentionToken,
|
||||
extractMentionIds,
|
||||
filterMentionCandidates,
|
||||
findActiveMentionQuery,
|
||||
insertMentionToken,
|
||||
toDisplayText,
|
||||
} from "@/domain/work-orders/utils/comment-mentions";
|
||||
|
||||
describe("comment-mentions", () => {
|
||||
it("encodes and extracts mention ids from tokens", () => {
|
||||
const token = encodeMentionToken("user-1", "Jane Doe");
|
||||
const text = `Please review ${token} today`;
|
||||
|
||||
expect(text).toBe("Please review @[user-1:Jane Doe] today");
|
||||
expect(extractMentionIds(text)).toEqual(["user-1"]);
|
||||
expect(toDisplayText(text)).toBe("Please review @Jane Doe today");
|
||||
});
|
||||
|
||||
it("builds highlight segments from encoded tokens", () => {
|
||||
const text = `Hi ${encodeMentionToken("42", "Alex Smith")}!`;
|
||||
|
||||
expect(buildCommentBodySegments(text)).toEqual([
|
||||
{ type: "text", value: "Hi " },
|
||||
{ type: "mention", userId: "42", label: "Alex Smith" },
|
||||
{ type: "text", value: "!" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("highlights plain @names when response mentions are provided", () => {
|
||||
const text = "Ping @Jane Doe about this";
|
||||
|
||||
expect(
|
||||
buildCommentBodySegments(text, ["user-1"], [{ id: "user-1", name: "Jane Doe" }]),
|
||||
).toEqual([
|
||||
{ type: "text", value: "Ping " },
|
||||
{ type: "mention", userId: "Jane Doe", label: "Jane Doe" },
|
||||
{ type: "text", value: " about this" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not highlight casual @names from the full user roster without mentions", () => {
|
||||
const text = "See @Jane Doe later";
|
||||
|
||||
expect(buildCommentBodySegments(text, undefined, [{ id: "user-1", name: "Jane Doe" }])).toEqual(
|
||||
[{ type: "text", value: "See @Jane Doe later" }],
|
||||
);
|
||||
});
|
||||
|
||||
it("highlights plain @names before encoded tokens when mentioned", () => {
|
||||
const encoded = encodeMentionToken("2", "Bob");
|
||||
const text = `Hi @Jane Doe and ${encoded}`;
|
||||
|
||||
expect(
|
||||
buildCommentBodySegments(
|
||||
text,
|
||||
["user-1", "2"],
|
||||
[
|
||||
{ id: "user-1", name: "Jane Doe" },
|
||||
{ id: "2", name: "Bob" },
|
||||
],
|
||||
),
|
||||
).toEqual([
|
||||
{ type: "text", value: "Hi " },
|
||||
{ type: "mention", userId: "Jane Doe", label: "Jane Doe" },
|
||||
{ type: "text", value: " and " },
|
||||
{ type: "mention", userId: "2", label: "Bob" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not highlight shorter mention names as prefixes of longer @words", () => {
|
||||
const text = "Check @Annual maintenance with @Ann";
|
||||
|
||||
expect(buildCommentBodySegments(text, ["user-ann"], [{ id: "user-ann", name: "Ann" }])).toEqual(
|
||||
[
|
||||
{ type: "text", value: "Check @Annual maintenance with " },
|
||||
{ type: "mention", userId: "Ann", label: "Ann" },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
it("finds active mention query at cursor", () => {
|
||||
const text = "Hello @ja";
|
||||
const query = findActiveMentionQuery(text, text.length);
|
||||
|
||||
expect(query).toEqual({ start: 6, query: "ja" });
|
||||
expect(findActiveMentionQuery("email@test.com", 13)).toBeNull();
|
||||
});
|
||||
|
||||
it("filters mention candidates by query", () => {
|
||||
const candidates = [
|
||||
{ id: "1", name: "Jane Doe" },
|
||||
{ id: "2", name: "John Smith" },
|
||||
];
|
||||
|
||||
expect(filterMentionCandidates(candidates, "jo")).toEqual([{ id: "2", name: "John Smith" }]);
|
||||
});
|
||||
|
||||
it("inserts encoded mention token replacing active query", () => {
|
||||
const text = "Hello @ja";
|
||||
const activeQuery = findActiveMentionQuery(text, text.length);
|
||||
|
||||
expect(activeQuery).not.toBeNull();
|
||||
if (!activeQuery) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { nextText, nextCursor } = insertMentionToken(text, activeQuery.start, text.length, {
|
||||
id: "1",
|
||||
name: "Jane Doe",
|
||||
});
|
||||
|
||||
expect(nextText).toBe("Hello @[1:Jane Doe] ");
|
||||
expect(nextCursor).toBe(nextText.length);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
# PR9 — Work order comment @mentions
|
||||
|
||||
## Jira
|
||||
|
||||
- [SH-217](https://luby-us.atlassian.net/browse/SH-217) — FE @mention autocomplete / chips
|
||||
- Related BE: [SH-75](https://luby-us.atlassian.net/browse/SH-75)
|
||||
|
||||
## QA residual
|
||||
|
||||
- Parcial / Blocked-BE for notification + structured `mentions[]` confirm; token chip polish = P1.
|
||||
|
||||
## Summary
|
||||
|
||||
- Adds `@` autocomplete to the internal comments composer on work order detail (`/workorders/:id`).
|
||||
- Encodes selected users as `@[userId:Display Name]` tokens in comment text so mentions stay readable when the backend ignores the optional payload field.
|
||||
- Sends `mentions: string[]` on `POST workorders/{id}/comments` when the composer contains encoded tokens.
|
||||
- Highlights mentions in the comment thread using encoded tokens and/or `mentions` returned by the API.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Notification Center deep-linking for mention events.
|
||||
- Backend notification triggers beyond the FE payload.
|
||||
|
||||
## BE contract
|
||||
|
||||
See `docs/work-orders/pending-be-contract.md` (Comments section):
|
||||
|
||||
| Field | Status |
|
||||
| ---------------------------------------- | -------- |
|
||||
| POST `{ text, mentions?: string[] }` | FE ready |
|
||||
| Response includes mentions for highlight | FE ready |
|
||||
|
||||
Until BE confirms, encoded tokens in `text` preserve highlight behavior client-side.
|
||||
|
||||
## Test plan
|
||||
|
||||
- [ ] Open a work order detail → Internal tab → type `@` and pick a dispatcher from the list.
|
||||
- [ ] Submit comment; verify POST body includes `text` with encoded token and `mentions` array.
|
||||
- [ ] Reload detail; verify mention is highlighted in the thread (token and/or API `mentions`).
|
||||
- [ ] Type `@` mid-word (e.g. email) — autocomplete should not open.
|
||||
- [ ] Run `npm run verify`.
|
||||
Loading…
Add table
Reference in a new issue