chore(work-orders): merge origin/dev into completedDate column branch

Align merge-base so vendor files matching dev drop out of the PR diff.
This commit is contained in:
arthur.bassi 2026-08-11 15:31:57 -03:00
commit 94f07fb66f
127 changed files with 7225 additions and 1120 deletions

View file

@ -41,3 +41,25 @@ jobs:
cache: npm
- run: npm ci
- run: npm run verify
vendor-visual-regression:
runs-on: ubuntu-latest
container: mcr.microsoft.com/playwright:v1.61.1-noble
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "24"
cache: npm
- run: npm ci
- run: npm run test:e2e:visual
- name: Upload visual diff artifacts
if: failure()
uses: actions/upload-artifact@v4
with:
name: vendor-visual-regression-diffs
path: |
test-results/visual
playwright-report-visual
if-no-files-found: ignore
retention-days: 14

1
.gitignore vendored
View file

@ -10,6 +10,7 @@
/coverage
/test-results
/playwright-report
/playwright-report-visual
/blob-report
/playwright/.cache

View file

@ -2,6 +2,7 @@ dist
build
node_modules
coverage
playwright-report-visual
package-lock.json
*.tsbuildinfo
@ -10,4 +11,4 @@ src/pages/**
src/**/*.js
src/**/*.jsx
src/**/*.css
!src/styles/globals.css
!src/styles/globals.css

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View file

@ -0,0 +1,221 @@
import { expect, test, type Page, type Route } from "@playwright/test";
const PORTAL_TOKEN = "portal-e2e-token";
const futureDate = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString();
const pastDate = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString();
interface DispatchDetail {
id: number;
dispatchNumber: string;
workOrderTitle?: string;
internalWONumber?: string;
status: string;
nteAmount?: number;
scheduledDate?: string;
poNumber?: string;
dispatchedAt?: string;
completedDate?: string;
refusedAt?: string;
statusUpdatedAt?: string;
description?: string;
workOrder?: Record<string, unknown>;
location?: Record<string, unknown>;
dispatcherContact?: { name?: string; email?: string; phone?: string };
checklist: never[];
comments: never[];
upliftRequests: never[];
signoffs: never[];
documents: never[];
}
const sentDetail: DispatchDetail = {
id: 101,
dispatchNumber: "DSP-101",
workOrderTitle: "Emergency boiler repair",
internalWONumber: "WO-8842",
status: "Sent",
nteAmount: 1250,
scheduledDate: futureDate,
poNumber: "PO-555",
statusUpdatedAt: "2026-08-09T16:30:00Z",
description: "Boiler is leaking; vendor visit required.",
workOrder: {
workerOrderTitle: "Emergency boiler repair",
internalWONumber: "WO-8842",
trade: "Plumbing",
subTrade: "Backflow Preventers",
siteCode: "STL-01",
service: "Boiler Repair",
},
location: {
name: "Central Distribution Center",
address: "100 Industrial Pkwy",
city: "St. Louis",
state: "MO",
zipCode: "63101",
},
dispatcherContact: {
name: "Dana Field",
email: "dana@seahavenind.com",
phone: "314-555-0142",
},
checklist: [],
comments: [],
upliftRequests: [],
signoffs: [],
documents: [],
};
const listDispatches = [
{
id: 101,
dispatchNumber: "DSP-101",
workOrderTitle: "Emergency boiler repair",
internalWONumber: "WO-8842",
locationName: "Central Distribution Center",
locationCity: "St. Louis",
locationState: "MO",
status: "Sent",
nteAmount: 1250,
scheduledDate: futureDate,
},
{
id: 102,
dispatchNumber: "DSP-102",
workOrderTitle: "HVAC quarterly service",
status: "Refused",
refusedAt: "2026-08-08T10:00:00Z",
},
{
id: 103,
dispatchNumber: "DSP-103",
workOrderTitle: "PastAcknowledged job",
status: "Acknowledged",
scheduledDate: pastDate,
},
];
async function fulfill(route: Route, body: unknown, status = 200) {
await route.fulfill({
status,
contentType: "application/json",
body: JSON.stringify(body),
});
}
async function mockPortalApi(page: Page, options: { sessionError?: boolean } = {}) {
const detailState: { current: DispatchDetail } = { current: { ...sentDetail } };
await page.route("**/api/vendor-portal/session", (route) => {
if (options.sessionError) {
fulfill(route, { message: "Invalid or expired token" }, 401);
return;
}
fulfill(route, { data: { companyName: "Gateway Plumbing", vendorId: 101 } });
});
await page.route(/\/api\/vendor-portal\/dispatches\/\d+\/refuse$/, async (route) => {
const body = route.request().postDataJSON() as { reason?: string };
detailState.current = {
...detailState.current,
status: "Refused",
refusedAt: new Date().toISOString(),
};
fulfill(route, {
data: {
id: detailState.current.id,
status: "Refused",
refusedAt: detailState.current.refusedAt,
reason: body?.reason,
},
});
});
await page.route(/\/api\/vendor-portal\/dispatches\/\d+$/, (route) =>
fulfill(route, { data: detailState.current }),
);
await page.route(/\/api\/vendor-portal\/dispatches(?:\?.*)?$/, (route) =>
fulfill(route, { data: listDispatches }),
);
return detailState;
}
test.describe("Vendor portal lifecycle", () => {
test("upcoming filter, detail snapshot, refuse dialog, and locked Refused state", async ({
page,
}) => {
await mockPortalApi(page);
await page.goto(`/v/${PORTAL_TOKEN}/dashboard`);
await expect(page.getByRole("heading", { name: "Your Work" })).toBeVisible();
await expect(page.getByRole("button", { name: /^Upcoming/ })).toBeVisible();
await expect(page.getByRole("button", { name: /^Refused/ })).toBeVisible();
await page.getByRole("button", { name: /^Upcoming/ }).click();
await expect(page.getByText("DSP-101 — Emergency boiler repair")).toBeVisible();
await expect(page.getByText("DSP-103")).toHaveCount(0);
await page.getByText("DSP-101 — Emergency boiler repair").click();
await expect(page).toHaveURL(/\/dispatch\/101$/);
await expect(page.getByRole("heading", { name: "DSP-101" })).toBeVisible();
await expect(page.getByText("Boiler Repair", { exact: true })).toBeVisible();
await expect(page.getByText("STL-01")).toBeVisible();
await expect(page.getByText("$1250.00").first()).toBeVisible();
await expect(page.getByRole("link", { name: "dana@seahavenind.com" })).toBeVisible();
await expect(page.getByText("Dana Field")).toBeVisible();
await expect(page.getByText("314-555-0142")).toBeVisible();
await expect(page.getByText(/Status updated/i)).toBeVisible();
await page.getByRole("button", { name: "Refuse Dispatch" }).click();
const dialog = page.getByRole("dialog", { name: "Refuse dispatch" });
await expect(dialog).toBeVisible();
await dialog.getByLabel(/Reason/i).fill("Equipment unavailable this week.");
await expect(dialog.getByText(/\d+\/500/)).toBeVisible();
await dialog.getByRole("button", { name: "Refuse dispatch" }).click();
await expect(dialog).toHaveCount(0);
await expect(page.getByText("Refused", { exact: true })).toBeVisible();
await expect(page.getByText(/refused and locked/i)).toBeVisible();
await expect(page.getByRole("button", { name: "Accept Dispatch" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Refuse Dispatch" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Submit Signoff" })).toHaveCount(0);
});
test("covers the refuse dialog at a mobile viewport", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await mockPortalApi(page);
await page.goto(`/v/${PORTAL_TOKEN}/dashboard`);
await page.getByText("DSP-101 — Emergency boiler repair").click();
await expect(page).toHaveURL(/\/dispatch\/101$/);
await page.getByRole("button", { name: "Refuse Dispatch" }).click();
const dialog = page.getByRole("dialog", { name: "Refuse dispatch" });
await expect(dialog).toBeVisible();
await page.keyboard.press("Escape");
await expect(dialog).toHaveCount(0);
await page.getByRole("button", { name: "Refuse Dispatch" }).click();
await page
.getByRole("dialog", { name: "Refuse dispatch" })
.getByRole("button", {
name: "Refuse dispatch",
})
.click();
await expect(page.getByText("Refused", { exact: true })).toBeVisible();
await expect(page.getByText(/refused and locked/i)).toBeVisible();
});
test("shows an expired-token state when the session is rejected", async ({ page }) => {
await mockPortalApi(page, { sessionError: true });
await page.goto(`/v/${PORTAL_TOKEN}/dashboard`);
await expect(page.getByRole("heading", { name: "Access Denied" })).toBeVisible();
await expect(page.getByRole("alert")).toContainText(/invalid or expired/i);
});
});

View file

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

261
e2e/vendors/vendors.visual.spec.ts vendored Normal file
View file

@ -0,0 +1,261 @@
import { expect, test, type Page, type Route } from "@playwright/test";
const TEST_USER = {
token: "vendor-visual-token",
expiration: "2030-01-01T00:00:00.000Z",
email: "admin@seahavenind.com",
userRoles: "Admin",
phoneNumber: "5551234567",
fullname: "Vendor Visual Admin",
id: "1",
};
const activeVendors = [
{
Id: 1,
CompanyId: 101,
CompanyName: "Gateway Plumbing",
ContactName: "Adam Whyte",
Email: "dispatch@gateway.test",
Phone: "314-555-0198",
CompanyPhone: "314-555-0100",
Address: "1 Market St",
City: "St. Louis",
State: "MO",
Zip: "63101",
TradeSpecialties: "Plumbing, Backflow Preventers",
GoogleMapsUrl: "https://maps.google.com/gateway",
Notes: "Preferred after-hours vendor",
TotalJobs: 72,
IsActive: true,
PreferredContact: "Email",
},
{
Id: 2,
CompanyId: 102,
CompanyName: "Metro HVAC",
ContactName: "Maya Chen",
Email: "maya@metro.test",
Phone: "314-555-0112",
CompanyPhone: "314-555-0110",
Address: "2 Pine St",
City: "Clayton",
State: "MO",
Zip: "63105",
TradeSpecialties: "HVAC",
GoogleMapsUrl: "",
Notes: "",
TotalJobs: 18,
IsActive: true,
},
];
const inactiveVendors = [
{
...activeVendors[0],
Id: 3,
CompanyId: 103,
CompanyName: "Drain Cleaning Bros",
ContactName: "Drew Banks",
TotalJobs: 151,
IsActive: false,
},
];
async function fulfillJson(route: Route, body: unknown, status = 200) {
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
}
async function mockVendorApi(page: Page, mode: "default" | "empty" | "error" = "default") {
await page.clock.setFixedTime(new Date("2026-08-10T12:00:00.000Z"));
await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER);
await page.route("**/api/**", async (route) => {
const request = route.request();
const url = new URL(request.url());
if (!url.pathname.startsWith("/api/")) {
await route.continue();
return;
}
if (url.pathname.includes("/DropdownOptions/ByCategory/Trade")) {
await fulfillJson(route, {
data: [
{ id: 1, value: "Plumbing" },
{ id: 2, value: "HVAC" },
],
});
return;
}
if (url.pathname.endsWith("/api/Vendor/facets")) {
await fulfillJson(route, {
data: {
companies: activeVendors.map((vendor) => ({
companyId: vendor.CompanyId,
name: vendor.CompanyName,
})),
trades: ["Plumbing", "Backflow Preventers", "HVAC"],
locations: [
{ city: "St. Louis", state: "MO", label: "St. Louis, MO" },
{ city: "Clayton", state: "MO", label: "Clayton, MO" },
],
jobBuckets: [
{ id: "under-50", label: "Under 50" },
{ id: "50-99", label: "50–99" },
{ id: "100-149", label: "100–149" },
{ id: "150-plus", label: "150+" },
],
},
});
return;
}
if (url.pathname.endsWith("/api/Vendor/GetVendorList")) {
const isCount = url.searchParams.get("pageSize") === "1";
if (mode === "error" && !isCount) {
await fulfillJson(route, { message: "Vendor directory unavailable" }, 500);
return;
}
const source =
mode === "empty"
? []
: url.searchParams.get("isActive") === "false"
? inactiveVendors
: activeVendors;
await fulfillJson(route, {
Data: isCount ? source.slice(0, 1) : source,
TotalCount: source.length,
PageNumber: 1,
PageSize: Number(url.searchParams.get("pageSize") ?? 12),
});
return;
}
if (url.pathname.startsWith("/api/vendor-company-roster")) {
await fulfillJson(route, {
companyId: 101,
rowVersion: "rv-visual",
name: "Gateway Plumbing",
companyPhone: "314-555-0100",
email: "dispatch@gateway.test",
address: "1 Market St",
city: "St. Louis",
state: "MO",
zip: "63101",
googleMapsUrl: "https://maps.google.com/gateway",
notes: "Preferred after-hours vendor",
technicians: [
{
id: 1,
contactName: "Adam Whyte",
phone: "314-555-0198",
email: "dispatch@gateway.test",
preferredContact: "Email",
tradeSpecialties: "Plumbing, Backflow Preventers",
isActive: true,
totalJobs: 72,
},
],
});
return;
}
await fulfillJson(route, { data: {} });
});
}
async function openVendorPage(page: Page, mode: "default" | "empty" | "error" = "default") {
await mockVendorApi(page, mode);
await page.goto("/vendors");
await page.evaluate(() => document.fonts.ready);
await page.addStyleTag({
content: `
*, *::before, *::after { transition: none !important; animation: none !important; }
.MuiDrawer-paper { transform: none !important; }
.Toastify__toast-container { display: none !important; }
.tsqd-parent-container { display: none !important; }
`,
});
}
async function expectStableScreenshot(page: Page, name: string) {
await page.waitForTimeout(250);
await page.evaluate(
() =>
new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
),
);
await page.screenshot({ animations: "disabled", caret: "hide" });
await expect(page).toHaveScreenshot(name);
}
test.describe("Vendor deterministic pixel regression", () => {
test("list", async ({ page }) => {
await openVendorPage(page);
await expect(page.getByText("Gateway Plumbing")).toBeVisible();
await expectStableScreenshot(page, "vendor-list.png");
});
test("filter", async ({ page }) => {
await openVendorPage(page);
await page.getByRole("button", { name: "Open vendor filters" }).click();
await expect(page.getByRole("heading", { name: "Filters" })).toBeVisible();
await expectStableScreenshot(page, "vendor-filter.png");
});
test("add", async ({ page }) => {
await openVendorPage(page);
await page.getByRole("button", { name: "Add Vendor", exact: true }).click();
await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toBeVisible();
await expectStableScreenshot(page, "vendor-add.png");
});
test("detail", async ({ page }) => {
await openVendorPage(page);
await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click();
await expect(page.getByRole("button", { name: "Close drawer" })).toBeVisible();
await expectStableScreenshot(page, "vendor-detail.png");
});
test("edit", async ({ page }) => {
await openVendorPage(page);
await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click();
await expect(page.getByRole("button", { name: "Save changes" })).toBeVisible();
await expectStableScreenshot(page, "vendor-edit.png");
});
test("inactive", async ({ page }) => {
await openVendorPage(page);
await page.getByRole("tab", { name: "Inactive (1)" }).click();
await expect(page.getByText("Drain Cleaning Bros")).toBeVisible();
await expectStableScreenshot(page, "vendor-inactive.png");
});
test("empty", async ({ page }) => {
await openVendorPage(page, "empty");
await expect(page.getByText("No active technicians found")).toBeVisible();
await expectStableScreenshot(page, "vendor-empty.png");
});
test("error", async ({ page }) => {
await openVendorPage(page, "error");
await expect(page.getByRole("main").getByRole("alert")).toContainText(
/server error|vendor directory unavailable/i,
);
await expectStableScreenshot(page, "vendor-error.png");
});
test("mobile", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await openVendorPage(page);
await expect(page.getByText("Gateway Plumbing")).toBeVisible();
await expectStableScreenshot(page, "vendor-mobile.png");
await page.getByRole("button", { name: "Toggle navigation" }).click();
await expect(page.getByRole("navigation", { name: "Primary navigation" })).toBeVisible();
await expectStableScreenshot(page, "vendor-mobile-navigation.png");
});
});

