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 cache: npm
- run: npm ci - run: npm ci
- run: npm run verify - 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 /coverage
/test-results /test-results
/playwright-report /playwright-report
/playwright-report-visual
/blob-report /blob-report
/playwright/.cache /playwright/.cache

View file

@ -2,6 +2,7 @@ dist
build build
node_modules node_modules
coverage coverage
playwright-report-visual
package-lock.json package-lock.json
*.tsbuildinfo *.tsbuildinfo
@ -10,4 +11,4 @@ src/pages/**
src/**/*.js src/**/*.js
src/**/*.jsx src/**/*.jsx
src/**/*.css 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" "license": "MIT"
}, },
"node_modules/fast-uri": { "node_modules/fast-uri": {
"version": "3.1.4", "version": "3.1.5",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
"integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@ -4578,9 +4578,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/js-yaml": { "node_modules/js-yaml": {
"version": "4.3.0", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@ -5302,9 +5302,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.16", "version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@ -5561,9 +5561,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.18", "version": "8.5.26",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.18.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
"integrity": "sha512-xdB1oSLHbz1vRWgCDalrCqEFTWzFlhqFC5tIHLMOSUIjhm3XXQ1qrFy8S/ESr1JYRRXqM3c1QFiMZUJdUTqyMQ==", "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@ -5580,7 +5580,7 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"nanoid": "^3.3.12", "nanoid": "^3.3.17",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
"source-map-js": "^1.2.1" "source-map-js": "^1.2.1"
}, },

View file

@ -10,6 +10,7 @@
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"test:e2e:visual": "playwright test --config playwright.visual.config.ts",
"test:e2e:ui": "playwright test --ui", "test:e2e:ui": "playwright test --ui",
"lint": "eslint . --max-warnings=0", "lint": "eslint . --max-warnings=0",
"lint:fix": "eslint . --fix --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({ export default defineConfig({
testDir: "./e2e", testDir: "./e2e",
testIgnore: "**/*.visual.spec.ts",
fullyParallel: true, fullyParallel: true,
forbidOnly: Boolean(process.env.CI), forbidOnly: Boolean(process.env.CI),
retries: process.env.CI ? 2 : 0, 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 { useState } from "react";
import { Outlet } from "react-router"; 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 { ProtectedRoute } from "@/components/auth/protected-route";
import { AppSidebar } from "@/components/layout/app-sidebar"; import { AppSidebar } from "@/components/layout/app-sidebar";
@ -8,19 +8,36 @@ import { getSidebarWidth } from "@/config/menu";
import { AppTopbar } from "@/components/layout/app-topbar"; import { AppTopbar } from "@/components/layout/app-topbar";
export default function AdminShellLayout() { export default function AdminShellLayout() {
const theme = useTheme();
const isDesktop = useMediaQuery(theme.breakpoints.up("md"));
const [navCollapsed, setNavCollapsed] = useState(false); 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 ( return (
<ProtectedRoute> <ProtectedRoute>
<Box className="flex h-screen w-full overflow-hidden bg-background"> <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 <Box
className="flex h-screen min-w-0 flex-1 flex-col overflow-hidden" className="flex h-screen min-w-0 flex-1 flex-col overflow-hidden"
sx={{ width: `calc(100% - ${sidebarWidth}px)` }} sx={{ width: `calc(100% - ${sidebarWidth}px)` }}
> >
<AppTopbar onToggleNav={() => setNavCollapsed((value) => !value)} /> <AppTopbar onToggleNav={handleToggleNav} />
<Box <Box
component="main" component="main"

View file

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

View file

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

View file

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

View file

@ -1,39 +1,21 @@
import { useState } from "react"; import { useParams } from "react-router";
import { useNavigate, useParams } from "react-router";
import { Alert, Box, CircularProgress } from "@mui/material"; import { Alert, Box, CircularProgress } from "@mui/material";
import { DispatchCreateModal } from "./_components/dispatch-create-modal"; import { DispatchCreateModal } from "./_components/dispatch-create-modal";
import { DispatchDetailModal } from "./_components/dispatch-detail-modal"; import { DispatchDetailModal } from "./_components/dispatch-detail-modal";
import { VendorEditorDialog } from "./_components/vendor-editor-dialog"; import { WorkOrderActivityPanel } from "./_components/view/work-order-activity-panel";
import { useVendorEditor } from "./_components/use-vendor-editor"; import { WorkOrderDispatchesAccordion } from "./_components/view/work-order-dispatches-accordion";
import { WorkOrderActivity } from "./_components/work-order-activity"; import { WorkOrderVendorEditorDialog } from "./_components/view/work-order-vendor-editor-dialog";
import { import { WorkOrderViewAccordions } from "./_components/view/work-order-view-accordions";
DispatchAccordion, import { WorkOrderViewDetails } from "./_components/view/work-order-view-details";
WorkOrderDetailsSection, import { WorkOrderViewHeader } from "./_components/view/work-order-view-header";
WorkOrderHeader, import { useWorkOrderViewPage } from "./_hooks/use-work-order-view-page";
WorkOrderSummaryAccordions,
} from "./_components/work-order-view-sections";
import { useWorkOrderDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
import {
useChangeWorkOrderAssignment,
useChangeWorkOrderStatus,
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
export default function WorkOrderViewPage() { export default function WorkOrderViewPage() {
const { id } = useParams(); const { id } = useParams();
const navigate = useNavigate();
const workOrderId = id ?? ""; const workOrderId = id ?? "";
const view = useWorkOrderViewPage(workOrderId);
const { data: wo, isLoading, error, refetch } = useWorkOrderDetail(workOrderId); if (view.isLoading) {
const { data: users = [] } = useWorkOrderUsers();
const statusMutation = useChangeWorkOrderStatus(workOrderId);
const assignmentMutation = useChangeWorkOrderAssignment(workOrderId);
const vendorEditor = useVendorEditor({ workOrderId, onSaved: () => void refetch() });
const [dispatchOpen, setDispatchOpen] = useState(false);
const [dispatchDetailId, setDispatchDetailId] = useState<string | number | null>(null);
if (isLoading) {
return ( return (
<Box className="flex justify-center p-12"> <Box className="flex justify-center p-12">
<CircularProgress /> <CircularProgress />
@ -41,11 +23,11 @@ export default function WorkOrderViewPage() {
); );
} }
if (error || !wo) { if (view.error || !view.wo) {
return ( return (
<Box className="p-4"> <Box className="p-4">
<Alert severity="error"> <Alert severity="error">
{error instanceof Error ? error.message : "Work order not found."} {view.error instanceof Error ? view.error.message : "Work order not found."}
</Alert> </Alert>
</Box> </Box>
); );
@ -53,56 +35,67 @@ export default function WorkOrderViewPage() {
return ( return (
<Box className="flex flex-col gap-4 p-4"> <Box className="flex flex-col gap-4 p-4">
<WorkOrderHeader <WorkOrderViewHeader
wo={wo} wo={view.wo}
onSelectStatus={(status) => statusMutation.mutate(status)}
onDispatch={() => setDispatchOpen(true)}
onEdit={() => navigate(`/workorders/edit/${id}`)}
onBack={() => navigate("/workorders")}
/>
<WorkOrderDetailsSection
wo={wo}
users={users}
onAssign={(payload) => assignmentMutation.mutate(payload)}
/>
<WorkOrderSummaryAccordions wo={wo} />
<DispatchAccordion
wo={wo}
onEditVendor={(event, vendorId, vendorName) =>
void vendorEditor.open(event, vendorId, vendorName)
}
onViewDispatch={setDispatchDetailId}
/>
<WorkOrderActivity
workOrderId={workOrderId} workOrderId={workOrderId}
comments={wo.comments ?? []} statusAnchor={view.statusAnchor}
auditLog={wo.auditLog ?? []} 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 <DispatchCreateModal
workOrder={wo} workOrder={view.wo}
onClose={() => setDispatchOpen(false)} onClose={() => view.setDispatchOpen(false)}
onDispatched={() => void refetch()} onDispatched={() => void view.refetch()}
/> />
)} )}
{dispatchDetailId != null && ( {view.dispatchDetailId != null && (
<DispatchDetailModal <DispatchDetailModal
dispatchId={dispatchDetailId} dispatchId={view.dispatchDetailId}
workOrderId={workOrderId} workOrderId={workOrderId}
onClose={() => setDispatchDetailId(null)} onClose={() => view.setDispatchDetailId(null)}
onUpdated={() => void refetch()} onUpdated={() => void view.refetch()}
/> />
)} )}
<VendorEditorDialog
editor={vendorEditor.editor} <WorkOrderVendorEditorDialog
loading={vendorEditor.loading} editor={view.vendorEditor}
error={vendorEditor.error} loading={view.vendorEditorLoading}
isPending={vendorEditor.isPending} error={view.vendorEditorError}
phoneInvalid={vendorEditor.phoneInvalid} isSaving={view.vendorMutation.isPending}
isValid={vendorEditor.isValid} onClose={() => {
onClose={vendorEditor.close} if (!view.vendorMutation.isPending) view.setVendorEditor(null);
onSave={vendorEditor.save} }}
onFieldChange={vendorEditor.updateField} onSave={view.saveVendorEditor}
onEditorChange={view.setVendorEditor}
/> />
</Box> </Box>
); );

View file

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

View file

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

View file

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

View file

@ -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); 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" 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)" }} style={{ fontSize: 12.5, fontWeight: 600, color: "var(--status-pastdue-text)" }}
> >
@ -83,8 +83,7 @@ export function StatusCell({
color: "var(--status-pending-text)", color: "var(--status-pending-text)",
}} }}
> >
This work order is past due. A new date must be scheduled before the status can be This work order is past due. Update the Due Date before the status can be updated.
updated.
</Text> </Text>
<button <button
@ -98,7 +97,7 @@ export function StatusCell({
}} }}
className="mt-2 block text-[11px] font-medium text-[var(--color-primary)]" className="mt-2 block text-[11px] font-medium text-[var(--color-primary)]"
> >
Reschedule → Update Due Date →
</button> </button>
</div> </div>
</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"; import { VendorDialog, type VendorPatch } from "./vendor-dialog";
type VendorCellProps = { type VendorCellProps = {
vendorId?: string | number;
company: string; company: string;
tech: string; tech: string;
techPhone?: string; techPhone?: string;
@ -13,7 +14,15 @@ type VendorCellProps = {
onSave: (patch: VendorPatch) => void; 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 [open, setOpen] = useState(false);
const hasAny = !!(tech || company); const hasAny = !!(tech || company);
@ -46,6 +55,7 @@ export function VendorCell({ company, tech, techPhone, vendors, q, onSave }: Ven
<VendorDialog <VendorDialog
open={open} open={open}
onOpenChange={setOpen} onOpenChange={setOpen}
vendorId={vendorId}
company={company} company={company}
tech={tech} tech={tech}
techPhone={techPhone} 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 { useEffect, useMemo, useState, type MouseEvent } from "react";
import { Wrench } from "lucide-react";
import { Dialog, DialogContent, DialogTitle } from "@mui/material"; 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 { Text } from "@/components/ui/text";
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop"; import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order"; 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 { import {
buildTechnicianOptions, buildTechnicianOptions,
phoneForOption, phoneForOption,
} from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-options"; } 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 = { export type { VendorPatch } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types";
vendorId: string;
company: string;
tech: string;
techPhone: string;
};
type VendorDialogProps = { type VendorDialogProps = {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
vendorId?: string | number;
company: string; company: string;
tech: string; tech: string;
techPhone?: string; techPhone?: string;
@ -37,43 +35,94 @@ type VendorDialogProps = {
export function VendorDialog({ export function VendorDialog({
open, open,
onOpenChange, onOpenChange,
vendorId = "",
company, company,
tech, tech,
techPhone = "", techPhone = "",
vendors, vendors,
onSave, onSave,
}: VendorDialogProps) { }: VendorDialogProps) {
const createVendor = useCreateVendor();
const [selectedKey, setSelectedKey] = useState(() =>
resolveTechnicianKey(vendorId, tech, company, vendors),
);
const [t, setT] = useState(tech); const [t, setT] = useState(tech);
const [phone, setPhone] = useState(techPhone); const [phone, setPhone] = useState(techPhone);
const [notes, setNotes] = useState(""); 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( const technicianOptions = useMemo(
() => buildTechnicianOptions(vendors, tech, company), () => buildTechnicianOptions(allVendors, tech, company),
[vendors, tech, company], [allVendors, tech, company],
); );
useEffect(() => { useEffect(() => {
if (open) { if (open) {
const key = resolveTechnicianKey(vendorId, tech, company, vendors);
setSelectedKey(key);
setT(tech); setT(tech);
setPhone(techPhone || phoneForOption(tech, technicianOptions)); setPhone(techPhone || phoneForOption(tech, buildTechnicianOptions(vendors, tech, company)));
setNotes(""); 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 = const selected = technicianOptions.find((option) => option.key === selectedKey);
technicianOptions.find((o) => o.name === t && (!company || o.company === company)) ?? const resolvedCompany = selectedKey ? (selected?.company ?? company) : company;
technicianOptions.find((o) => o.name === t || o.key === t); const selectedVendor =
const selectedKey = selected?.key ?? ""; selected?.vendor ?? allVendors.find((vendor) => String(vendor.id) === selectedKey);
const resolvedCompany = t ? (selected?.company ?? company) : "";
const selectedVendor = selected?.vendor ?? vendors.find((v) => v.companyName === resolvedCompany); const { companyName: anchorCompanyName, companyVendor } = resolveVendorCompanyAnchor({
company,
resolvedCompany,
selectedVendor,
vendors: allVendors,
});
const handlePick = (key: string) => { const handlePick = (key: string) => {
const option = technicianOptions.find((o) => o.key === key); const option = technicianOptions.find((o) => o.key === key);
const name = option?.name ?? key; setSelectedKey(key);
setT(name); setT(option?.name ?? key);
setPhone(phoneForOption(name, technicianOptions)); 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 ( return (
<Dialog <Dialog
open={open} open={open}
@ -96,84 +145,28 @@ export function VendorDialog({
Search and select the technician Search and select the technician
</Text> </Text>
<div className="mt-3 space-y-3"> <VendorDialogBody
<div> selectedKey={selectedKey}
<FieldLabel>Technician</FieldLabel> technicianOptions={technicianOptions}
<WizardFieldSelect anchorCompanyName={anchorCompanyName}
value={selectedKey} companyVendorForAdd={companyVendor ?? undefined}
placeholder="Search technician …" tech={selected?.name ?? t}
icon={ phone={phone}
<Wrench notes={notes}
size={14} resolvedCompany={resolvedCompany}
className="shrink-0" selectedVendor={selectedVendor}
style={{ color: "var(--color-text-muted)" }} saveError={saveError}
/> onPick={handlePick}
} onPhoneChange={setPhone}
searchPlaceholder="Search technician…" onNotesChange={setNotes}
options={technicianOptions.map((option) => ({ onTechnicianCreated={handleTechnicianCreated}
key: option.key, />
label: option.label,
}))}
onPick={handlePick}
/>
</div>
{Boolean(t) && ( <VendorDialogFooter
<div> isPending={createVendor.isPending}
<FieldLabel>Technician Phone</FieldLabel> onCancel={() => onOpenChange(false)}
<input onSave={handleSave}
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>
</DialogContent> </DialogContent>
</Dialog> </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 { AssignedToCell } from "./cells/assigned-to-cell";
import { ApptTimeCell } from "./cells/appt-time-cell"; import { ApptTimeCell } from "./cells/appt-time-cell";
import { DateCell } from "./cells/date-cell"; import { DateCell } from "./cells/date-cell";
import { DueDateCell } from "./cells/due-date-cell";
import { EditableWONumberCell } from "./cells/editable-wo-number-cell"; import { EditableWONumberCell } from "./cells/editable-wo-number-cell";
import { PMTypeCell } from "./cells/pm-type-cell"; import { PMTypeCell } from "./cells/pm-type-cell";
import { SiteCell } from "./cells/site-cell"; import { SiteCell } from "./cells/site-cell";
@ -106,7 +107,11 @@ export function WoTableInlineRowCells({
</td> </td>
<td className="group/cell" style={{ padding: "0 14px" }}> <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>
<td className="group/cell" style={{ padding: "0 14px" }}> <td className="group/cell" style={{ padding: "0 14px" }}>
@ -129,6 +134,7 @@ export function WoTableInlineRowCells({
<td className="group/cell" style={{ padding: "0 14px" }}> <td className="group/cell" style={{ padding: "0 14px" }}>
<VendorCell <VendorCell
vendorId={draft.vendorId}
company={draft.company} company={draft.company}
tech={draft.tech} tech={draft.tech}
techPhone={draft.techPhone} 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 type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { AssignedToCell } from "./cells/assigned-to-cell"; import { AssignedToCell } from "./cells/assigned-to-cell";
import { DateCell } from "./cells/date-cell"; import { DateCell } from "./cells/date-cell";
import { DueDateCell } from "./cells/due-date-cell";
import { EditableWONumberCell } from "./cells/editable-wo-number-cell"; import { EditableWONumberCell } from "./cells/editable-wo-number-cell";
import { SiteCell } from "./cells/site-cell"; import { SiteCell } from "./cells/site-cell";
import { TypeCell } from "./cells/type-cell"; import { TypeCell } from "./cells/type-cell";
@ -16,6 +17,7 @@ type WoTableRowCellsProps = {
sitePlace?: { city: string; state: string }; sitePlace?: { city: string; state: string };
search?: string; search?: string;
forceScheduleHighlight?: boolean; forceScheduleHighlight?: boolean;
forceDueDateHighlight?: boolean;
mutationCellStyle?: CSSProperties; mutationCellStyle?: CSSProperties;
handlers: WoTableRowHandlers; handlers: WoTableRowHandlers;
onPatchRow: (patch: Partial<WorkOrderTableRow>) => void; onPatchRow: (patch: Partial<WorkOrderTableRow>) => void;
@ -29,6 +31,7 @@ export function WoTableRowCells({
sitePlace, sitePlace,
search, search,
forceScheduleHighlight, forceScheduleHighlight,
forceDueDateHighlight,
mutationCellStyle, mutationCellStyle,
handlers, handlers,
onPatchRow, onPatchRow,
@ -104,7 +107,7 @@ export function WoTableRowCells({
<DateCell <DateCell
value={row.scheduledOn} value={row.scheduledOn}
q={search} q={search}
highlight={forceScheduleHighlight || row.isPastDue} highlight={forceScheduleHighlight}
allowWeekMode allowWeekMode
targetWeek={row.targetWeek} targetWeek={row.targetWeek}
onSave={(date) => handlers.onScheduleDate(row.id, date)} onSave={(date) => handlers.onScheduleDate(row.id, date)}
@ -116,7 +119,13 @@ export function WoTableRowCells({
style={{ padding: "0 14px", ...mutationCellStyle }} style={{ padding: "0 14px", ...mutationCellStyle }}
onClick={(e) => e.stopPropagation()} 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> </td>
</> </>
); );

View file

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

View file

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

View file

@ -54,7 +54,7 @@ export function WoTable({
onOpenAdvancedFilters, onOpenAdvancedFilters,
onPatch, onPatch,
}: WoTableProps) { }: WoTableProps) {
const [scheduleHighlightId, setScheduleHighlightId] = useState<string | number | null>(null); const [dueDateHighlightId, setDueDateHighlightId] = useState<string | number | null>(null);
const { data: locationSites = [] } = useLocationSites(); const { data: locationSites = [] } = useLocationSites();
const sitePlaceLookups = useMemo(() => buildSitePlaceLookups(locationSites), [locationSites]); const sitePlaceLookups = useMemo(() => buildSitePlaceLookups(locationSites), [locationSites]);
const createMutation = useCreateWorkOrderFromWizard(() => { const createMutation = useCreateWorkOrderFromWizard(() => {
@ -69,7 +69,7 @@ export function WoTable({
onOpenCompDoc, onOpenCompDoc,
onOpenMedia, onOpenMedia,
onPatch, onPatch,
onScheduleHighlight: setScheduleHighlightId, onScheduleHighlight: setDueDateHighlightId,
}); });
const renderRow = (row: WorkOrderTableRow, forceScheduleHighlight = false) => ( const renderRow = (row: WorkOrderTableRow, forceScheduleHighlight = false) => (
@ -83,7 +83,8 @@ export function WoTable({
vendors={vendors} vendors={vendors}
pmTypes={pmTypes} pmTypes={pmTypes}
search={tableData.search} search={tableData.search}
forceScheduleHighlight={forceScheduleHighlight || scheduleHighlightId === row.id} forceScheduleHighlight={forceScheduleHighlight}
forceDueDateHighlight={dueDateHighlightId === row.id}
isSelected={tableData.selectedId === row.id} isSelected={tableData.selectedId === row.id}
isFlashing={tableData.flashId === row.id} isFlashing={tableData.flashId === row.id}
dragViz={tableData.dragViz} 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 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 { 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 { 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 { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
import { import {
DLG_INPUT_CLS, DLG_INPUT_CLS,
@ -197,7 +198,8 @@ export function WizardStepTypeSchedule({
> >
Due Date Due Date
</WizLabelRow> </WizLabelRow>
<WizardDateField <WizardDueDateField
type={draft.type}
value={draft.dueDate} value={draft.dueDate}
placeholder="No due date" placeholder="No due date"
onChange={(iso) => onUpdate("dueDate", iso)} onChange={(iso) => onUpdate("dueDate", iso)}

View file

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

View file

@ -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, formatVendorPortalDateTime,
vendorPortalStatusClass, vendorPortalStatusClass,
} from "@/domain/vendor-portal/lib/status-helpers"; } 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"; 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() { export default function VendorPortalDashboardPage() {
const { token } = useVendorPortal(); const { token } = useVendorPortal();
const navigate = useNavigate(); const navigate = useNavigate();
const [filter, setFilter] = useState<(typeof FILTERS)[number]["key"]>("open"); const [filter, setFilter] = useState<VendorPortalDispatchFilterKey>("open");
const { const {
data: dispatches = [], data: dispatches = [],
@ -49,8 +31,11 @@ export default function VendorPortalDashboardPage() {
enabled: Boolean(token), enabled: Boolean(token),
}); });
const activeFilter = FILTERS.find((item) => item.key === filter) ?? FILTERS[0]; const activeFilter = getVendorPortalDispatchFilter(filter);
const filtered = useMemo(() => dispatches.filter(activeFilter.match), [dispatches, activeFilter]); const filtered = useMemo(
() => applyVendorPortalDispatchFilter(dispatches, activeFilter),
[dispatches, activeFilter],
);
return ( return (
<> <>
@ -60,7 +45,7 @@ export default function VendorPortalDashboardPage() {
Active dispatches and work orders assigned to your company. Active dispatches and work orders assigned to your company.
</div> </div>
<div className="vp-actions" style={{ marginTop: 0 }}> <div className="vp-actions" style={{ marginTop: 0 }}>
{FILTERS.map((item) => ( {VENDOR_PORTAL_DISPATCH_FILTERS.map((item) => (
<button <button
key={item.key} key={item.key}
type="button" type="button"

View file

@ -4,8 +4,10 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Text } from "@/components/ui/text"; import { Text } from "@/components/ui/text";
import { DispatchDetailSections } from "@/app/v/[token]/dispatch/_components/dispatch-detail-sections"; import { DispatchDetailSections } from "@/app/v/[token]/dispatch/_components/dispatch-detail-sections";
import { DispatchSummaryCard } from "@/app/v/[token]/dispatch/_components/dispatch-summary-card"; 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 { useVendorPortal } from "@/app/v/_components/vendor-portal-context";
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api"; 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 type { VendorPortalDispatchDetail } from "@/domain/vendor-portal/types/vendor-portal";
import { queryKeys } from "@/infra/query-key/query-key"; import { queryKeys } from "@/infra/query-key/query-key";
@ -16,6 +18,7 @@ export default function VendorPortalDispatchPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [actionError, setActionError] = useState(""); const [actionError, setActionError] = useState("");
const [refuseOpen, setRefuseOpen] = useState(false);
const { data, isLoading, error, refetch } = useQuery({ const { data, isLoading, error, refetch } = useQuery({
queryKey: queryKeys.vendorPortal.dispatch(token, dispatchId), queryKey: queryKeys.vendorPortal.dispatch(token, dispatchId),
@ -28,6 +31,11 @@ export default function VendorPortalDispatchPage() {
await queryClient.invalidateQueries({ queryKey: queryKeys.vendorPortal.dispatches(token) }); await queryClient.invalidateQueries({ queryKey: queryKeys.vendorPortal.dispatches(token) });
}, [refetch, queryClient, token]); }, [refetch, queryClient, token]);
const pollEvidenceDocument = useCallback(
(documentId: number) => vendorPortalApi.getDocumentStatus(token, dispatchId, documentId),
[token, dispatchId],
);
const runAction = async (action: () => Promise<void>, confirmMsg?: string) => { const runAction = async (action: () => Promise<void>, confirmMsg?: string) => {
if (confirmMsg && !window.confirm(confirmMsg)) return; if (confirmMsg && !window.confirm(confirmMsg)) return;
setActionError(""); setActionError("");
@ -74,7 +82,7 @@ export default function VendorPortalDispatchPage() {
if (!data) return null; if (!data) return null;
const locked = data.status === "Verified" || data.status === "Cancelled"; const locked = isVendorPortalLockedStatus(data.status);
return ( return (
<> <>
@ -84,6 +92,7 @@ export default function VendorPortalDispatchPage() {
locked={locked} locked={locked}
busy={busy} busy={busy}
onAccept={() => void runAction(() => vendorPortalApi.accept(token, dispatchId))} onAccept={() => void runAction(() => vendorPortalApi.accept(token, dispatchId))}
onRefuse={() => setRefuseOpen(true)}
onStart={() => onStart={() =>
void runAction(() => vendorPortalApi.changeStatus(token, dispatchId, "In Progress")) void runAction(() => vendorPortalApi.changeStatus(token, dispatchId, "In Progress"))
} }
@ -105,8 +114,16 @@ export default function VendorPortalDispatchPage() {
data={data} data={data}
locked={locked} locked={locked}
refresh={refresh} refresh={refresh}
pollEvidenceDocument={pollEvidenceDocument}
updateLocal={updateLocal} 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 { SignoffSection } from "@/app/v/[token]/dispatch/_components/signoff-section";
import { UpliftRequestsSection } from "@/app/v/[token]/dispatch/_components/uplift-requests-section"; import { UpliftRequestsSection } from "@/app/v/[token]/dispatch/_components/uplift-requests-section";
import { VendorPortalDocuments } from "@/app/v/_components/vendor-portal-documents"; import { VendorPortalDocuments } from "@/app/v/_components/vendor-portal-documents";
import type { VendorPortalDispatchDetail } from "@/domain/vendor-portal/types/vendor-portal"; import type {
VendorPortalDispatchDetail,
VendorPortalDocumentStatus,
} from "@/domain/vendor-portal/types/vendor-portal";
type DispatchDetailSectionsProps = { type DispatchDetailSectionsProps = {
token: string; token: string;
@ -12,6 +15,7 @@ type DispatchDetailSectionsProps = {
data: VendorPortalDispatchDetail; data: VendorPortalDispatchDetail;
locked: boolean; locked: boolean;
refresh: () => Promise<void>; refresh: () => Promise<void>;
pollEvidenceDocument: (documentId: number) => Promise<VendorPortalDocumentStatus | undefined>;
updateLocal: ( updateLocal: (
updater: (current: VendorPortalDispatchDetail) => VendorPortalDispatchDetail, updater: (current: VendorPortalDispatchDetail) => VendorPortalDispatchDetail,
) => void; ) => void;
@ -23,6 +27,7 @@ export function DispatchDetailSections({
data, data,
locked, locked,
refresh, refresh,
pollEvidenceDocument,
updateLocal, updateLocal,
}: DispatchDetailSectionsProps) { }: DispatchDetailSectionsProps) {
return ( return (
@ -65,6 +70,7 @@ export function DispatchDetailSections({
upliftRequests={data.upliftRequests ?? []} upliftRequests={data.upliftRequests ?? []}
locked={locked} locked={locked}
onChanged={refresh} onChanged={refresh}
pollDocument={pollEvidenceDocument}
/> />
</div> </div>
@ -76,6 +82,7 @@ export function DispatchDetailSections({
existing={data.signoffs ?? []} existing={data.signoffs ?? []}
signoffType="customer" signoffType="customer"
nameLabel="Customer name" nameLabel="Customer name"
submissionLocked={locked}
onAdded={() => void refresh()} onAdded={() => void refresh()}
/> />
</div> </div>
@ -88,6 +95,7 @@ export function DispatchDetailSections({
existing={data.signoffs ?? []} existing={data.signoffs ?? []}
signoffType="vendor" signoffType="vendor"
nameLabel="Your name" nameLabel="Your name"
submissionLocked={locked}
onAdded={() => void refresh()} onAdded={() => void refresh()}
/> />
</div> </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; locked: boolean;
busy: boolean; busy: boolean;
onAccept: () => void; onAccept: () => void;
onRefuse: () => void;
onStart: () => void; onStart: () => void;
onComplete: () => void; onComplete: () => void;
onRequestCancel: () => void; onRequestCancel: () => void;
@ -13,6 +14,7 @@ export function DispatchStatusActions({
locked, locked,
busy, busy,
onAccept, onAccept,
onRefuse,
onStart, onStart,
onComplete, onComplete,
onRequestCancel, onRequestCancel,
@ -20,9 +22,19 @@ export function DispatchStatusActions({
return ( return (
<div className="vp-actions"> <div className="vp-actions">
{status === "Sent" && ( {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" && ( {status === "Acknowledged" && (
<button type="button" className="vp-btn vp-btn-primary" disabled={busy} onClick={onStart}> <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 { Link } from "react-router";
import { Text } from "@/components/ui/text"; import { Text } from "@/components/ui/text";
import { import { vendorPortalStatusClass } from "@/domain/vendor-portal/lib/status-helpers";
formatVendorPortalDateTime,
vendorPortalStatusClass,
} from "@/domain/vendor-portal/lib/status-helpers";
import type { VendorPortalDispatchDetail } from "@/domain/vendor-portal/types/vendor-portal"; 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"; import { DispatchStatusActions } from "@/app/v/[token]/dispatch/_components/dispatch-status-actions";
type DispatchSummaryCardProps = { type DispatchSummaryCardProps = {
@ -13,6 +11,7 @@ type DispatchSummaryCardProps = {
locked: boolean; locked: boolean;
busy: boolean; busy: boolean;
onAccept: () => void; onAccept: () => void;
onRefuse: () => void;
onStart: () => void; onStart: () => void;
onComplete: () => void; onComplete: () => void;
onRequestCancel: () => void; onRequestCancel: () => void;
@ -24,6 +23,7 @@ export function DispatchSummaryCard({
locked, locked,
busy, busy,
onAccept, onAccept,
onRefuse,
onStart, onStart,
onComplete, onComplete,
onRequestCancel, onRequestCancel,
@ -43,77 +43,14 @@ export function DispatchSummaryCard({
<span className={vendorPortalStatusClass(data.status)}>{data.status}</span> <span className={vendorPortalStatusClass(data.status)}>{data.status}</span>
</div> </div>
<div style={{ marginTop: 12 }}> <DispatchSnapshotDetails data={data} locked={locked} />
{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>
</>
)}
<DispatchStatusActions <DispatchStatusActions
status={data.status} status={data.status}
locked={locked} locked={locked}
busy={busy} busy={busy}
onAccept={onAccept} onAccept={onAccept}
onRefuse={onRefuse}
onStart={onStart} onStart={onStart}
onComplete={onComplete} onComplete={onComplete}
onRequestCancel={onRequestCancel} 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[]; existing: VendorPortalSignoff[];
signoffType: string; signoffType: string;
nameLabel: string; nameLabel: string;
submissionLocked?: boolean;
onAdded: () => void; onAdded: () => void;
}; };
@ -20,6 +21,7 @@ export function SignoffSection({
existing, existing,
signoffType, signoffType,
nameLabel, nameLabel,
submissionLocked = false,
onAdded, onAdded,
}: SignoffSectionProps) { }: SignoffSectionProps) {
const [name, setName] = useState(""); 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) => { const submit = async (event: React.FormEvent) => {
event.preventDefault(); event.preventDefault();
const signature = method === "drawn" ? drawnSig : typedSig; 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 { useCallback, useState } from "react";
import { Text } from "@/components/ui/text"; import { VendorPortalFeedback } from "@/app/v/_components/vendor-portal-feedback";
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api"; import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
import type { VendorPortalUpliftRequest } from "@/domain/vendor-portal/types/vendor-portal"; import { createUpliftRequestKey } from "@/domain/vendor-portal/lib/request-key";
import type {
VendorPortalDocumentStatus,
VendorPortalUpliftRequest,
} from "@/domain/vendor-portal/types/vendor-portal";
import { UpliftRequestForm } from "@/app/v/[token]/dispatch/_components/uplift-request-form";
import type { UpliftFormValues } from "@/domain/vendor-portal/lib/uplift-validation";
import { UpliftRequestCard } from "@/app/v/[token]/dispatch/_components/uplift-request-card";
import {
useVendorUpliftEvidence,
type EvidenceScanState,
} from "@/app/v/[token]/dispatch/_components/use-uplift-evidence";
const ACTIVE_STATUSES = new Set(["Pending", "ChangesRequested"]);
type UpliftRequestsSectionProps = { type UpliftRequestsSectionProps = {
token: string; token: string;
@ -10,20 +23,141 @@ type UpliftRequestsSectionProps = {
upliftRequests: VendorPortalUpliftRequest[]; upliftRequests: VendorPortalUpliftRequest[];
locked: boolean; locked: boolean;
onChanged: () => Promise<void>; onChanged: () => Promise<void>;
pollDocument: (documentId: number) => Promise<VendorPortalDocumentStatus | undefined>;
}; };
function validateUpliftRequest(requested: string, currentNte: number | undefined, reason: string) { type UseVendorUpliftActionsArgs = {
const amount = Number.parseFloat(requested); token: string;
if (!Number.isFinite(amount) || amount <= 0) { dispatchId: number;
return "Enter a valid requested NTE amount."; currentNte?: number;
onChanged: () => Promise<void>;
evidence: ReturnType<typeof useVendorUpliftEvidence>;
};
function scanErrorMessage(scan: EvidenceScanState): string {
if (scan === "Rejected") {
return "The evidence file was rejected by the security scan.";
} }
if (currentNte != null && amount <= currentNte) { return "Security scan timed out. Please try again once the file finishes scanning.";
return `Requested NTE must be greater than the current NTE ($${currentNte.toFixed(2)}).`; }
}
if (!reason.trim()) { function useVendorUpliftActions({
return "Please include a reason for the uplift request."; token,
} dispatchId,
return ""; currentNte,
onChanged,
evidence,
}: UseVendorUpliftActionsArgs) {
const [submitting, setSubmitting] = useState(false);
const [formError, setFormError] = useState("");
const [infoMessage, setInfoMessage] = useState("");
const [requestError, setRequestError] = useState("");
const [errorRequestId, setErrorRequestId] = useState<string | number | null>(null);
const uploadAndScan = useCallback(
async (file: File): Promise<{ documentId: number; scan: EvidenceScanState }> => {
const document = await evidence.uploadEvidence({ token, dispatchId, file });
const scan = await evidence.waitForScan(document.id);
evidence.setScanState(scan);
return { documentId: document.id, scan };
},
[evidence, token, dispatchId],
);
const handleCreate = async (values: UpliftFormValues) => {
setFormError("");
setInfoMessage("");
setSubmitting(true);
try {
const requiresApproval = currentNte == null || values.amount > (currentNte ?? 0);
let evidenceDocumentId: number | undefined;
if (requiresApproval) {
if (!values.evidenceFile) {
setFormError(
"Attach PDF, JPG, or PNG evidence — approval-required requests need evidence.",
);
return;
}
const result = await uploadAndScan(values.evidenceFile);
if (result.scan !== "Passed") {
setFormError(scanErrorMessage(result.scan));
return;
}
evidenceDocumentId = result.documentId;
}
const outcome = await vendorPortalApi.requestUplift(token, dispatchId, {
requestedNTE: values.amount,
reason: values.reason,
requestKey: createUpliftRequestKey(),
evidenceDocumentId,
});
evidence.reset();
await onChanged();
if (outcome.outcome === "NoApprovalRequired") {
setInfoMessage("No approval required — the existing NTE already covers this amount.");
}
} catch {
setFormError("Unable to request the NTE uplift. Please try again.");
} finally {
setSubmitting(false);
}
};
const handleWithdraw = async (request: VendorPortalUpliftRequest) => {
if (!window.confirm("Withdraw this uplift request?")) return;
setErrorRequestId(request.id);
setRequestError("");
setSubmitting(true);
try {
await vendorPortalApi.withdrawUplift(token, dispatchId, request.id);
await onChanged();
} catch {
setRequestError("Unable to withdraw the uplift request. Please try again.");
} finally {
setSubmitting(false);
}
};
const handleRevise = async (request: VendorPortalUpliftRequest, values: UpliftFormValues) => {
setErrorRequestId(request.id);
setRequestError("");
setSubmitting(true);
try {
const requiresApproval =
request.currentNTE == null || values.amount > (request.currentNTE ?? 0);
let evidenceDocumentId = request.evidenceDocumentId;
if (requiresApproval && values.evidenceFile) {
const result = await uploadAndScan(values.evidenceFile);
if (result.scan !== "Passed") {
setRequestError(scanErrorMessage(result.scan));
return;
}
evidenceDocumentId = result.documentId;
}
await vendorPortalApi.reviseUplift(token, dispatchId, request.id, {
requestedNTE: values.amount,
reason: values.reason,
evidenceDocumentId,
});
evidence.reset();
await onChanged();
} catch {
setRequestError("Unable to revise the uplift request. Please try again.");
} finally {
setSubmitting(false);
}
};
return {
submitting,
formError,
infoMessage,
requestError,
errorRequestId,
handleCreate,
handleWithdraw,
handleRevise,
};
} }
export function UpliftRequestsSection({ export function UpliftRequestsSection({
@ -33,136 +167,56 @@ export function UpliftRequestsSection({
upliftRequests, upliftRequests,
locked, locked,
onChanged, onChanged,
pollDocument,
}: UpliftRequestsSectionProps) { }: UpliftRequestsSectionProps) {
const [requested, setRequested] = useState(""); const evidence = useVendorUpliftEvidence({ pollDocument });
const [reason, setReason] = useState(""); const {
const [submitting, setSubmitting] = useState(false); submitting,
const [errorMessage, setErrorMessage] = useState(""); formError,
infoMessage,
requestError,
errorRequestId,
handleCreate,
handleWithdraw,
handleRevise,
} = useVendorUpliftActions({ token, dispatchId, currentNte, onChanged, evidence });
const pending = upliftRequests.find((request) => request.status === "Pending"); const activeRequest = upliftRequests.find((request) => ACTIVE_STATUSES.has(request.status));
const canShowForm = !locked && !activeRequest;
const submit = async (event: React.FormEvent) => {
event.preventDefault();
const amount = Number.parseFloat(requested);
const validationError = validateUpliftRequest(requested, currentNte, reason);
setErrorMessage(validationError);
if (validationError) {
return;
}
setSubmitting(true);
try {
await vendorPortalApi.requestUplift(token, dispatchId, {
requestedNTE: amount,
reason: reason.trim(),
});
setRequested("");
setReason("");
await onChanged();
} catch {
setErrorMessage("Unable to request the NTE uplift. Please try again.");
} finally {
setSubmitting(false);
}
};
const cancel = async (requestId: number) => {
if (!window.confirm("Cancel this uplift request?")) return;
setErrorMessage("");
try {
await vendorPortalApi.cancelUplift(token, dispatchId, requestId);
await onChanged();
} catch {
setErrorMessage("Unable to cancel the NTE uplift request. Please try again.");
}
};
return ( return (
<> <>
{upliftRequests.length === 0 && !pending && ( {upliftRequests.length === 0 && !activeRequest && (
<div className="vp-muted" style={{ marginBottom: 12 }}> <VendorPortalFeedback>
No uplift requests yet. Use the form below if the work will exceed the current NTE. No uplift requests yet. Use the form below if the work will exceed the current NTE.
</div> </VendorPortalFeedback>
)} )}
{Boolean(infoMessage) && <VendorPortalFeedback>{infoMessage}</VendorPortalFeedback>}
{upliftRequests.map((request) => ( {upliftRequests.map((request) => (
<div key={request.id} className="vp-uplift-row"> <UpliftRequestCard
<div className="vp-row"> key={request.id}
<strong>Requested</strong> request={request}
<span> locked={locked}
${Number(request.requestedNTE).toFixed(2)} (was $ canAct={!locked}
{Number(request.currentNTE ?? 0).toFixed(2)}) uploadingPercent={evidence.uploadingPercent}
</span> submitting={submitting}
</div> errorMessage={errorRequestId === request.id ? requestError : ""}
<div className="vp-row"> onWithdraw={(r) => void handleWithdraw(r)}
<strong>Status</strong> onRevise={(r, values) => void handleRevise(r, values)}
<span> onCancelRevise={evidence.reset}
<span className={`vp-uplift-status-${String(request.status).toLowerCase()}`}> />
{request.status}
</span>
</span>
</div>
{Boolean(request.vendorReason) && (
<div className="vp-row">
<strong>Reason</strong>
<span>{request.vendorReason}</span>
</div>
)}
{request.status === "Pending" && (
<div className="vp-actions">
<button
type="button"
className="vp-btn vp-btn-ghost"
onClick={() => void cancel(request.id)}
>
Cancel Request
</button>
</div>
)}
</div>
))} ))}
<Text variant="error" when={Boolean(errorMessage)}>
{errorMessage}
</Text>
{!locked && !pending && ( {canShowForm && (
<form onSubmit={(event) => void submit(event)} style={{ marginTop: 12 }}> <UpliftRequestForm
<div className="vp-row"> mode="create"
<strong>Current NTE</strong> currentNte={currentNte}
<span>${Number(currentNte ?? 0).toFixed(2)}</span> submitting={submitting}
</div> uploadingPercent={evidence.uploadingPercent}
<div className="vp-row"> errorMessage={formError}
<strong>New total NTE</strong> onSubmit={(values) => void handleCreate(values)}
<input />
type="number"
step="0.01"
min="0"
className="vp-input"
value={requested}
onChange={(event) => {
setRequested(event.target.value);
setErrorMessage("");
}}
placeholder="0.00"
/>
</div>
<div className="vp-row">
<strong>Reason</strong>
<textarea
className="vp-textarea"
value={reason}
onChange={(event) => {
setReason(event.target.value);
setErrorMessage("");
}}
placeholder="Why is the uplift needed?"
/>
</div>
<div className="vp-actions">
<button type="submit" className="vp-btn vp-btn-primary" disabled={submitting}>
{submitting ? "Submitting…" : "Request Uplift"}
</button>
</div>
</form>
)} )}
</> </>
); );

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,8 @@
import { useMemo } from "react"; 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"; import { formatDateTime } from "@/lib/time-utils";
export type CommentTimelineItem = { export type CommentTimelineItem = {
@ -7,18 +10,20 @@ export type CommentTimelineItem = {
author: string; author: string;
createdAt?: string; createdAt?: string;
body: string; body: string;
mentions?: string[];
}; };
type CommentTimelineProps = { type CommentTimelineProps = {
items: CommentTimelineItem[]; items: CommentTimelineItem[];
emptyMessage?: string; emptyMessage?: string;
maxHeightClass?: string; maxHeightClass?: string;
mentionUsers?: MentionLookup[];
}; };
export function CommentTimeline({ export function CommentTimeline({
items, items,
emptyMessage = "No comments yet.", emptyMessage = "No comments yet.",
maxHeightClass = "max-h-72", maxHeightClass = "max-h-72",
mentionUsers,
}: CommentTimelineProps) { }: CommentTimelineProps) {
const sorted = useMemo( const sorted = useMemo(
() => () =>
@ -31,7 +36,7 @@ export function CommentTimeline({
); );
if (!sorted.length) { if (!sorted.length) {
return <Typography color="text.secondary">{emptyMessage}</Typography>; return <Text tone="muted">{emptyMessage}</Text>;
} }
return ( return (
@ -39,14 +44,14 @@ export function CommentTimeline({
{sorted.map((item) => ( {sorted.map((item) => (
<Box key={String(item.id)} className="rounded border border-border p-3"> <Box key={String(item.id)} className="rounded border border-border p-3">
<Stack direction="row" className="mb-1" sx={{ justifyContent: "space-between" }}> <Stack direction="row" className="mb-1" sx={{ justifyContent: "space-between" }}>
<Typography variant="caption" className="font-semibold"> <Text variant="caption" className="font-semibold">
{item.author} {item.author}
</Typography> </Text>
<Typography variant="caption" color="text.secondary"> <Text variant="caption" tone="muted">
{item.createdAt ? formatDateTime(item.createdAt) : ""} {item.createdAt ? formatDateTime(item.createdAt) : ""}
</Typography> </Text>
</Stack> </Stack>
<Typography variant="body2">{item.body}</Typography> <CommentBodyWithMentions text={item.body} mentions={item.mentions} users={mentionUsers} />
</Box> </Box>
))} ))}
</Stack> </Stack>

View file

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

View file

@ -6,6 +6,7 @@ import {
ClipboardList, ClipboardList,
FileText, FileText,
LayoutDashboard, LayoutDashboard,
MapPin,
Bell, Bell,
BarChart3, BarChart3,
Star, Star,
@ -52,7 +53,10 @@ export const navGroups: NavGroup[] = [
}, },
{ {
title: "CUSTOMERS & SITES", 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", // title: "INTERNAL",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,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", Completed: "vp-status-completed",
Verified: "vp-status-verified", Verified: "vp-status-verified",
Cancelled: "vp-status-cancelled", Cancelled: "vp-status-cancelled",
Refused: "vp-status-refused",
}; };
return `vp-status-pill ${map[status] ?? ""}`; return `vp-status-pill ${map[status] ?? ""}`;
@ -23,3 +24,34 @@ export function formatVendorPortalDateTime(value?: string): string {
return date.toLocaleString(); 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; nteAmount?: number;
dispatchedAt?: string; dispatchedAt?: string;
completedDate?: string; completedDate?: string;
refusedAt?: string;
} }
export interface VendorPortalDocument { export interface VendorPortalDocument {
@ -32,12 +33,18 @@ export interface VendorPortalDocument {
rejectionReason?: string; rejectionReason?: string;
version: number; version: number;
replacesDocumentId?: number; replacesDocumentId?: number;
purpose?: string;
createdDate?: string; createdDate?: string;
scannedAt?: string; scannedAt?: string;
reviewedAt?: string; reviewedAt?: string;
canDownload: boolean; canDownload: boolean;
} }
export type VendorPortalDocumentStatus = Pick<
VendorPortalDocument,
"id" | "originalFileName" | "scanStatus" | "reviewStatus" | "purpose"
>;
export interface VendorPortalChecklistItem { export interface VendorPortalChecklistItem {
id: number; id: number;
itemText: string; itemText: string;
@ -64,6 +71,29 @@ export interface VendorPortalUpliftRequest {
decidedByName?: string; decidedByName?: string;
decisionNote?: string; decisionNote?: string;
requiredTier?: number; requiredTier?: number;
expiresAt?: string;
notificationStatus?: string;
notificationError?: string;
evidenceDocumentId?: number;
evidenceFileName?: string;
evidenceContentType?: string;
evidenceSizeBytes?: number;
}
export type VendorUpliftRequestOutcome =
{ outcome: "NoApprovalRequired" } | { outcome: "Created"; request?: VendorPortalUpliftRequest };
export interface VendorUpliftRequestBody {
requestedNTE: number;
reason: string;
requestKey: string;
evidenceDocumentId?: number;
}
export interface VendorUpliftReviseBody {
requestedNTE: number;
reason: string;
evidenceDocumentId?: number;
} }
export interface VendorPortalSignoff { export interface VendorPortalSignoff {
@ -73,15 +103,25 @@ export interface VendorPortalSignoff {
signedAt?: string; signedAt?: string;
} }
export interface VendorPortalDispatcherContact {
name?: string;
email?: string;
phone?: string;
}
export interface VendorPortalDispatchDetail extends VendorPortalDispatchSummary { export interface VendorPortalDispatchDetail extends VendorPortalDispatchSummary {
description?: string; description?: string;
acknowledgedAt?: string; acknowledgedAt?: string;
statusUpdatedAt?: string;
dispatcherContact?: VendorPortalDispatcherContact;
workOrder?: { workOrder?: {
workerOrderTitle?: string; workerOrderTitle?: string;
internalWONumber?: string; internalWONumber?: string;
priority?: string; priority?: string;
trade?: string; trade?: string;
subTrade?: string; subTrade?: string;
siteCode?: string;
service?: string;
problem?: string; problem?: string;
description?: string; description?: string;
}; };

View file

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

View file

@ -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"; } from "@/domain/work-orders/types/work-order-board-detail";
import type { WorkOrderComment } from "@/domain/work-orders/types/work-order"; import type { WorkOrderComment } from "@/domain/work-orders/types/work-order";
export interface CreateWorkOrderCommentPayload {
text: string;
mentions?: string[];
}
async function readResponseJson(response: Response): Promise<unknown> { async function readResponseJson(response: Response): Promise<unknown> {
try { try {
return await response.json(); return await response.json();
@ -57,8 +62,17 @@ export const workOrderBoardDocumentsApi = {
return Array.isArray(items) ? items.map(mapBoardDetailComment) : []; return Array.isArray(items) ? items.map(mapBoardDetailComment) : [];
}, },
createComment: async (workOrderId: string | number, text: string): Promise<WorkOrderComment> => { createComment: async (
const data = await apiPost<unknown>(API_PATHS.workOrder.comments(workOrderId), { text }); 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)); return mapBoardDetailComment(handleApiResponse(data));
}, },

View file

@ -61,6 +61,33 @@ function readBoolean(record: Record<string, unknown>, ...keys: string[]): boolea
return false; 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 { function mapDocStatus(raw: unknown): CompletionDocStatus {
const numeric = typeof raw === "number" ? raw : Number(raw); const numeric = typeof raw === "number" ? raw : Number(raw);
if (numeric === DOC_STATUS_API.Yes) return "Yes"; if (numeric === DOC_STATUS_API.Yes) return "Yes";
@ -93,6 +120,7 @@ export function mapBoardDetailComment(raw: unknown): WorkOrderComment {
authorId, authorId,
commenter: readString(item, "commenter", "Commenter") || null, commenter: readString(item, "commenter", "Commenter") || null,
recordType: readString(item, "recordType", "RecordType") || null, recordType: readString(item, "recordType", "RecordType") || null,
mentions: readStringArray(item, "mentions", "Mentions"),
}; };
} }

View file

@ -10,7 +10,6 @@ import type {
WorkOrderFormValues, WorkOrderFormValues,
WorkOrderListItem, WorkOrderListItem,
} from "@/domain/work-orders/types/work-order"; } from "@/domain/work-orders/types/work-order";
import type { UpliftRequest } from "@/domain/uplifts/types/uplift";
import type { WorkOrderStatus } from "@/domain/work-orders/types/work-order-common"; import type { WorkOrderStatus } from "@/domain/work-orders/types/work-order-common";
import { WORK_ORDER_STATUS_OPTIONS } from "@/domain/work-orders/types/work-order-common"; import { WORK_ORDER_STATUS_OPTIONS } from "@/domain/work-orders/types/work-order-common";
import { toDateInputValue } from "@/lib/time-utils"; import { toDateInputValue } from "@/lib/time-utils";
@ -215,6 +214,12 @@ export { mapWorkOrderTableRow, mapWorkOrderTableRows } from "./work-order-table-
export function mapWorkOrderComment(raw: unknown): WorkOrderComment { export function mapWorkOrderComment(raw: unknown): WorkOrderComment {
const item = asRecord(raw); 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 { return {
id: readId(item), id: readId(item),
@ -224,6 +229,7 @@ export function mapWorkOrderComment(raw: unknown): WorkOrderComment {
userName: readString(item, "userName", "UserName"), userName: readString(item, "userName", "UserName"),
commenter: readString(item, "commenter", "Commenter") || null, commenter: readString(item, "commenter", "Commenter") || null,
recordType: readString(item, "recordType", "RecordType") || null, recordType: readString(item, "recordType", "RecordType") || null,
mentions,
}; };
} }
@ -455,27 +461,3 @@ export {
mapDispatchWorkOrderRef, mapDispatchWorkOrderRef,
mapUpdateDispatchPayload, mapUpdateDispatchPayload,
} from "./work-order-dispatch-mapper"; } from "./work-order-dispatch-mapper";
export function mapUpliftRequest(raw: unknown): UpliftRequest {
const item = asRecord(raw);
return {
id: readId(item),
status: readString(item, "status", "Status"),
currentNTE: readNumber(item, "currentNTE", "CurrentNTE"),
requestedNTE: readNumber(item, "requestedNTE", "RequestedNTE") ?? 0,
delta: readNumber(item, "delta", "Delta") ?? 0,
vendorReason: readString(item, "vendorReason", "VendorReason"),
requestedAt: readString(item, "requestedAt", "RequestedAt"),
requestedByVendorName: readString(item, "requestedByVendorName", "RequestedByVendorName"),
decidedAt: readString(item, "decidedAt", "DecidedAt"),
decidedByName: readString(item, "decidedByName", "DecidedByName"),
decisionNote: readString(item, "decisionNote", "DecisionNote"),
requiredTier: readNumber(item, "requiredTier", "RequiredTier") ?? 1,
canDecide: readBool(item, "canDecide", "CanDecide"),
};
}
export function mapUpliftRequests(rawItems: unknown[]): UpliftRequest[] {
return rawItems.map(mapUpliftRequest);
}

View file

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

View file

@ -11,6 +11,8 @@ export interface WorkOrderComment {
authorId?: string | null; authorId?: string | null;
commenter: string | null; commenter: string | null;
recordType: string | null; recordType: string | null;
/** Mentioned user ids when returned by board comments API. */
mentions?: string[];
} }
export interface WorkOrderAuditEntry { export interface WorkOrderAuditEntry {

View file

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

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