26
package-lock.json generated
View file

@ -4048,9 +4048,9 @@
"license": "MIT"
},
"node_modules/fast-uri": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz",
"integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
"integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
"dev": true,
"funding": [
{
@ -4578,9 +4578,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"dev": true,
"funding": [
{
@ -5302,9 +5302,9 @@
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"funding": [
{
"type": "github",
@ -5561,9 +5561,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.18",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.18.tgz",
"integrity": "sha512-xdB1oSLHbz1vRWgCDalrCqEFTWzFlhqFC5tIHLMOSUIjhm3XXQ1qrFy8S/ESr1JYRRXqM3c1QFiMZUJdUTqyMQ==",
"version": "8.5.26",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
"integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
"funding": [
{
"type": "opencollective",
@ -5580,7 +5580,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.12",
"nanoid": "^3.3.17",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},

View file

@ -10,6 +10,7 @@
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test",
"test:e2e:visual": "playwright test --config playwright.visual.config.ts",
"test:e2e:ui": "playwright test --ui",
"lint": "eslint . --max-warnings=0",
"lint:fix": "eslint . --fix --max-warnings=0",

View file

@ -2,6 +2,7 @@ import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
testIgnore: "**/*.visual.spec.ts",
fullyParallel: true,
forbidOnly: Boolean(process.env.CI),
retries: process.env.CI ? 2 : 0,

View file

@ -0,0 +1,43 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
testMatch: "**/*.visual.spec.ts",
fullyParallel: false,
forbidOnly: true,
retries: 0,
workers: 1,
reporter: [["list"], ["html", { outputFolder: "playwright-report-visual", open: "never" }]],
outputDir: "test-results/visual",
snapshotPathTemplate: "{testDir}/__screenshots__/{testFilePath}/{arg}{ext}",
expect: {
toHaveScreenshot: {
animations: "disabled",
caret: "hide",
// Keep the changed-pixel budget strict while ignoring sub-perceptual
// antialiasing differences between local and hosted container runtimes.
maxDiffPixels: 5,
threshold: 0.1,
},
},
use: {
...devices["Desktop Chrome"],
baseURL: "http://localhost:4174",
colorScheme: "light",
deviceScaleFactor: 1,
locale: "en-US",
launchOptions: {
args: ["--disable-gpu", "--font-render-hinting=none"],
},
reducedMotion: "reduce",
timezoneId: "UTC",
trace: "retain-on-failure",
viewport: { width: 1440, height: 1000 },
},
webServer: {
command: "npm run dev -- --port 4174 --strictPort",
url: "http://localhost:4174",
reuseExistingServer: false,
timeout: 120_000,
},
});

View file

@ -1,6 +1,6 @@
import { useState } from "react";
import { Outlet } from "react-router";
import { Box } from "@mui/material";
import { Box, useMediaQuery, useTheme } from "@mui/material";
import { ProtectedRoute } from "@/components/auth/protected-route";
import { AppSidebar } from "@/components/layout/app-sidebar";
@ -8,19 +8,36 @@ import { getSidebarWidth } from "@/config/menu";
import { AppTopbar } from "@/components/layout/app-topbar";
export default function AdminShellLayout() {
const theme = useTheme();
const isDesktop = useMediaQuery(theme.breakpoints.up("md"));
const [navCollapsed, setNavCollapsed] = useState(false);
const sidebarWidth = getSidebarWidth(navCollapsed);
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const sidebarWidth = isDesktop ? getSidebarWidth(navCollapsed) : 0;
const handleToggleNav = () => {
if (isDesktop) {
setNavCollapsed((value) => !value);
return;
}
setMobileNavOpen((value) => !value);
};
return (
<ProtectedRoute>
<Box className="flex h-screen w-full overflow-hidden bg-background">
<AppSidebar collapsed={navCollapsed} />
<AppSidebar
collapsed={isDesktop ? navCollapsed : false}
mobile={!isDesktop}
open={isDesktop || mobileNavOpen}
onClose={() => setMobileNavOpen(false)}
/>
<Box
className="flex h-screen min-w-0 flex-1 flex-col overflow-hidden"
sx={{ width: `calc(100% - ${sidebarWidth}px)` }}
>
<AppTopbar onToggleNav={() => setNavCollapsed((value) => !value)} />
<AppTopbar onToggleNav={handleToggleNav} />
<Box
component="main"

View file

@ -27,28 +27,319 @@ import {
Tooltip,
Typography,
} from "@mui/material";
import { upliftsApi } from "@/domain/uplifts/api/uplifts-api";
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
import { useApproveUplift, useDenyUplift } from "@/domain/uplifts/use-cases/use-uplift-actions";
import { UPLIFT_STATUSES } from "@/domain/uplifts/types/uplift";
import {
useApproveUplift,
useRejectUplift,
useRequestChangesUplift,
} from "@/domain/uplifts/use-cases/use-uplift-actions";
import { useUpliftsQueue } from "@/domain/uplifts/use-cases/use-uplifts-queue";
import { formatDateTime } from "@/lib/time-utils";
import { toast } from "react-toastify";
const STATUS_OPTIONS = ["Pending", "Approved", "Denied", "Cancelled", ""];
const STATUS_OPTIONS = ["", ...UPLIFT_STATUSES];
type UpliftActionType = "approve" | "reject" | "request-changes";
type UpliftDecision = { type: UpliftActionType; item: UpliftQueueItem };
function formatMoney(value: number | null | undefined): string {
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<{
type: "approve" | "deny";
item: UpliftQueueItem;
} | null>(null);
const [upliftAction, setUpliftAction] = useState<UpliftDecision | null>(null);
const [note, setNote] = useState("");
const listParams = useMemo(
@ -58,39 +349,59 @@ export default function UpliftQueuePage() {
const { data, isLoading, isFetching, error, refetch } = useUpliftsQueue(listParams);
const approveUplift = useApproveUplift();
const denyUplift = useDenyUplift();
const rejectUplift = useRejectUplift();
const requestChangesUplift = useRequestChangesUplift();
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: upliftAction.item.id, note: note || undefined },
{
onSuccess: () => {
setUpliftAction(null);
setNote("");
void refetch();
},
},
);
approveUplift.mutate({ id, note: note || undefined }, { onSuccess: onDone });
} else if (upliftAction.type === "reject") {
rejectUplift.mutate({ id, note: note.trim() }, { onSuccess: onDone });
} else {
if (!note.trim()) return;
denyUplift.mutate(
{ id: upliftAction.item.id, note: note.trim() },
{
onSuccess: () => {
setUpliftAction(null);
setNote("");
void refetch();
},
},
);
requestChangesUplift.mutate({ id, note: note.trim() }, { onSuccess: onDone });
}
};
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
@ -101,177 +412,26 @@ export default function UpliftQueuePage() {
justifyContent: "space-between",
}}
>
<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>
<UpliftQueueHeader totalCount={totalCount} isFetching={isFetching} isLoading={isLoading} />
<UpliftQueueFilters
status={status}
tier={tier}
onStatusChange={handleStatusChange}
onTierChange={handleTierChange}
/>
</Stack>
{Boolean(error) && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load uplift queue"}
</Alert>
)}
<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>
<UpliftQueueTable
isLoading={isLoading}
rows={rows}
onViewWorkOrder={(r) => navigate(`/workorders/${r.workOrderId}`)}
onDownloadEvidence={(r) => void downloadEvidence(r)}
onDecide={handleDecide}
/>
<TablePagination
component="div"
count={totalCount}
@ -280,46 +440,15 @@ export default function UpliftQueuePage() {
rowsPerPage={pageSize}
rowsPerPageOptions={[25]}
/>
<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>
<UpliftDecisionDialog
action={upliftAction}
note={note}
noteRequired={noteRequired}
decisionPending={decisionPending}
onNoteChange={setNote}
onClose={closeDialog}
onConfirm={handleConfirm}
/>
</Box>
);
}

View file

@ -1,90 +1,82 @@
import { useState, type ReactNode } from "react";
import { useState } from "react";
import { useNavigate, useParams } from "react-router";
import {
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 { 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 { formatDateTime, toDateInputValue } from "@/lib/time-utils";
import {
useApproveUplift,
useRejectUplift,
useRequestChangesUplift,
} from "@/domain/uplifts/use-cases/use-uplift-actions";
import { useVendorPoDetail } from "@/domain/vendor-pos/use-cases/use-vendor-po-detail";
import {
PoDetailsPaper,
SitePaper,
UpliftDecisionDialog,
UpliftHistoryPaper,
VendorSummaryPaper,
WorkOrdersPaper,
type UpliftActionType,
type VendorPoUpliftDecision,
} from "@/app/(protected)/vendor-pos/_components/vendor-po-sections";
function formatMoney(value: number | null | undefined): string {
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 denyUplift = useDenyUplift(id);
const rejectUplift = useRejectUplift(id);
const requestChangesUplift = useRequestChangesUplift(id);
const [upliftAction, setUpliftAction] = useState<{
type: "approve" | "deny";
uplift: UpliftRequest;
} | null>(null);
const [upliftAction, setUpliftAction] = useState<VendorPoUpliftDecision | 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: upliftAction.uplift.id, note: note || undefined },
{
onSuccess: () => {
setUpliftAction(null);
setNote("");
void refetch();
},
},
);
approveUplift.mutate({ id: upliftId, note: note || undefined }, { onSuccess: onDone });
} else if (upliftAction.type === "reject") {
rejectUplift.mutate({ id: upliftId, note: note.trim() }, { onSuccess: onDone });
} else {
if (!note.trim()) return;
denyUplift.mutate(
{ id: upliftAction.uplift.id, note: note.trim() },
{
onSuccess: () => {
setUpliftAction(null);
setNote("");
void refetch();
},
},
);
requestChangesUplift.mutate({ id: upliftId, note: note.trim() }, { onSuccess: onDone });
}
};
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">
@ -141,212 +133,29 @@ export default function VendorPoViewPage() {
</Stack>
<Stack spacing={3}>
<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>
<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}
/>
</Stack>
<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>
<UpliftDecisionDialog
action={upliftAction}
note={note}
noteRequired={noteRequired}
decisionPending={decisionPending}
onNoteChange={setNote}
onClose={closeUpliftDialog}
onConfirm={handleUpliftConfirm}
/>
</Box>
);
}

View file

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

View file

@ -1,39 +1,21 @@
import { useState } from "react";
import { useNavigate, useParams } from "react-router";
import { 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 { 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";
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";
export default function WorkOrderViewPage() {
const { id } = useParams();
const navigate = useNavigate();
const workOrderId = id ?? "";
const view = useWorkOrderViewPage(workOrderId);
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) {
if (view.isLoading) {
return (
<Box className="flex justify-center p-12">
<CircularProgress />
@ -41,11 +23,11 @@ export default function WorkOrderViewPage() {
);
}
if (error || !wo) {
if (view.error || !view.wo) {
return (
<Box className="p-4">
<Alert severity="error">
{error instanceof Error ? error.message : "Work order not found."}
{view.error instanceof Error ? view.error.message : "Work order not found."}
</Alert>
</Box>
);
@ -53,56 +35,67 @@ export default function WorkOrderViewPage() {
return (
<Box className="flex flex-col gap-4 p-4">
<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
<WorkOrderViewHeader
wo={view.wo}
workOrderId={workOrderId}
comments={wo.comments ?? []}
auditLog={wo.auditLog ?? []}
statusAnchor={view.statusAnchor}
onStatusAnchorChange={view.setStatusAnchor}
onStatusChange={(status) => view.statusMutation.mutate(status)}
onOpenDispatch={() => view.setDispatchOpen(true)}
/>
{dispatchOpen && (
<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 && (
<DispatchCreateModal
workOrder={wo}
onClose={() => setDispatchOpen(false)}
onDispatched={() => void refetch()}
workOrder={view.wo}
onClose={() => view.setDispatchOpen(false)}
onDispatched={() => void view.refetch()}
/>
)}
{dispatchDetailId != null && (
{view.dispatchDetailId != null && (
<DispatchDetailModal
dispatchId={dispatchDetailId}
dispatchId={view.dispatchDetailId}
workOrderId={workOrderId}
onClose={() => setDispatchDetailId(null)}
onUpdated={() => void refetch()}
onClose={() => view.setDispatchDetailId(null)}
onUpdated={() => void view.refetch()}
/>
)}
<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}
<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}
/>
</Box>
);

View file

@ -1,82 +1,178 @@
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;
onDeny: (uplift: UpliftRequest) => void;
onReject: (uplift: UpliftRequest) => void;
onRequestChanges: (uplift: UpliftRequest) => void;
};
const ACTION_LABELS: Record<Exclude<UpliftAction, "approve">, string> = {
reject: "Reject",
"request-changes": "Request Changes",
};
function actionColor(action: UpliftAction): "success" | "error" | "primary" {
if (action === "approve") return "success";
if (action === "reject") return "error";
return "primary";
}
function actionVariant(action: UpliftAction): "contained" | "outlined" {
return action === "request-changes" ? "outlined" : "contained";
}
type UpliftCardProps = Omit<DispatchUpliftsSectionProps, "uplifts"> & {
uplift: UpliftRequest;
};
function UpliftCard({ uplift, isLocked, onApprove, onDeny }: UpliftCardProps) {
const canDecide = uplift.canDecide && uplift.status === "Pending" && !isLocked;
type UpliftCardActionsProps = Pick<
UpliftCardProps,
"isLocked" | "onApprove" | "onReject" | "onRequestChanges"
> & {
uplift: UpliftRequest;
};
function downloadUpliftEvidence(uplift: UpliftRequest) {
return upliftsApi
.downloadEvidence(uplift.id, uplift.evidenceFileName || "uplift-evidence")
.catch((err: unknown) => {
toast.error(err instanceof Error ? err.message : "Unable to download evidence.");
});
}
function UpliftCardHeader({ uplift }: { uplift: UpliftRequest }) {
const evidenceDocumentId = uplift.evidenceDocumentId;
return (
<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.delta).toFixed(2)})
${Number(uplift.currentNTE ?? 0).toFixed(2)} → ${Number(uplift.requestedNTE).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>
)}
{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` : ""}
>
<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>
</>
);
}
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))}
>
{ACTION_LABELS[action]}
</Button>
</span>
</Tooltip>
))}
</Stack>
);
}
function UpliftCard({ uplift, isLocked, onApprove, onReject, onRequestChanges }: UpliftCardProps) {
return (
<Box className="rounded border border-border p-3">
<UpliftCardHeader uplift={uplift} />
<UpliftCardDecisionSummary uplift={uplift} />
<UpliftCardActions
uplift={uplift}
isLocked={isLocked}
onApprove={onApprove}
onReject={onReject}
onRequestChanges={onRequestChanges}
/>
</Box>
);
}
@ -85,7 +181,8 @@ export function DispatchUpliftsSection({
uplifts,
isLocked,
onApprove,
onDeny,
onReject,
onRequestChanges,
}: DispatchUpliftsSectionProps) {
return (
<Box>
@ -102,7 +199,8 @@ export function DispatchUpliftsSection({
uplift={uplift}
isLocked={isLocked}
onApprove={onApprove}
onDeny={onDeny}
onReject={onReject}
onRequestChanges={onRequestChanges}
/>
))}
</Stack>

View file

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

View file

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

View file

@ -0,0 +1,56 @@
import type { MouseEvent } from "react";
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
import { MiniCalendar } from "@/app/(protected)/workorders/_components/wizard/mini-calendar";
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
type DueDateCalendarDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
value: string;
onSave: (iso: string) => void;
};
/**
* Emergency Due Date picker — dedicated calendar Dialog (no time, no week/range).
* Reuses the shared MiniCalendar used by the standard Due Date popover.
*/
export function DueDateCalendarDialog({
open,
onOpenChange,
value,
onSave,
}: DueDateCalendarDialogProps) {
return (
<Dialog
open={open}
onClose={() => onOpenChange(false)}
maxWidth={false}
slotProps={{
...dialogBlurBackdropSlotProps,
paper: {
className: "w-full",
style: { borderRadius: 12, maxWidth: 320 },
onClick: (e: MouseEvent<HTMLDivElement>) => e.stopPropagation(),
},
}}
>
<DialogContent className="p-5" onClick={(e) => e.stopPropagation()}>
<DialogTitle
className="mb-3 p-0"
style={{ fontSize: 16, color: "var(--color-text-primary)" }}
>
Due Date
</DialogTitle>
<div className="flex justify-center">
<MiniCalendar
value={value}
onChange={(iso) => {
onSave(iso);
onOpenChange(false);
}}
/>
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,71 @@
import { useState } from "react";
import { Calendar as CalendarIcon, ChevronDown } from "lucide-react";
import { DateCell } from "@/app/(protected)/workorders/_components/list/table/cells/date-cell";
import { DueDateCalendarDialog } from "@/app/(protected)/workorders/_components/list/table/cells/due-date-calendar-dialog";
import { HighlightText } from "@/app/(protected)/workorders/_components/list/table/cells/highlight-text";
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
type DueDateCellProps = {
type: WOType | "";
value: string;
q?: string;
highlight?: boolean;
onSave: (date: string) => void;
};
/**
* Board / slide-over Due Date editor.
* Emergency → dedicated calendar Dialog; other types → standard DateCell popover.
*/
export function DueDateCell({ type, value, q, highlight, onSave }: DueDateCellProps) {
const [open, setOpen] = useState(false);
if (type !== "Emergency") {
return <DateCell value={value} q={q} highlight={highlight} onSave={onSave} />;
}
return (
<>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setOpen(true);
}}
className="group/cell flex w-full items-center whitespace-nowrap rounded transition-opacity hover:opacity-80"
style={
highlight
? {
boxShadow: "0 0 0 2px var(--color-accent)",
background: "var(--color-primary-tint)",
padding: "2px 8px",
margin: "0 -8px",
}
: undefined
}
aria-haspopup="dialog"
>
<span className="flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden">
<CalendarIcon
size={11}
className="shrink-0"
style={{ color: highlight ? "var(--primary)" : "var(--color-text-muted)" }}
/>
{value ? (
<span className="truncate text-[13px] text-[var(--color-text-secondary)]">
<HighlightText text={value.replace(/^\d{4}-/, "")} q={q} />
</span>
) : (
<span className="text-[13px] text-[var(--color-text-muted)]">—</span>
)}
</span>
<ChevronDown
size={14}
className="ml-2 shrink-0 opacity-0 transition-opacity group-hover/cell:opacity-100"
style={{ color: "var(--muted-foreground)" }}
/>
</button>
<DueDateCalendarDialog open={open} onOpenChange={setOpen} value={value} onSave={onSave} />
</>
);
}

View file

@ -0,0 +1,36 @@
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
/**
* Board rows often have tech/company but no vendorId — resolve via vendors list.
* Prefers a stable vendor id over name+company matching.
*/
export function resolveTechnicianKey(
vendorId: string | number,
tech: string,
company: string,
vendors: VendorDropdownItem[],
): string {
const idStr = vendorId !== "" && vendorId != null ? String(vendorId) : "";
if (idStr && vendors.some((vendor) => String(vendor.id) === idStr)) {
return idStr;
}
if (idStr && vendors.length === 0) {
return idStr;
}
const name = tech.trim();
if (!name) {
return idStr;
}
const byNameAndCompany =
vendors.find((vendor) => vendor.contactName === name && vendor.companyName === company) ??
vendors.find((vendor) => vendor.contactName === name);
if (byNameAndCompany) {
return String(byNameAndCompany.id);
}
// Orphan tech string used as option key when no vendor row matches (legacy board cells).
return idStr || name;
}

View file

@ -0,0 +1,53 @@
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
type ResolveVendorCompanyAnchorInput = {
company: string;
resolvedCompany: string;
selectedVendor: VendorDropdownItem | undefined;
vendors: VendorDropdownItem[];
};
export function resolveVendorCompanyAnchor({
company,
resolvedCompany,
selectedVendor,
vendors,
}: ResolveVendorCompanyAnchorInput): {
companyName: string;
companyVendor: VendorDropdownItem | null;
} {
const companyName = resolvedCompany || company;
if (!companyName) {
return { companyName: "", companyVendor: null };
}
const companyVendor =
selectedVendor ??
vendors.find((vendor) => vendor.companyName === companyName) ??
({
id: "",
companyName,
contactName: "",
tradeSpecialties: "",
address: "",
distanceMiles: null,
} satisfies VendorDropdownItem);
return { companyName, companyVendor };
}
export function mergeVendorDropdownItems(
vendors: VendorDropdownItem[],
addedVendors: VendorDropdownItem[],
): VendorDropdownItem[] {
const byId = new Map<string, VendorDropdownItem>();
for (const vendor of vendors) {
if (vendor.id === "") continue;
byId.set(String(vendor.id), vendor);
}
for (const vendor of addedVendors) {
if (vendor.id === "") continue;
byId.set(String(vendor.id), vendor);
}
return Array.from(byId.values());
}

View file

@ -49,7 +49,7 @@ export function StatusCell({
setAnchorEl(e.currentTarget);
}}
title="This WO missed its service window. Reschedule to unlock status updates."
title="This WO missed its deadline. Update the Due Date to unlock status updates."
className="group/cell flex w-full cursor-pointer items-center gap-1.5 whitespace-nowrap leading-none transition-opacity hover:opacity-80"
style={{ fontSize: 12.5, fontWeight: 600, color: "var(--status-pastdue-text)" }}
>
@ -83,8 +83,7 @@ export function StatusCell({
color: "var(--status-pending-text)",
}}
>
This work order is past due. A new date must be scheduled before the status can be
updated.
This work order is past due. Update the Due Date before the status can be updated.
</Text>
<button
@ -98,7 +97,7 @@ export function StatusCell({
}}
className="mt-2 block text-[11px] font-medium text-[var(--color-primary)]"
>
Reschedule →
Update Due Date →
</button>
</div>
</div>

View file

@ -0,0 +1,75 @@
import { isDraftVendorId } from "@/domain/vendors/utils/draft-vendor";
import { vendorToDropdownItem } from "@/domain/vendors/utils/vendor-to-dropdown-item";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
import type { VendorPatch } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types";
import type { useCreateVendor } from "@/domain/vendors/use-cases/use-create-vendor";
type UseVendorDialogSaveArgs = {
allVendors: VendorDropdownItem[];
pendingCreates: Record<string, VendorFormValues>;
selectedKey: string;
selectedVendor: VendorDropdownItem | undefined;
resolvedCompany: string;
anchorCompanyName: string;
tech: string;
phone: string;
createVendor: ReturnType<typeof useCreateVendor>;
onSave: (patch: VendorPatch) => void;
onOpenChange: (open: boolean) => void;
setSaveError: (error: string | null) => void;
};
export function useVendorDialogSave({
allVendors,
pendingCreates,
selectedKey,
selectedVendor,
resolvedCompany,
anchorCompanyName,
tech,
phone,
createVendor,
onSave,
onOpenChange,
setSaveError,
}: UseVendorDialogSaveArgs) {
const resolveSaveVendor = () =>
allVendors.find((vendor) => String(vendor.id) === selectedKey) ??
allVendors.find(
(vendor) =>
vendor.contactName === tech &&
vendor.companyName === (resolvedCompany || anchorCompanyName),
) ??
selectedVendor;
const handleSave = async () => {
setSaveError(null);
let saveVendor = resolveSaveVendor();
const pendingPayload = saveVendor ? pendingCreates[String(saveVendor.id)] : undefined;
if (saveVendor != null && isDraftVendorId(saveVendor.id) && pendingPayload) {
try {
const created = await createVendor.mutateAsync({
...pendingPayload,
phone: phone.trim() || pendingPayload.phone,
});
saveVendor = vendorToDropdownItem(created);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to create technician";
setSaveError(message);
return;
}
}
onSave({
vendorId: saveVendor ? String(saveVendor.id) : "",
company: resolvedCompany || anchorCompanyName,
tech: saveVendor?.contactName ?? tech,
techPhone: phone,
});
onOpenChange(false);
};
return { handleSave };
}

View file

@ -5,6 +5,7 @@ import { HoverChevron } from "./hover-chevron";
import { VendorDialog, type VendorPatch } from "./vendor-dialog";
type VendorCellProps = {
vendorId?: string | number;
company: string;
tech: string;
techPhone?: string;
@ -13,7 +14,15 @@ type VendorCellProps = {
onSave: (patch: VendorPatch) => void;
};
export function VendorCell({ company, tech, techPhone, vendors, q, onSave }: VendorCellProps) {
export function VendorCell({
vendorId = "",
company,
tech,
techPhone,
vendors,
q,
onSave,
}: VendorCellProps) {
const [open, setOpen] = useState(false);
const hasAny = !!(tech || company);
@ -46,6 +55,7 @@ export function VendorCell({ company, tech, techPhone, vendors, q, onSave }: Ven
<VendorDialog
open={open}
onOpenChange={setOpen}
vendorId={vendorId}
company={company}
tech={tech}
techPhone={techPhone}

View file

@ -0,0 +1,134 @@
import { useState } from "react";
import { Plus } from "lucide-react";
import {
DLG_INPUT_CLS,
DLG_INPUT_STYLE,
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
import { Button } from "@/components/ui/button";
import { Text } from "@/components/ui/text";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import { vendorSchema, type VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
import { buildTechnicianCreatePayload } from "@/domain/vendors/utils/build-technician-create-payload";
import { createDraftVendorDropdownItem } from "@/domain/vendors/utils/draft-vendor";
import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label";
type VendorDialogAddTechnicianProps = {
companyName: string;
companyVendor: VendorDropdownItem;
onCreated: (vendor: VendorDropdownItem, phone: string, payload: VendorFormValues) => void;
};
export function VendorDialogAddTechnician({
companyName,
companyVendor,
onCreated,
}: VendorDialogAddTechnicianProps) {
const [expanded, setExpanded] = useState(false);
const [contactName, setContactName] = useState("");
const [phone, setPhone] = useState("");
const [error, setError] = useState<string | null>(null);
const resetForm = () => {
setContactName("");
setPhone("");
setError(null);
};
const handleAdd = () => {
const payload = buildTechnicianCreatePayload({
companyVendor,
contactName,
phone,
});
const parsed = vendorSchema.safeParse(payload);
if (!parsed.success) {
const fieldErrors = parsed.error.flatten().fieldErrors;
const message =
fieldErrors.contactName?.[0] ??
fieldErrors.phone?.[0] ??
fieldErrors.companyName?.[0] ??
"Enter a valid technician name";
setError(message);
return;
}
setError(null);
const draftItem = createDraftVendorDropdownItem(parsed.data);
onCreated(draftItem, parsed.data.phone || phone.trim(), parsed.data);
resetForm();
setExpanded(false);
};
if (!expanded) {
return (
<button
type="button"
onClick={() => setExpanded(true)}
className="flex items-center gap-1.5 text-[12px] font-medium transition-opacity hover:opacity-80"
style={{ color: "var(--color-primary)" }}
>
<Plus size={12} className="shrink-0" aria-hidden />+ Add technician
</button>
);
}
return (
<div
className="space-y-2 rounded-lg p-3"
style={{
background: "var(--color-bg-muted)",
border: "1px solid var(--color-border)",
}}
>
<Text as="p" variant="caption" sx={{ fontSize: 12, color: "var(--muted-foreground)" }}>
New technician for {companyName}
</Text>
<div>
<FieldLabel>Technician Name</FieldLabel>
<input
autoFocus
value={contactName}
onChange={(e) => setContactName(e.target.value)}
placeholder="Full name"
className={DLG_INPUT_CLS}
style={DLG_INPUT_STYLE}
/>
</div>
<div>
<FieldLabel>Technician Phone</FieldLabel>
<input
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder="(000) 000-0000"
className={DLG_INPUT_CLS}
style={DLG_INPUT_STYLE}
/>
</div>
{Boolean(error) && (
<Text as="p" variant="caption" sx={{ fontSize: 11.5, color: "var(--color-destructive)" }}>
{error}
</Text>
)}
<div className="flex items-center justify-end gap-2 pt-1">
<Button
variant="ghost"
className="min-h-0 px-2 py-1 text-[12px]"
onClick={() => {
resetForm();
setExpanded(false);
}}
>
Cancel
</Button>
<Button
className="min-h-0 px-3 py-1 text-[12px]"
disabled={contactName.trim() === ""}
onClick={handleAdd}
>
Add
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,121 @@
import { Wrench } from "lucide-react";
import {
DLG_INPUT_CLS,
DLG_INPUT_STYLE,
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
import { Text } from "@/components/ui/text";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label";
import { VendorDialogAddTechnician } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-add-technician";
import { VendorDialogCompanyCard } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-company-card";
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
import type { TechnicianOption } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-options";
type VendorDialogBodyProps = {
selectedKey: string;
technicianOptions: TechnicianOption[];
anchorCompanyName: string;
companyVendorForAdd: VendorDropdownItem | undefined;
tech: string;
phone: string;
notes: string;
resolvedCompany: string;
selectedVendor: VendorDropdownItem | undefined;
saveError: string | null;
onPick: (key: string) => void;
onPhoneChange: (phone: string) => void;
onNotesChange: (notes: string) => void;
onTechnicianCreated: (
created: VendorDropdownItem,
createdPhone: string,
payload: VendorFormValues,
) => void;
};
export function VendorDialogBody({
selectedKey,
technicianOptions,
anchorCompanyName,
companyVendorForAdd,
tech,
phone,
notes,
resolvedCompany,
selectedVendor,
saveError,
onPick,
onPhoneChange,
onNotesChange,
onTechnicianCreated,
}: VendorDialogBodyProps) {
return (
<>
<div className="mt-3 space-y-3">
<div>
<FieldLabel>Technician</FieldLabel>
<WizardFieldSelect
value={selectedKey}
placeholder="Search technician …"
icon={
<Wrench size={14} className="shrink-0" style={{ color: "var(--color-text-muted)" }} />
}
searchPlaceholder="Search technician…"
options={technicianOptions.map((option) => ({
key: option.key,
label: option.label,
}))}
onPick={onPick}
/>
{companyVendorForAdd != null && (
<div className="mt-2">
<VendorDialogAddTechnician
companyName={anchorCompanyName}
companyVendor={companyVendorForAdd}
onCreated={onTechnicianCreated}
/>
</div>
)}
</div>
{Boolean(tech) && (
<div>
<FieldLabel>Technician Phone</FieldLabel>
<input
type="tel"
value={phone}
onChange={(e) => onPhoneChange(e.target.value)}
className={DLG_INPUT_CLS}
style={DLG_INPUT_STYLE}
/>
</div>
)}
{Boolean(resolvedCompany) && (
<div>
<FieldLabel>Company</FieldLabel>
<VendorDialogCompanyCard company={resolvedCompany} vendor={selectedVendor} />
</div>
)}
<div>
<FieldLabel>Notes</FieldLabel>
<textarea
value={notes}
onChange={(e) => onNotesChange(e.target.value)}
placeholder="Contact preferences, availability, etc."
rows={3}
className={`${DLG_INPUT_CLS} resize-none`}
style={{ ...DLG_INPUT_STYLE, fontFamily: "inherit" }}
/>
</div>
</div>
{Boolean(saveError) && (
<Text as="p" variant="caption" sx={{ fontSize: 11.5, color: "var(--color-destructive)" }}>
{saveError}
</Text>
)}
</>
);
}

View file

@ -0,0 +1,29 @@
import { Button } from "@/components/ui/button";
type VendorDialogFooterProps = {
isPending: boolean;
onCancel: () => void;
onSave: () => void;
};
export function VendorDialogFooter({ isPending, onCancel, onSave }: VendorDialogFooterProps) {
return (
<div className="mt-4 flex items-center justify-end gap-2">
<Button
variant="ghost"
className="min-h-0 px-2 py-1 text-[12px]"
disabled={isPending}
onClick={onCancel}
>
Cancel
</Button>
<Button
className="min-h-0 px-3 py-1 text-[12px]"
disabled={isPending}
onClick={() => void onSave()}
>
{isPending ? "Saving…" : "Save"}
</Button>
</div>
);
}

View file

@ -0,0 +1,6 @@
export type VendorPatch = {
vendorId: string;
company: string;
tech: string;
techPhone: string;
};

View file

@ -1,32 +1,30 @@
import { useEffect, useMemo, useState, type MouseEvent } from "react";
import { Wrench } from "lucide-react";
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
import {
DLG_INPUT_CLS,
DLG_INPUT_STYLE,
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
import { Button } from "@/components/ui/button";
import { Text } from "@/components/ui/text";
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label";
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
import { useCreateVendor } from "@/domain/vendors/use-cases/use-create-vendor";
import {
buildTechnicianOptions,
phoneForOption,
} from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-options";
import { VendorDialogCompanyCard } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-company-card";
import { VendorDialogBody } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-body";
import { VendorDialogFooter } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-footer";
import {
mergeVendorDropdownItems,
resolveVendorCompanyAnchor,
} from "@/app/(protected)/workorders/_components/list/table/cells/resolve-vendor-company-anchor";
import { resolveTechnicianKey } from "@/app/(protected)/workorders/_components/list/table/cells/resolve-technician-key";
import { useVendorDialogSave } from "@/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-save";
import type { VendorPatch } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types";
export type VendorPatch = {
vendorId: string;
company: string;
tech: string;
techPhone: string;
};
export type { VendorPatch } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types";
type VendorDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
vendorId?: string | number;
company: string;
tech: string;
techPhone?: string;
@ -37,43 +35,94 @@ type VendorDialogProps = {
export function VendorDialog({
open,
onOpenChange,
vendorId = "",
company,
tech,
techPhone = "",
vendors,
onSave,
}: VendorDialogProps) {
const createVendor = useCreateVendor();
const [selectedKey, setSelectedKey] = useState(() =>
resolveTechnicianKey(vendorId, tech, company, vendors),
);
const [t, setT] = useState(tech);
const [phone, setPhone] = useState(techPhone);
const [notes, setNotes] = useState("");
const [addedVendors, setAddedVendors] = useState<VendorDropdownItem[]>([]);
const [pendingCreates, setPendingCreates] = useState<Record<string, VendorFormValues>>({});
const [saveError, setSaveError] = useState<string | null>(null);
const allVendors = useMemo(
() => mergeVendorDropdownItems(vendors, addedVendors),
[vendors, addedVendors],
);
const technicianOptions = useMemo(
() => buildTechnicianOptions(vendors, tech, company),
[vendors, tech, company],
() => buildTechnicianOptions(allVendors, tech, company),
[allVendors, tech, company],
);
useEffect(() => {
if (open) {
const key = resolveTechnicianKey(vendorId, tech, company, vendors);
setSelectedKey(key);
setT(tech);
setPhone(techPhone || phoneForOption(tech, technicianOptions));
setPhone(techPhone || phoneForOption(tech, buildTechnicianOptions(vendors, tech, company)));
setNotes("");
setAddedVendors([]);
setPendingCreates({});
setSaveError(null);
}
}, [open, tech, techPhone, technicianOptions]);
// Intentionally omit technicianOptions: rebuilding options after Add must not clear staged drafts.
}, [open, tech, techPhone, vendors, company, vendorId]);
const selected =
technicianOptions.find((o) => o.name === t && (!company || o.company === company)) ??
technicianOptions.find((o) => o.name === t || o.key === t);
const selectedKey = selected?.key ?? "";
const resolvedCompany = t ? (selected?.company ?? company) : "";
const selectedVendor = selected?.vendor ?? vendors.find((v) => v.companyName === resolvedCompany);
const selected = technicianOptions.find((option) => option.key === selectedKey);
const resolvedCompany = selectedKey ? (selected?.company ?? company) : company;
const selectedVendor =
selected?.vendor ?? allVendors.find((vendor) => String(vendor.id) === selectedKey);
const { companyName: anchorCompanyName, companyVendor } = resolveVendorCompanyAnchor({
company,
resolvedCompany,
selectedVendor,
vendors: allVendors,
});
const handlePick = (key: string) => {
const option = technicianOptions.find((o) => o.key === key);
const name = option?.name ?? key;
setT(name);
setPhone(phoneForOption(name, technicianOptions));
setSelectedKey(key);
setT(option?.name ?? key);
setPhone(option?.phone || phoneForOption(option?.name ?? key, technicianOptions));
};
const handleTechnicianCreated = (
created: VendorDropdownItem,
createdPhone: string,
payload: VendorFormValues,
) => {
setAddedVendors((current) => [...current, created]);
setPendingCreates((current) => ({ ...current, [String(created.id)]: payload }));
setSelectedKey(String(created.id));
setT(created.contactName);
setPhone(createdPhone);
};
const { handleSave } = useVendorDialogSave({
allVendors,
pendingCreates,
selectedKey,
selectedVendor,
resolvedCompany,
anchorCompanyName,
tech: selected?.name ?? t,
phone,
createVendor,
onSave,
onOpenChange,
setSaveError,
});
return (
<Dialog
open={open}
@ -96,84 +145,28 @@ export function VendorDialog({
Search and select the technician
</Text>
<div className="mt-3 space-y-3">
<div>
<FieldLabel>Technician</FieldLabel>
<WizardFieldSelect
value={selectedKey}
placeholder="Search technician …"
icon={
<Wrench
size={14}
className="shrink-0"
style={{ color: "var(--color-text-muted)" }}
/>
}
searchPlaceholder="Search technician…"
options={technicianOptions.map((option) => ({
key: option.key,
label: option.label,
}))}
onPick={handlePick}
/>
</div>
<VendorDialogBody
selectedKey={selectedKey}
technicianOptions={technicianOptions}
anchorCompanyName={anchorCompanyName}
companyVendorForAdd={companyVendor ?? undefined}
tech={selected?.name ?? t}
phone={phone}
notes={notes}
resolvedCompany={resolvedCompany}
selectedVendor={selectedVendor}
saveError={saveError}
onPick={handlePick}
onPhoneChange={setPhone}
onNotesChange={setNotes}
onTechnicianCreated={handleTechnicianCreated}
/>
{Boolean(t) && (
<div>
<FieldLabel>Technician Phone</FieldLabel>
<input
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
className={DLG_INPUT_CLS}
style={DLG_INPUT_STYLE}
/>
</div>
)}
{Boolean(t) && (
<div>
<FieldLabel>Company</FieldLabel>
<VendorDialogCompanyCard company={resolvedCompany} vendor={selectedVendor} />
</div>
)}
<div>
<FieldLabel>Notes</FieldLabel>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Contact preferences, availability, etc."
rows={3}
className={`${DLG_INPUT_CLS} resize-none`}
style={{ ...DLG_INPUT_STYLE, fontFamily: "inherit" }}
/>
</div>
</div>
<div className="mt-4 flex items-center justify-end gap-2">
<Button
variant="ghost"
className="min-h-0 px-2 py-1 text-[12px]"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button
className="min-h-0 px-3 py-1 text-[12px]"
onClick={() => {
onSave({
vendorId: selectedVendor ? String(selectedVendor.id) : "",
company: resolvedCompany,
tech: t,
techPhone: phone,
});
onOpenChange(false);
}}
>
Save
</Button>
</div>
<VendorDialogFooter
isPending={createVendor.isPending}
onCancel={() => onOpenChange(false)}
onSave={handleSave}
/>
</DialogContent>
</Dialog>
);

View file

@ -7,6 +7,7 @@ import type { WorkOrderTableDraft } from "@/domain/work-orders/types/work-order-
import { AssignedToCell } from "./cells/assigned-to-cell";
import { ApptTimeCell } from "./cells/appt-time-cell";
import { DateCell } from "./cells/date-cell";
import { DueDateCell } from "./cells/due-date-cell";
import { EditableWONumberCell } from "./cells/editable-wo-number-cell";
import { PMTypeCell } from "./cells/pm-type-cell";
import { SiteCell } from "./cells/site-cell";
@ -106,7 +107,11 @@ export function WoTableInlineRowCells({
</td>
<td className="group/cell" style={{ padding: "0 14px" }}>
<DateCell value={draft.dueDate} onSave={(date) => onPatch({ dueDate: date })} />
<DueDateCell
type={draft.type}
value={draft.dueDate}
onSave={(date) => onPatch({ dueDate: date })}
/>
</td>
<td className="group/cell" style={{ padding: "0 14px" }}>
@ -129,6 +134,7 @@ export function WoTableInlineRowCells({
<td className="group/cell" style={{ padding: "0 14px" }}>
<VendorCell
vendorId={draft.vendorId}
company={draft.company}
tech={draft.tech}
techPhone={draft.techPhone}

View file

@ -3,6 +3,7 @@ import type { LocationOption, WorkOrderUser } from "@/domain/work-orders/types/w
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { AssignedToCell } from "./cells/assigned-to-cell";
import { DateCell } from "./cells/date-cell";
import { DueDateCell } from "./cells/due-date-cell";
import { EditableWONumberCell } from "./cells/editable-wo-number-cell";
import { SiteCell } from "./cells/site-cell";
import { TypeCell } from "./cells/type-cell";
@ -16,6 +17,7 @@ type WoTableRowCellsProps = {
sitePlace?: { city: string; state: string };
search?: string;
forceScheduleHighlight?: boolean;
forceDueDateHighlight?: boolean;
mutationCellStyle?: CSSProperties;
handlers: WoTableRowHandlers;
onPatchRow: (patch: Partial<WorkOrderTableRow>) => void;
@ -29,6 +31,7 @@ export function WoTableRowCells({
sitePlace,
search,
forceScheduleHighlight,
forceDueDateHighlight,
mutationCellStyle,
handlers,
onPatchRow,
@ -104,7 +107,7 @@ export function WoTableRowCells({
<DateCell
value={row.scheduledOn}
q={search}
highlight={forceScheduleHighlight || row.isPastDue}
highlight={forceScheduleHighlight}
allowWeekMode
targetWeek={row.targetWeek}
onSave={(date) => handlers.onScheduleDate(row.id, date)}
@ -116,7 +119,13 @@ export function WoTableRowCells({
style={{ padding: "0 14px", ...mutationCellStyle }}
onClick={(e) => e.stopPropagation()}
>
<DateCell value={row.dueDate} q={search} onSave={(date) => onPatchRow({ dueDate: date })} />
<DueDateCell
type={row.type}
value={row.dueDate}
q={search}
highlight={forceDueDateHighlight || row.isPastDue}
onSave={(date) => onPatchRow({ dueDate: date })}
/>
</td>
</>
);

View file

@ -57,6 +57,7 @@ export function WoTableRowServiceCells({
onClick={(e) => e.stopPropagation()}
>
<VendorCell
vendorId={row.vendorId}
company={row.company}
tech={row.tech}
techPhone={row.techPhone}

View file

@ -45,6 +45,7 @@ type WoTableRowProps = {
pmTypes: string[];
search?: string;
forceScheduleHighlight?: boolean;
forceDueDateHighlight?: boolean;
isSelected: boolean;
isFlashing: boolean;
dragViz: DragVizState;
@ -61,6 +62,7 @@ export function WoTableRow({
pmTypes,
search,
forceScheduleHighlight,
forceDueDateHighlight,
isSelected,
isFlashing,
dragViz,
@ -154,6 +156,7 @@ export function WoTableRow({
sitePlace={sitePlace}
search={search}
forceScheduleHighlight={forceScheduleHighlight}
forceDueDateHighlight={forceDueDateHighlight}
mutationCellStyle={mutationCellStyle}
handlers={handlers}
onPatchRow={patchRow}

View file

@ -54,7 +54,7 @@ export function WoTable({
onOpenAdvancedFilters,
onPatch,
}: WoTableProps) {
const [scheduleHighlightId, setScheduleHighlightId] = useState<string | number | null>(null);
const [dueDateHighlightId, setDueDateHighlightId] = useState<string | number | null>(null);
const { data: locationSites = [] } = useLocationSites();
const sitePlaceLookups = useMemo(() => buildSitePlaceLookups(locationSites), [locationSites]);
const createMutation = useCreateWorkOrderFromWizard(() => {
@ -69,7 +69,7 @@ export function WoTable({
onOpenCompDoc,
onOpenMedia,
onPatch,
onScheduleHighlight: setScheduleHighlightId,
onScheduleHighlight: setDueDateHighlightId,
});
const renderRow = (row: WorkOrderTableRow, forceScheduleHighlight = false) => (
@ -83,7 +83,8 @@ export function WoTable({
vendors={vendors}
pmTypes={pmTypes}
search={tableData.search}
forceScheduleHighlight={forceScheduleHighlight || scheduleHighlightId === row.id}
forceScheduleHighlight={forceScheduleHighlight}
forceDueDateHighlight={dueDateHighlightId === row.id}
isSelected={tableData.selectedId === row.id}
isFlashing={tableData.flashId === row.id}
dragViz={tableData.dragViz}

View file

@ -0,0 +1,107 @@
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>
);
}

View file

@ -0,0 +1,38 @@
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>
);
}

View file

@ -0,0 +1,12 @@
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>
);
}

View file

@ -0,0 +1,113 @@
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>
);
}

View file

@ -0,0 +1,114 @@
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>
);
}

View file

@ -0,0 +1,8 @@
export interface VendorEditorState {
vendorId: string | number;
vendorName: string;
contactName: string;
phone: string;
email: string;
notes: string;
}

View file

@ -0,0 +1,16 @@
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
);
}

View file

@ -0,0 +1,66 @@
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>
</>
);
}

View file

@ -0,0 +1,73 @@
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>
);
}

View file

@ -0,0 +1,95 @@
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>
</>
);
}

View file

@ -0,0 +1,69 @@
import { useState } from "react";
import { CalendarIcon, ChevronDown } from "lucide-react";
import { DueDateCalendarDialog } from "@/app/(protected)/workorders/_components/list/table/cells/due-date-calendar-dialog";
import { WizardDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-date-field";
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
import { fmtDate } from "@/domain/work-orders/utils/wizard-date-utils";
type WizardDueDateFieldProps = {
type: WOType | "";
value: string;
placeholder: string;
onChange: (iso: string) => void;
};
/**
* Wizard Due Date editor.
* Emergency → dedicated calendar Dialog; other types → WizardDateField popover.
*/
export function WizardDueDateField({
type,
value,
placeholder,
onChange,
}: WizardDueDateFieldProps) {
const [open, setOpen] = useState(false);
if (type !== "Emergency") {
return <WizardDateField value={value} placeholder={placeholder} onChange={onChange} />;
}
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className="flex w-full items-center gap-2 transition-colors"
style={{
height: 36,
border: "1px solid var(--color-border)",
borderRadius: 6,
background: "var(--color-bg-surface)",
padding: "0 10px",
outline: "none",
}}
onFocus={(e) => {
e.currentTarget.style.boxShadow = "0 0 0 2px var(--color-primary)";
}}
onBlur={(e) => {
e.currentTarget.style.boxShadow = "none";
}}
aria-haspopup="dialog"
>
<CalendarIcon size={16} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
<span
className="flex-1 truncate text-left"
style={{
fontFamily: "var(--font-sans)",
fontSize: 13,
color: value ? "var(--foreground)" : "var(--muted-foreground)",
}}
>
{value ? fmtDate(value) : placeholder}
</span>
<ChevronDown size={13} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
</button>
<DueDateCalendarDialog open={open} onOpenChange={setOpen} value={value} onSave={onChange} />
</>
);
}

View file

@ -3,6 +3,7 @@ import { Text } from "@/components/ui/text";
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
import { WO_TYPE_META, WO_TYPES, type WOType } from "@/domain/work-orders/types/work-order-wizard";
import { WizardDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-date-field";
import { WizardDueDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-due-date-field";
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
import {
DLG_INPUT_CLS,
@ -197,7 +198,8 @@ export function WizardStepTypeSchedule({
>
Due Date
</WizLabelRow>
<WizardDateField
<WizardDueDateField
type={draft.type}
value={draft.dueDate}
placeholder="No due date"
onChange={(iso) => onUpdate("dueDate", iso)}

View file

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

View file

@ -0,0 +1,149 @@
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,
};
}

View file

@ -8,36 +8,18 @@ import {
formatVendorPortalDateTime,
vendorPortalStatusClass,
} from "@/domain/vendor-portal/lib/status-helpers";
import type { VendorPortalDispatchSummary } from "@/domain/vendor-portal/types/vendor-portal";
import {
VENDOR_PORTAL_DISPATCH_FILTERS,
applyVendorPortalDispatchFilter,
getVendorPortalDispatchFilter,
type VendorPortalDispatchFilterKey,
} from "@/domain/vendor-portal/lib/dispatch-filters";
import { queryKeys } from "@/infra/query-key/query-key";
const FILTERS = [
{
key: "open",
label: "Open",
match: (dispatch: VendorPortalDispatchSummary) =>
dispatch.status !== "Completed" &&
dispatch.status !== "Verified" &&
dispatch.status !== "Cancelled",
},
{
key: "completed",
label: "Completed",
match: (dispatch: VendorPortalDispatchSummary) =>
dispatch.status === "Completed" || dispatch.status === "Verified",
},
{
key: "cancelled",
label: "Cancelled",
match: (dispatch: VendorPortalDispatchSummary) => dispatch.status === "Cancelled",
},
{ key: "all", label: "All", match: () => true },
] as const;
export default function VendorPortalDashboardPage() {
const { token } = useVendorPortal();
const navigate = useNavigate();
const [filter, setFilter] = useState<(typeof FILTERS)[number]["key"]>("open");
const [filter, setFilter] = useState<VendorPortalDispatchFilterKey>("open");
const {
data: dispatches = [],
@ -49,8 +31,11 @@ export default function VendorPortalDashboardPage() {
enabled: Boolean(token),
});
const activeFilter = FILTERS.find((item) => item.key === filter) ?? FILTERS[0];
const filtered = useMemo(() => dispatches.filter(activeFilter.match), [dispatches, activeFilter]);
const activeFilter = getVendorPortalDispatchFilter(filter);
const filtered = useMemo(
() => applyVendorPortalDispatchFilter(dispatches, activeFilter),
[dispatches, activeFilter],
);
return (
<>
@ -60,7 +45,7 @@ export default function VendorPortalDashboardPage() {
Active dispatches and work orders assigned to your company.
</div>
<div className="vp-actions" style={{ marginTop: 0 }}>
{FILTERS.map((item) => (
{VENDOR_PORTAL_DISPATCH_FILTERS.map((item) => (
<button
key={item.key}
type="button"

View file

@ -4,8 +4,10 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Text } from "@/components/ui/text";
import { DispatchDetailSections } from "@/app/v/[token]/dispatch/_components/dispatch-detail-sections";
import { DispatchSummaryCard } from "@/app/v/[token]/dispatch/_components/dispatch-summary-card";
import { RefuseDispatchDialog } from "@/app/v/[token]/dispatch/_components/refuse-dispatch-dialog";
import { useVendorPortal } from "@/app/v/_components/vendor-portal-context";
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
import { isVendorPortalLockedStatus } from "@/domain/vendor-portal/lib/status-helpers";
import type { VendorPortalDispatchDetail } from "@/domain/vendor-portal/types/vendor-portal";
import { queryKeys } from "@/infra/query-key/query-key";
@ -16,6 +18,7 @@ export default function VendorPortalDispatchPage() {
const queryClient = useQueryClient();
const [busy, setBusy] = useState(false);
const [actionError, setActionError] = useState("");
const [refuseOpen, setRefuseOpen] = useState(false);
const { data, isLoading, error, refetch } = useQuery({
queryKey: queryKeys.vendorPortal.dispatch(token, dispatchId),
@ -28,6 +31,11 @@ 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("");
@ -74,7 +82,7 @@ export default function VendorPortalDispatchPage() {
if (!data) return null;
const locked = data.status === "Verified" || data.status === "Cancelled";
const locked = isVendorPortalLockedStatus(data.status);
return (
<>
@ -84,6 +92,7 @@ export default function VendorPortalDispatchPage() {
locked={locked}
busy={busy}
onAccept={() => void runAction(() => vendorPortalApi.accept(token, dispatchId))}
onRefuse={() => setRefuseOpen(true)}
onStart={() =>
void runAction(() => vendorPortalApi.changeStatus(token, dispatchId, "In Progress"))
}
@ -105,8 +114,16 @@ export default function VendorPortalDispatchPage() {
data={data}
locked={locked}
refresh={refresh}
pollEvidenceDocument={pollEvidenceDocument}
updateLocal={updateLocal}
/>
<RefuseDispatchDialog
open={refuseOpen}
dispatchId={dispatchId}
onClose={() => setRefuseOpen(false)}
onRefused={() => setActionError("")}
/>
</>
);
}

View file

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

View file

@ -0,0 +1,166 @@
import { Text } from "@/components/ui/text";
import { formatVendorPortalDateTime } from "@/domain/vendor-portal/lib/status-helpers";
import type { VendorPortalDispatchDetail } from "@/domain/vendor-portal/types/vendor-portal";
type DispatchSnapshotDetailsProps = {
data: VendorPortalDispatchDetail;
locked: boolean;
};
function resolveCanonicalService(workOrder: VendorPortalDispatchDetail["workOrder"]): string {
if (workOrder?.service) {
return workOrder.service;
}
return [workOrder?.trade, workOrder?.subTrade].filter(Boolean).join(" · ");
}
function DispatchCommercialDetails({ data }: Pick<DispatchSnapshotDetailsProps, "data">) {
const service = resolveCanonicalService(data.workOrder);
return (
<div style={{ marginTop: 12 }}>
{data.workOrder != null && Boolean(data.workOrder.internalWONumber) && (
<div className="vp-row">
<strong>WO #</strong>
<span>{data.workOrder.internalWONumber}</span>
</div>
)}
{Boolean(service) && (
<div className="vp-row">
<strong>Service</strong>
<span>{service}</span>
</div>
)}
{Boolean(data.poNumber) && (
<div className="vp-row">
<strong>PO #</strong>
<span>{data.poNumber}</span>
</div>
)}
{data.nteAmount != null && (
<div className="vp-row">
<strong>NTE</strong>
<span>${Number(data.nteAmount).toFixed(2)}</span>
</div>
)}
<div className="vp-row">
<strong>Invoice</strong>
<span>
{data.invoiceStatus || "Not Submitted"}
{data.invoiceNumber ? ` · ${data.invoiceNumber}` : ""}
</span>
</div>
<div className="vp-row">
<strong>Payment</strong>
<span>{data.paymentStatus || "Unavailable"}</span>
</div>
{Boolean(data.scheduledDate) && (
<div className="vp-row">
<strong>Scheduled</strong>
<span>{formatVendorPortalDateTime(data.scheduledDate)}</span>
</div>
)}
{Boolean(data.statusUpdatedAt) && (
<div className="vp-row">
<strong>Status updated</strong>
<span>{formatVendorPortalDateTime(data.statusUpdatedAt)}</span>
</div>
)}
</div>
);
}
function DispatchLocationDetails({ data }: Pick<DispatchSnapshotDetailsProps, "data">) {
if (data.location == null) {
return null;
}
const address = [
data.location.address,
data.location.city,
data.location.state,
data.location.zipCode,
]
.filter(Boolean)
.join(", ");
return (
<>
<Text variant="heading" sx={{ marginTop: 2 }}>
Location
</Text>
{Boolean(data.workOrder?.siteCode) && (
<div className="vp-row">
<strong>Site code</strong>
<span>{data.workOrder?.siteCode}</span>
</div>
)}
<div>{data.location.name}</div>
<div className="vp-muted">{address}</div>
</>
);
}
function DispatcherContactDetails({ data }: Pick<DispatchSnapshotDetailsProps, "data">) {
const contact = data.dispatcherContact;
if (!contact || (!contact.name && !contact.email && !contact.phone)) {
return null;
}
return (
<>
<Text variant="heading" sx={{ marginTop: 2 }}>
Dispatcher
</Text>
{Boolean(contact.name) && (
<div className="vp-row">
<strong>Name</strong>
<span>{contact.name}</span>
</div>
)}
{Boolean(contact.email) && (
<div className="vp-row">
<strong>Email</strong>
<span>
<a href={`mailto:${contact.email}`}>{contact.email}</a>
</span>
</div>
)}
{Boolean(contact.phone) && (
<div className="vp-row">
<strong>Phone</strong>
<span>
<a href={`tel:${contact.phone}`}>{contact.phone}</a>
</span>
</div>
)}
</>
);
}
export function DispatchSnapshotDetails({ data, locked }: DispatchSnapshotDetailsProps) {
const description = data.description || data.workOrder?.description;
return (
<>
<DispatchCommercialDetails data={data} />
<DispatchLocationDetails data={data} />
<DispatcherContactDetails data={data} />
{locked && (
<Text variant="feedback" className="vp-muted" sx={{ marginTop: 2 }}>
This dispatch is {data.status.toLowerCase()} and locked to further changes. Comments
remain open.
</Text>
)}
{Boolean(description) && (
<>
<Text variant="heading" sx={{ marginTop: 2 }}>
Description
</Text>
<div>{description}</div>
</>
)}
</>
);
}

View file

@ -3,6 +3,7 @@ type DispatchStatusActionsProps = {
locked: boolean;
busy: boolean;
onAccept: () => void;
onRefuse: () => void;
onStart: () => void;
onComplete: () => void;
onRequestCancel: () => void;
@ -13,6 +14,7 @@ export function DispatchStatusActions({
locked,
busy,
onAccept,
onRefuse,
onStart,
onComplete,
onRequestCancel,
@ -20,9 +22,19 @@ export function DispatchStatusActions({
return (
<div className="vp-actions">
{status === "Sent" && (
<button type="button" className="vp-btn vp-btn-success" disabled={busy} onClick={onAccept}>
Accept Dispatch
</button>
<>
<button
type="button"
className="vp-btn vp-btn-success"
disabled={busy}
onClick={onAccept}
>
Accept Dispatch
</button>
<button type="button" className="vp-btn vp-btn-warn" disabled={busy} onClick={onRefuse}>
Refuse Dispatch
</button>
</>
)}
{status === "Acknowledged" && (
<button type="button" className="vp-btn vp-btn-primary" disabled={busy} onClick={onStart}>

View file

@ -1,10 +1,8 @@
import { Link } from "react-router";
import { Text } from "@/components/ui/text";
import {
formatVendorPortalDateTime,
vendorPortalStatusClass,
} from "@/domain/vendor-portal/lib/status-helpers";
import { vendorPortalStatusClass } from "@/domain/vendor-portal/lib/status-helpers";
import type { VendorPortalDispatchDetail } from "@/domain/vendor-portal/types/vendor-portal";
import { DispatchSnapshotDetails } from "@/app/v/[token]/dispatch/_components/dispatch-snapshot-details";
import { DispatchStatusActions } from "@/app/v/[token]/dispatch/_components/dispatch-status-actions";
type DispatchSummaryCardProps = {
@ -13,6 +11,7 @@ type DispatchSummaryCardProps = {
locked: boolean;
busy: boolean;
onAccept: () => void;
onRefuse: () => void;
onStart: () => void;
onComplete: () => void;
onRequestCancel: () => void;
@ -24,6 +23,7 @@ export function DispatchSummaryCard({
locked,
busy,
onAccept,
onRefuse,
onStart,
onComplete,
onRequestCancel,
@ -43,77 +43,14 @@ export function DispatchSummaryCard({
<span className={vendorPortalStatusClass(data.status)}>{data.status}</span>
</div>
<div style={{ marginTop: 12 }}>
{data.workOrder != null && Boolean(data.workOrder.internalWONumber) && (
<div className="vp-row">
<strong>WO #</strong>
<span>{data.workOrder.internalWONumber}</span>
</div>
)}
{Boolean(data.poNumber) && (
<div className="vp-row">
<strong>PO #</strong>
<span>{data.poNumber}</span>
</div>
)}
{data.nteAmount != null && (
<div className="vp-row">
<strong>NTE</strong>
<span>${Number(data.nteAmount).toFixed(2)}</span>
</div>
)}
<div className="vp-row">
<strong>Invoice</strong>
<span>
{data.invoiceStatus || "Not Submitted"}
{data.invoiceNumber ? ` · ${data.invoiceNumber}` : ""}
</span>
</div>
<div className="vp-row">
<strong>Payment</strong>
<span>{data.paymentStatus || "Unavailable"}</span>
</div>
{Boolean(data.scheduledDate) && (
<div className="vp-row">
<strong>Scheduled</strong>
<span>{formatVendorPortalDateTime(data.scheduledDate)}</span>
</div>
)}
</div>
{data.location != null && (
<>
<Text variant="heading" sx={{ marginTop: 2 }}>
Location
</Text>
<div>{data.location.name}</div>
<div className="vp-muted">
{[
data.location.address,
data.location.city,
data.location.state,
data.location.zipCode,
]
.filter(Boolean)
.join(", ")}
</div>
</>
)}
{Boolean(data.description || data.workOrder?.description) && (
<>
<Text variant="heading" sx={{ marginTop: 2 }}>
Description
</Text>
<div>{data.description || data.workOrder?.description}</div>
</>
)}
<DispatchSnapshotDetails data={data} locked={locked} />
<DispatchStatusActions
status={data.status}
locked={locked}
busy={busy}
onAccept={onAccept}
onRefuse={onRefuse}
onStart={onStart}
onComplete={onComplete}
onRequestCancel={onRequestCancel}

View file

@ -0,0 +1,141 @@
import { useEffect } from "react";
import { useForm, useWatch } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material";
import { Text } from "@/components/ui/text";
import { useVendorPortal } from "@/app/v/_components/vendor-portal-context";
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
import {
REFUSE_REASON_MAX_LENGTH,
refuseDispatchSchema,
type RefuseDispatchValues,
} from "@/domain/vendor-portal/schemas/refuse-dispatch-schema";
import { queryKeys } from "@/infra/query-key/query-key";
import { guardDialogClose } from "@/lib/dialog-guard";
type RefuseDispatchDialogProps = {
open: boolean;
dispatchId: number;
onClose: () => void;
onRefused: () => void;
};
export function RefuseDispatchDialog({
open,
dispatchId,
onClose,
onRefused,
}: RefuseDispatchDialogProps) {
const { token } = useVendorPortal();
const queryClient = useQueryClient();
const {
register,
handleSubmit,
control,
reset,
formState: { errors },
} = useForm<RefuseDispatchValues>({
resolver: zodResolver(refuseDispatchSchema),
mode: "onChange",
defaultValues: { reason: "" },
});
const reasonValue = useWatch({ control, name: "reason" }) ?? "";
const isOverLimit = reasonValue.length > REFUSE_REASON_MAX_LENGTH;
const refuseMutation = useMutation({
mutationFn: (values: RefuseDispatchValues) => {
const trimmed = values.reason.trim();
return vendorPortalApi.refuse(token, dispatchId, trimmed || undefined);
},
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({
queryKey: queryKeys.vendorPortal.dispatch(token, dispatchId),
}),
queryClient.invalidateQueries({
queryKey: queryKeys.vendorPortal.dispatches(token),
}),
]);
onRefused();
onClose();
},
});
const { reset: resetMutation } = refuseMutation;
useEffect(() => {
if (!open) {
reset({ reason: "" });
resetMutation();
}
}, [open, reset, resetMutation]);
const submit = handleSubmit((values) => refuseMutation.mutate(values));
const errorMessage = refuseMutation.error
? "Unable to refuse this dispatch. Please try again."
: "";
return (
<Dialog
open={open}
onClose={guardDialogClose(refuseMutation.isPending, onClose)}
aria-labelledby="refuse-dispatch-title"
>
<DialogTitle id="refuse-dispatch-title">Refuse dispatch</DialogTitle>
<form onSubmit={(event) => void submit(event)}>
<DialogContent>
<Text variant="body" className="vp-muted" sx={{ marginBottom: 2 }}>
Refusing ends your assignment for this dispatch. A reason is optional but helps your
dispatcher reassign the work.
</Text>
<label htmlFor="refuse-reason" className="vp-muted" style={{ fontSize: 13 }}>
Reason (optional)
</label>
<textarea
id="refuse-reason"
className="vp-textarea"
placeholder="Tell the dispatcher why you are refusing…"
aria-invalid={Boolean(errors.reason)}
aria-describedby={errors.reason ? "refuse-reason-error" : "refuse-reason-counter"}
maxLength={REFUSE_REASON_MAX_LENGTH}
{...register("reason")}
style={{ marginTop: 6 }}
/>
<div
id="refuse-reason-counter"
className="vp-muted"
style={{ fontSize: 12, textAlign: "right" }}
>
{reasonValue.length}/{REFUSE_REASON_MAX_LENGTH}
</div>
<Text variant="error" when={isOverLimit} id="refuse-reason-error">
{errors.reason?.message ?? "Reason must be 500 characters or fewer."}
</Text>
<Text variant="error" when={Boolean(errorMessage)}>
{errorMessage}
</Text>
</DialogContent>
<DialogActions>
<button
type="button"
className="vp-btn vp-btn-ghost"
onClick={onClose}
disabled={refuseMutation.isPending}
>
Cancel
</button>
<button
type="submit"
className="vp-btn vp-btn-warn"
disabled={refuseMutation.isPending || isOverLimit}
>
{refuseMutation.isPending ? "Refusing…" : "Refuse dispatch"}
</button>
</DialogActions>
</form>
</Dialog>
);
}

View file

@ -11,6 +11,7 @@ type SignoffSectionProps = {
existing: VendorPortalSignoff[];
signoffType: string;
nameLabel: string;
submissionLocked?: boolean;
onAdded: () => void;
};
@ -20,6 +21,7 @@ export function SignoffSection({
existing,
signoffType,
nameLabel,
submissionLocked = false,
onAdded,
}: SignoffSectionProps) {
const [name, setName] = useState("");
@ -50,6 +52,10 @@ export function SignoffSection({
);
}
if (submissionLocked) {
return <Text variant="feedback">Signoff is unavailable for this dispatch.</Text>;
}
const submit = async (event: React.FormEvent) => {
event.preventDefault();
const signature = method === "drawn" ? drawnSig : typedSig;

View file

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

View file

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

View file

@ -1,7 +1,20 @@
import { useState } from "react";
import { Text } from "@/components/ui/text";
import { useCallback, useState } from "react";
import { VendorPortalFeedback } from "@/app/v/_components/vendor-portal-feedback";
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
import type { VendorPortalUpliftRequest } from "@/domain/vendor-portal/types/vendor-portal";
import { createUpliftRequestKey } from "@/domain/vendor-portal/lib/request-key";
import type {
VendorPortalDocumentStatus,
VendorPortalUpliftRequest,
} from "@/domain/vendor-portal/types/vendor-portal";
import { UpliftRequestForm } from "@/app/v/[token]/dispatch/_components/uplift-request-form";
import type { UpliftFormValues } from "@/domain/vendor-portal/lib/uplift-validation";
import { UpliftRequestCard } from "@/app/v/[token]/dispatch/_components/uplift-request-card";
import {
useVendorUpliftEvidence,
type EvidenceScanState,
} from "@/app/v/[token]/dispatch/_components/use-uplift-evidence";
const ACTIVE_STATUSES = new Set(["Pending", "ChangesRequested"]);
type UpliftRequestsSectionProps = {
token: string;
@ -10,20 +23,141 @@ type UpliftRequestsSectionProps = {
upliftRequests: VendorPortalUpliftRequest[];
locked: boolean;
onChanged: () => Promise<void>;
pollDocument: (documentId: number) => Promise<VendorPortalDocumentStatus | undefined>;
};
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.";
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.";
}
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 "";
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,
};
}
export function UpliftRequestsSection({
@ -33,136 +167,56 @@ export function UpliftRequestsSection({
upliftRequests,
locked,
onChanged,
pollDocument,
}: UpliftRequestsSectionProps) {
const [requested, setRequested] = useState("");
const [reason, setReason] = useState("");
const [submitting, setSubmitting] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const evidence = useVendorUpliftEvidence({ pollDocument });
const {
submitting,
formError,
infoMessage,
requestError,
errorRequestId,
handleCreate,
handleWithdraw,
handleRevise,
} = useVendorUpliftActions({ token, dispatchId, currentNte, onChanged, evidence });
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.");
}
};
const activeRequest = upliftRequests.find((request) => ACTIVE_STATUSES.has(request.status));
const canShowForm = !locked && !activeRequest;
return (
<>
{upliftRequests.length === 0 && !pending && (
<div className="vp-muted" style={{ marginBottom: 12 }}>
{upliftRequests.length === 0 && !activeRequest && (
<VendorPortalFeedback>
No uplift requests yet. Use the form below if the work will exceed the current NTE.
</div>
</VendorPortalFeedback>
)}
{Boolean(infoMessage) && <VendorPortalFeedback>{infoMessage}</VendorPortalFeedback>}
{upliftRequests.map((request) => (
<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>
<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}
/>
))}
<Text variant="error" when={Boolean(errorMessage)}>
{errorMessage}
</Text>
{!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>
{canShowForm && (
<UpliftRequestForm
mode="create"
currentNte={currentNte}
submitting={submitting}
uploadingPercent={evidence.uploadingPercent}
errorMessage={formError}
onSubmit={(values) => void handleCreate(values)}
/>
)}
</>
);

View file

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

View file

@ -8,32 +8,14 @@ import {
formatVendorPortalDateTime,
vendorPortalStatusClass,
} from "@/domain/vendor-portal/lib/status-helpers";
import type { VendorPortalDispatchSummary } from "@/domain/vendor-portal/types/vendor-portal";
import {
VENDOR_PORTAL_DISPATCH_FILTERS,
applyVendorPortalDispatchFilter,
getVendorPortalDispatchFilter,
type VendorPortalDispatchFilterKey,
} from "@/domain/vendor-portal/lib/dispatch-filters";
import { queryKeys } from "@/infra/query-key/query-key";
const FILTERS = [
{
key: "open",
label: "Open",
match: (dispatch: VendorPortalDispatchSummary) =>
dispatch.status !== "Completed" &&
dispatch.status !== "Verified" &&
dispatch.status !== "Cancelled",
},
{
key: "completed",
label: "Completed",
match: (dispatch: VendorPortalDispatchSummary) =>
dispatch.status === "Completed" || dispatch.status === "Verified",
},
{
key: "cancelled",
label: "Cancelled",
match: (dispatch: VendorPortalDispatchSummary) => dispatch.status === "Cancelled",
},
{ key: "all", label: "All", match: () => true },
] as const;
function formatDate(value?: string) {
if (!value) return "—";
const date = new Date(value);
@ -49,7 +31,7 @@ function formatMoney(value?: number) {
export default function VendorPortalPosPage() {
const { token } = useVendorPortal();
const navigate = useNavigate();
const [filter, setFilter] = useState<(typeof FILTERS)[number]["key"]>("open");
const [filter, setFilter] = useState<VendorPortalDispatchFilterKey>("open");
const [search, setSearch] = useState("");
const {
@ -62,12 +44,11 @@ export default function VendorPortalPosPage() {
enabled: Boolean(token),
});
const activeFilter = FILTERS.find((item) => item.key === filter) ?? FILTERS[0];
const activeFilter = getVendorPortalDispatchFilter(filter);
const visible = useMemo(() => {
const needle = search.trim().toLowerCase();
return dispatches.filter((dispatch) => {
if (!activeFilter.match(dispatch)) return false;
return applyVendorPortalDispatchFilter(dispatches, activeFilter).filter((dispatch) => {
if (!needle) return true;
return (
(dispatch.poNumber || "").toLowerCase().includes(needle) ||
@ -90,7 +71,7 @@ export default function VendorPortalPosPage() {
className="vp-actions"
style={{ marginTop: 0, gap: 12, alignItems: "center", flexWrap: "wrap" }}
>
{FILTERS.map((item) => (
{VENDOR_PORTAL_DISPATCH_FILTERS.map((item) => (
<button
key={item.key}
type="button"

View file

@ -138,6 +138,10 @@
background: var(--doc-no-bg);
color: var(--doc-no-text);
}
.vp-status-refused {
background: var(--doc-no-bg);
color: var(--doc-no-text);
}
.vp-btn {
display: inline-block;
@ -296,6 +300,56 @@
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);

View file

@ -0,0 +1,44 @@
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>
);
}

View file

@ -0,0 +1,205 @@
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>
);
}

View file

@ -6,6 +6,7 @@ type CommentSource = {
commenter?: string | null;
createdDate?: string;
commenttext: string;
mentions?: string[];
};
export function mapWorkOrderCommentsToTimeline(comments: CommentSource[]): CommentTimelineItem[] {
@ -14,6 +15,7 @@ export function mapWorkOrderCommentsToTimeline(comments: CommentSource[]): Comme
author: comment.userName || comment.commenter || "System",
createdAt: comment.createdDate,
body: comment.commenttext,
mentions: comment.mentions,
}));
}

View file

@ -1,5 +1,8 @@
import { useMemo } from "react";
import { Box, Stack, Typography } from "@mui/material";
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 { formatDateTime } from "@/lib/time-utils";
export type CommentTimelineItem = {
@ -7,18 +10,20 @@ 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(
() =>
@ -31,7 +36,7 @@ export function CommentTimeline({
);
if (!sorted.length) {
return <Typography color="text.secondary">{emptyMessage}</Typography>;
return <Text tone="muted">{emptyMessage}</Text>;
}
return (
@ -39,14 +44,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" }}>
<Typography variant="caption" className="font-semibold">
<Text variant="caption" className="font-semibold">
{item.author}
</Typography>
<Typography variant="caption" color="text.secondary">
</Text>
<Text variant="caption" tone="muted">
{item.createdAt ? formatDateTime(item.createdAt) : ""}
</Typography>
</Text>
</Stack>
<Typography variant="body2">{item.body}</Typography>
<CommentBodyWithMentions text={item.body} mentions={item.mentions} users={mentionUsers} />
</Box>
))}
</Stack>

View file

@ -32,9 +32,10 @@ function getNavItemAppearance(isActive: boolean, isHover: boolean) {
type NavItemProps = NavItemDef & {
collapsed: boolean;
active: boolean;
onNavigate?: () => void;
};
function NavItem({ label, icon: Icon, path, collapsed, active, badge }: NavItemProps) {
function NavItem({ label, icon: Icon, path, collapsed, active, badge, onNavigate }: NavItemProps) {
const [hovered, setHovered] = useState(false);
const isHover = hovered && !active;
const { itemColor, iconColor, itemBg } = getNavItemAppearance(active, isHover);
@ -45,6 +46,7 @@ function NavItem({ label, icon: Icon, path, collapsed, active, badge }: NavItemP
aria-current={active ? "page" : undefined}
className="no-underline"
title={collapsed ? label : undefined}
onClick={onNavigate}
>
<Box
onMouseEnter={() => setHovered(true)}
@ -118,18 +120,24 @@ function NavItem({ label, icon: Icon, path, collapsed, active, badge }: NavItemP
type AppSidebarProps = {
collapsed: boolean;
mobile?: boolean;
open?: boolean;
onClose?: () => void;
};
export function AppSidebar({ collapsed }: AppSidebarProps) {
export function AppSidebar({ collapsed, mobile = false, open = true, onClose }: AppSidebarProps) {
const width = getSidebarWidth(collapsed);
const { pathname } = useLocation();
const activePath = getActiveNavPath(pathname);
return (
<Drawer
variant="permanent"
variant={mobile ? "temporary" : "permanent"}
open={open}
onClose={onClose}
ModalProps={{ keepMounted: true }}
sx={{
width,
width: mobile ? 0 : width,
flexShrink: 0,
"& .MuiDrawer-paper": {
width,
@ -157,7 +165,13 @@ export function AppSidebar({ collapsed }: AppSidebarProps) {
{collapsed ? <BrandMark size="compact" /> : <BrandLockup size="compact" />}
</Box>
<Box component="nav" className="flex-1 overflow-y-auto overflow-x-hidden" sx={{ py: 1 }}>
<Box
component="nav"
id="primary-navigation"
aria-label="Primary navigation"
className="flex-1 overflow-y-auto overflow-x-hidden"
sx={{ py: 1 }}
>
{navGroups.map((group, groupIndex) => (
<Box key={group.title || `group-${groupIndex}`}>
{collapsed ? (
@ -198,6 +212,7 @@ export function AppSidebar({ collapsed }: AppSidebarProps) {
badge={badge}
collapsed={collapsed}
active={path === activePath}
onNavigate={mobile ? onClose : undefined}
/>
</Box>
))}

View file

@ -6,6 +6,7 @@ import {
ClipboardList,
FileText,
LayoutDashboard,
MapPin,
Bell,
BarChart3,
Star,
@ -52,7 +53,10 @@ export const navGroups: NavGroup[] = [
},
{
title: "CUSTOMERS & SITES",
items: [{ label: "Accounts", icon: BriefcaseBusiness, path: "/accounts" }],
items: [
{ label: "Accounts", icon: BriefcaseBusiness, path: "/accounts" },
{ label: "Sites", icon: MapPin, path: "/locations" },
],
},
// {
// title: "INTERNAL",

View file

@ -1,9 +1,9 @@
import { HTTPError } from "ky";
import { API_PATHS } from "@/api/api-paths";
import { apiGet, apiPost } from "@/api/api";
import { api, apiGet, apiPost } from "@/api/api";
import { handleApiResponse } from "@/api/handle-api-response";
import { mapUpliftQueueItems } from "@/domain/uplifts/mappers/uplift-mapper";
import { mapUpliftQueueItems, mapUpliftRequests } from "@/domain/uplifts/mappers/uplift-mapper";
import type { UpliftsQueueParams, UpliftsQueueResult } from "@/domain/uplifts/types/uplift";
import { 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,6 +16,26 @@ 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, {
@ -57,7 +77,35 @@ export const upliftsApi = {
await apiPost<unknown>(`${API_PATHS.rest.uplifts}/${id}/approve`, { note });
},
deny: async (id: string | number, note: string): Promise<void> => {
await apiPost<unknown>(`${API_PATHS.rest.uplifts}/${id}/deny`, { 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);
},
};

View file

@ -1,5 +1,4 @@
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
import { mapUpliftRequest } from "@/domain/work-orders/mappers/work-order-mapper";
import type { UpliftQueueItem, UpliftRequest } from "@/domain/uplifts/types/uplift";
function asRecord(raw: unknown): Record<string, unknown> {
return typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
@ -14,6 +13,21 @@ function readString(record: Record<string, unknown>, ...keys: string[]): string
return "";
}
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[]
@ -25,6 +39,36 @@ 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);

View file

@ -1,3 +1,19 @@
export const UPLIFT_STATUSES = [
"Pending",
"Approved",
"Rejected",
"ChangesRequested",
"Withdrawn",
"Expired",
] as const;
export type UpliftStatus = (typeof UPLIFT_STATUSES)[number];
export const UPLIFT_DECISIONABLE_STATUSES = ["Pending"] as const;
export const UPLIFT_NOTIFICATION_STATUSES = ["Pending", "Sent", "Error"] as const;
export type UpliftNotificationStatus = (typeof UPLIFT_NOTIFICATION_STATUSES)[number];
export interface UpliftRequest {
id: string | number;
status: string;
@ -12,6 +28,13 @@ 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 {

View file

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

View file

@ -6,8 +6,12 @@ import type {
VendorPortalDispatchDetail,
VendorPortalDispatchSummary,
VendorPortalDocument,
VendorPortalDocumentStatus,
VendorPortalSession,
VendorPortalSignoff,
VendorUpliftRequestBody,
VendorUpliftRequestOutcome,
VendorUpliftReviseBody,
} from "@/domain/vendor-portal/types/vendor-portal";
function resolveVendorPortalUrl(path: string): string {
@ -15,11 +19,14 @@ 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;
};
@ -28,6 +35,7 @@ function uploadVendorDocument({
dispatchId,
file,
replacesDocumentId,
purpose,
onProgress,
}: UploadVendorDocumentOptions): Promise<VendorPortalDocument> {
return new Promise((resolve, reject) => {
@ -51,6 +59,7 @@ function uploadVendorDocument({
if (replacesDocumentId !== undefined) {
body.append("replacesDocumentId", String(replacesDocumentId));
}
body.append("purpose", purpose ?? "Completion");
request.send(body);
});
}
@ -106,6 +115,23 @@ 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),
@ -129,6 +155,16 @@ export const vendorPortalApi = {
method: "POST",
}),
refuse: (token: string, id: string | number, reason?: string) =>
vendorPortalCall<{ id: number; status: string; refusedAt?: string }>(
token,
`${API_PATHS.vendorPortal.dispatches}/${id}/refuse`,
{
method: "POST",
body: reason ? { reason } : {},
},
),
changeStatus: (token: string, id: string | number, status: string) =>
vendorPortalCall<void>(token, `${API_PATHS.vendorPortal.dispatches}/${id}/status`, {
method: "POST",
@ -176,23 +212,41 @@ export const vendorPortalApi = {
{ method: "POST", body: { commentText } },
),
requestUplift: (
token: string,
id: string | number,
payload: { requestedNTE: number; reason: string },
) =>
vendorPortalCall<void>(token, `${API_PATHS.vendorPortal.dispatches}/${id}/uplift-request`, {
method: "POST",
body: payload,
}),
requestUplift: (token: string, dispatchId: string | number, payload: VendorUpliftRequestBody) =>
vendorPortalCall<VendorUpliftRequestOutcome>(
token,
`${API_PATHS.vendorPortal.dispatches}/${dispatchId}/uplift-request`,
{
method: "POST",
body: payload,
},
).then(toVendorUpliftOutcome),
cancelUplift: (token: string, id: string | number, requestId: string | number) =>
withdrawUplift: (token: string, dispatchId: string | number, requestId: string | number) =>
vendorPortalCall<void>(
token,
`${API_PATHS.vendorPortal.dispatches}/${id}/uplift-request/${requestId}/cancel`,
`${API_PATHS.vendorPortal.dispatches}/${dispatchId}/uplift-request/${requestId}/withdraw`,
{ method: "POST" },
),
reviseUplift: (
token: string,
dispatchId: string | number,
requestId: string | number,
payload: VendorUpliftReviseBody,
) =>
vendorPortalCall<void>(
token,
`${API_PATHS.vendorPortal.dispatches}/${dispatchId}/uplift-request/${requestId}/revise`,
{ method: "POST", body: payload },
),
getDocumentStatus: (token: string, dispatchId: string | number, documentId: string | number) =>
vendorPortalCall<VendorPortalDocumentStatus>(
token,
`${API_PATHS.vendorPortal.dispatches}/${dispatchId}/documents/${documentId}/status`,
),
uploadDocument: uploadVendorDocument,
downloadDocument: downloadVendorDocument,
};

View file

@ -0,0 +1,71 @@
import type { VendorPortalDispatchSummary } from "@/domain/vendor-portal/types/vendor-portal";
import {
isVendorPortalTerminalStatus,
isVendorPortalUpcomingDispatch,
} from "@/domain/vendor-portal/lib/status-helpers";
export interface VendorPortalDispatchFilter {
key: VendorPortalDispatchFilterKey;
label: string;
match: (dispatch: VendorPortalDispatchSummary) => boolean;
sort?: (dispatches: VendorPortalDispatchSummary[]) => VendorPortalDispatchSummary[];
}
export type VendorPortalDispatchFilterKey =
"open" | "upcoming" | "completed" | "refused" | "cancelled" | "all";
const sortUpcomingByScheduledDateAscending = (
dispatches: VendorPortalDispatchSummary[],
): VendorPortalDispatchSummary[] =>
[...dispatches].sort((left, right) => {
const leftTime = left.scheduledDate ? new Date(left.scheduledDate).getTime() : 0;
const rightTime = right.scheduledDate ? new Date(right.scheduledDate).getTime() : 0;
return leftTime - rightTime;
});
export const VENDOR_PORTAL_DISPATCH_FILTERS: readonly VendorPortalDispatchFilter[] = [
{
key: "open",
label: "Open",
match: (dispatch) => !isVendorPortalTerminalStatus(dispatch.status),
},
{
key: "upcoming",
label: "Upcoming",
match: (dispatch) => isVendorPortalUpcomingDispatch(dispatch),
sort: sortUpcomingByScheduledDateAscending,
},
{
key: "completed",
label: "Completed",
match: (dispatch) => dispatch.status === "Completed" || dispatch.status === "Verified",
},
{
key: "refused",
label: "Refused",
match: (dispatch) => dispatch.status === "Refused",
},
{
key: "cancelled",
label: "Cancelled",
match: (dispatch) => dispatch.status === "Cancelled",
},
{ key: "all", label: "All", match: () => true },
] as const;
export function getVendorPortalDispatchFilter(
key: VendorPortalDispatchFilterKey,
): VendorPortalDispatchFilter {
return (
VENDOR_PORTAL_DISPATCH_FILTERS.find((filter) => filter.key === key) ??
VENDOR_PORTAL_DISPATCH_FILTERS[0]
);
}
export function applyVendorPortalDispatchFilter(
dispatches: VendorPortalDispatchSummary[],
filter: VendorPortalDispatchFilter,
): VendorPortalDispatchSummary[] {
const matched = dispatches.filter(filter.match);
return filter.sort ? filter.sort(matched) : matched;
}

View file

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

View file

@ -6,6 +6,7 @@ export function vendorPortalStatusClass(status: string): string {
Completed: "vp-status-completed",
Verified: "vp-status-verified",
Cancelled: "vp-status-cancelled",
Refused: "vp-status-refused",
};
return `vp-status-pill ${map[status] ?? ""}`;
@ -23,3 +24,34 @@ export function formatVendorPortalDateTime(value?: string): string {
return date.toLocaleString();
}
const TERMINAL_STATUSES = new Set(["Completed", "Verified", "Cancelled", "Refused"]);
const LOCKED_STATUSES = new Set(["Verified", "Cancelled", "Refused"]);
export function isVendorPortalTerminalStatus(status: string): boolean {
return TERMINAL_STATUSES.has(status);
}
export function isVendorPortalLockedStatus(status: string): boolean {
return LOCKED_STATUSES.has(status);
}
export function isVendorPortalUpcomingDispatch(
dispatch: { status: string; scheduledDate?: string },
now: Date = new Date(),
): boolean {
if (isVendorPortalTerminalStatus(dispatch.status)) {
return false;
}
if (!dispatch.scheduledDate) {
return false;
}
const scheduled = new Date(dispatch.scheduledDate);
if (Number.isNaN(scheduled.getTime())) {
return false;
}
return scheduled.getTime() >= now.getTime();
}

View file

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

View file

@ -0,0 +1,9 @@
import { z } from "zod";
export const REFUSE_REASON_MAX_LENGTH = 500;
export const refuseDispatchSchema = z.object({
reason: z.string().max(REFUSE_REASON_MAX_LENGTH, "Reason must be 500 characters or fewer."),
});
export type RefuseDispatchValues = z.infer<typeof refuseDispatchSchema>;

View file

@ -20,6 +20,7 @@ export interface VendorPortalDispatchSummary {
nteAmount?: number;
dispatchedAt?: string;
completedDate?: string;
refusedAt?: string;
}
export interface VendorPortalDocument {
@ -32,12 +33,18 @@ 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;
@ -64,6 +71,29 @@ 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 {
@ -73,15 +103,25 @@ export interface VendorPortalSignoff {
signedAt?: string;
}
export interface VendorPortalDispatcherContact {
name?: string;
email?: string;
phone?: string;
}
export interface VendorPortalDispatchDetail extends VendorPortalDispatchSummary {
description?: string;
acknowledgedAt?: string;
statusUpdatedAt?: string;
dispatcherContact?: VendorPortalDispatcherContact;
workOrder?: {
workerOrderTitle?: string;
internalWONumber?: string;
priority?: string;
trade?: string;
subTrade?: string;
siteCode?: string;
service?: string;
problem?: string;
description?: string;
};

View file

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

View file

@ -0,0 +1,36 @@
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
type BuildTechnicianCreatePayloadInput = {
companyVendor: Pick<VendorDropdownItem, "companyName" | "email" | "address" | "tradeSpecialties">;
contactName: string;
phone?: string;
companyId?: string | number | null;
};
/** Builds a vendor create payload for a new technician under an existing company. */
export function buildTechnicianCreatePayload({
companyVendor,
contactName,
phone = "",
companyId = null,
}: BuildTechnicianCreatePayloadInput): VendorFormValues {
return {
companyName: companyVendor.companyName,
contactName: contactName.trim(),
email: companyVendor.email ?? "",
phone: phone.trim(),
companyPhone: "",
googleMapsUrl: "",
notes: "",
totalJobs: 0,
address: companyVendor.address ?? "",
city: "",
state: "",
zip: "",
tradeSpecialties: companyVendor.tradeSpecialties ?? "",
isActive: true,
companyId,
preferredContact: "Phone",
};
}

View file

@ -0,0 +1,21 @@
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
export const DRAFT_VENDOR_ID_PREFIX = "draft:";
export function isDraftVendorId(id: string | number | null | undefined): boolean {
return String(id ?? "").startsWith(DRAFT_VENDOR_ID_PREFIX);
}
export function createDraftVendorDropdownItem(payload: VendorFormValues): VendorDropdownItem {
const email = (payload.email ?? "").trim();
return {
id: `${DRAFT_VENDOR_ID_PREFIX}${crypto.randomUUID()}`,
companyName: payload.companyName,
contactName: payload.contactName,
tradeSpecialties: payload.tradeSpecialties ?? "",
address: payload.address ?? "",
distanceMiles: null,
email: email || undefined,
};
}

View file

@ -0,0 +1,15 @@
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import type { Vendor } from "@/domain/vendors/types/vendor";
export function vendorToDropdownItem(vendor: Vendor): VendorDropdownItem {
const email = vendor.email.trim();
return {
id: vendor.id ?? "",
companyName: vendor.companyName,
contactName: vendor.contactName,
tradeSpecialties: vendor.tradeSpecialties,
address: vendor.address,
distanceMiles: null,
email: email || undefined,
};
}

View file

@ -20,6 +20,11 @@ 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();
@ -57,8 +62,17 @@ export const workOrderBoardDocumentsApi = {
return Array.isArray(items) ? items.map(mapBoardDetailComment) : [];
},
createComment: async (workOrderId: string | number, text: string): Promise<WorkOrderComment> => {
const data = await apiPost<unknown>(API_PATHS.workOrder.comments(workOrderId), { text });
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);
return mapBoardDetailComment(handleApiResponse(data));
},

View file

@ -61,6 +61,33 @@ 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";
@ -93,6 +120,7 @@ export function mapBoardDetailComment(raw: unknown): WorkOrderComment {
authorId,
commenter: readString(item, "commenter", "Commenter") || null,
recordType: readString(item, "recordType", "RecordType") || null,
mentions: readStringArray(item, "mentions", "Mentions"),
};
}

View file

@ -10,7 +10,6 @@ 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";
@ -215,6 +214,12 @@ 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),
@ -224,6 +229,7 @@ export function mapWorkOrderComment(raw: unknown): WorkOrderComment {
userName: readString(item, "userName", "UserName"),
commenter: readString(item, "commenter", "Commenter") || null,
recordType: readString(item, "recordType", "RecordType") || null,
mentions,
};
}
@ -455,27 +461,3 @@ 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);
}

View file

@ -94,6 +94,7 @@ export interface WorkOrderBoardDetailCommentApi {
text?: string | null;
time?: string | null;
documents?: unknown;
mentions?: string[] | null;
}
export interface WorkOrderBoardDetailAuditApi {

View file

@ -11,6 +11,8 @@ 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 {

View file

@ -38,8 +38,9 @@ function invalidateBoardDetail(
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.detail(workOrderId) });
}
interface AddWorkOrderCommentInput {
export interface AddWorkOrderCommentInput {
text: string;
mentions?: string[];
commentType?: "customer" | "internal";
}
@ -78,12 +79,12 @@ export interface AddDispatchSignoffInput {
signatureMethod: string;
}
interface ChangeWorkOrderAssignmentInput {
export interface ChangeWorkOrderAssignmentInput {
userId: string | number;
userName: string;
}
interface UpdateWorkOrderVendorInput {
export interface UpdateWorkOrderVendorInput {
vendorId: string | number;
payload: WorkOrderVendorUpdatePayload;
}
@ -157,8 +158,8 @@ export function useAddWorkOrderComment(
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ text }: AddWorkOrderCommentInput) =>
workOrdersApi.createComment(workOrderId, text),
mutationFn: ({ text, mentions }: AddWorkOrderCommentInput) =>
workOrdersApi.createComment(workOrderId, text, mentions),
onSuccess: () => {
invalidateBoardDetail(queryClient, workOrderId);
},

Some files were not shown because too many files have changed in this diff Show more