mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-06 13:32:06 +00:00
Complete vendor operations roadmap frontend (#52)
* feat(vendors): complete operations roadmap frontend * test(vendors): cover work order assignment flow * fix(vendors): address review feedback * fix(vendors): prevent stale preference saves * fix(vendors): require truthful appointment bounds
This commit is contained in:
parent
52e77dc197
commit
45909e7aa6
31 changed files with 3264 additions and 879 deletions
508
e2e/vendors/vendor-operations.spec.ts
vendored
Normal file
508
e2e/vendors/vendor-operations.spec.ts
vendored
Normal file
|
|
@ -0,0 +1,508 @@
|
||||||
|
import { expect, test, type Page, type Route } from "@playwright/test";
|
||||||
|
|
||||||
|
const TEST_USER = {
|
||||||
|
token: "vendor-operations-e2e-token",
|
||||||
|
expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||||
|
email: "admin@seahavenind.com",
|
||||||
|
userRoles: "Admin",
|
||||||
|
phoneNumber: "5551234567",
|
||||||
|
fullname: "Vendor Operations Admin",
|
||||||
|
id: "1",
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fulfillJson(route: Route, body: unknown, status = 200) {
|
||||||
|
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authenticate(page: Page) {
|
||||||
|
await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("Vendor operations", () => {
|
||||||
|
test("groups 48-hour reminders and overlap conflicts with direct work-order actions", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await authenticate(page);
|
||||||
|
await page.route("**/api/vendor-operations/notifications", (route) =>
|
||||||
|
fulfillJson(route, {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: "no-vendor-501",
|
||||||
|
type: "NoVendor",
|
||||||
|
severity: "Medium",
|
||||||
|
group: "No Vendor",
|
||||||
|
title: "No vendor assigned — 48h to service",
|
||||||
|
workOrderId: 501,
|
||||||
|
workOrderNumber: "WO-501",
|
||||||
|
triggeredAt: "2026-07-23T12:00:00Z",
|
||||||
|
scheduledAt: "2026-07-25T12:00:00Z",
|
||||||
|
action: { label: "Choose vendor", href: "/workorders/edit/501?assignVendor=1" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "vendor-conflict-801-802",
|
||||||
|
type: "VendorConflict",
|
||||||
|
severity: "Medium",
|
||||||
|
group: "Vendor Conflict",
|
||||||
|
title: "Vendor has overlapping appointments",
|
||||||
|
triggeredAt: "2026-07-23T12:00:00Z",
|
||||||
|
workOrders: [
|
||||||
|
{
|
||||||
|
workOrderId: 501,
|
||||||
|
workOrderNumber: "WO-501",
|
||||||
|
start: "2026-07-25T12:00:00Z",
|
||||||
|
end: "2026-07-25T14:00:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
workOrderId: 502,
|
||||||
|
workOrderNumber: "WO-502",
|
||||||
|
start: "2026-07-25T13:00:00Z",
|
||||||
|
end: "2026-07-25T15:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
actions: [
|
||||||
|
{ label: "WO-501", href: "/workorders/501" },
|
||||||
|
{ label: "WO-502", href: "/workorders/502" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto("/notifications");
|
||||||
|
|
||||||
|
await expect(page.getByRole("heading", { name: "Items requiring attention" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("heading", { name: "No Vendor" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("heading", { name: "Vendor Conflict" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("link", { name: "Choose vendor" })).toHaveAttribute(
|
||||||
|
"href",
|
||||||
|
"/workorders/edit/501?assignVendor=1",
|
||||||
|
);
|
||||||
|
await expect(page.getByRole("link", { name: "WO-501" })).toHaveAttribute(
|
||||||
|
"href",
|
||||||
|
"/workorders/501",
|
||||||
|
);
|
||||||
|
await expect(page.getByRole("link", { name: "WO-502" })).toHaveAttribute(
|
||||||
|
"href",
|
||||||
|
"/workorders/502",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("filters insights and exposes both export formats", async ({ page }) => {
|
||||||
|
await authenticate(page);
|
||||||
|
const requestedUrls: string[] = [];
|
||||||
|
await page.route("**/api/vendor-operations/insights*", async (route) => {
|
||||||
|
requestedUrls.push(route.request().url());
|
||||||
|
const { pathname } = new URL(route.request().url());
|
||||||
|
if (pathname.endsWith(".csv")) {
|
||||||
|
await route.fulfill({ status: 200, contentType: "text/csv", body: "Vendor,Completed\n" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pathname.endsWith(".pdf")) {
|
||||||
|
await route.fulfill({ status: 200, contentType: "application/pdf", body: "%PDF-1.4" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await fulfillJson(route, {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
vendorId: 7,
|
||||||
|
vendor: "Gateway Plumbing",
|
||||||
|
completedJobs: 12,
|
||||||
|
cancellationRefusalRate: 0.1,
|
||||||
|
onTimeArrivalRate: 0.9,
|
||||||
|
onTimeCompletionRate: 0.8,
|
||||||
|
averageCycleHours: 5.5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/vendors/insights");
|
||||||
|
|
||||||
|
await expect(page.getByRole("cell", { name: "Gateway Plumbing" })).toBeVisible();
|
||||||
|
await page.getByLabel("From", { exact: true }).fill("2026-01-01");
|
||||||
|
await page.getByLabel("To", { exact: true }).fill("2026-01-31");
|
||||||
|
await page.getByLabel("Trade", { exact: true }).fill("Plumbing");
|
||||||
|
|
||||||
|
const insightsQueryHas = (name: string, value: string) =>
|
||||||
|
requestedUrls.some(
|
||||||
|
(url) =>
|
||||||
|
new URL(url).pathname.endsWith("/insights") &&
|
||||||
|
new URL(url).searchParams.get(name) === value,
|
||||||
|
);
|
||||||
|
await expect.poll(() => insightsQueryHas("from", "2026-01-01")).toBe(true);
|
||||||
|
await expect.poll(() => insightsQueryHas("to", "2026-01-31")).toBe(true);
|
||||||
|
await expect.poll(() => insightsQueryHas("trade", "Plumbing")).toBe(true);
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Export CSV" }).click();
|
||||||
|
await page.getByRole("button", { name: "Export PDF" }).click();
|
||||||
|
|
||||||
|
const exportRequest = (format: "csv" | "pdf") =>
|
||||||
|
requestedUrls.find((url) => new URL(url).pathname.endsWith(`/insights.${format}`));
|
||||||
|
const exportHasFilters = (url: string | undefined) => {
|
||||||
|
if (!url) return false;
|
||||||
|
const params = new URL(url).searchParams;
|
||||||
|
return (
|
||||||
|
params.get("from") === "2026-01-01" &&
|
||||||
|
params.get("to") === "2026-01-31" &&
|
||||||
|
params.get("trade") === "Plumbing"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect.poll(() => exportHasFilters(exportRequest("csv"))).toBe(true);
|
||||||
|
await expect.poll(() => exportHasFilters(exportRequest("pdf"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reorders and saves preferred vendors for a site", async ({ page }) => {
|
||||||
|
await authenticate(page);
|
||||||
|
let savedPreferences: unknown;
|
||||||
|
await page.route("**/api/Contact/GetLocations", (route) =>
|
||||||
|
fulfillJson(route, { data: [{ Id: 10, Name: "St. Louis HQ" }] }),
|
||||||
|
);
|
||||||
|
await page.route("**/api/Vendor/Dropdown*", (route) =>
|
||||||
|
fulfillJson(route, {
|
||||||
|
data: [
|
||||||
|
{ Id: 7, CompanyName: "Gateway Plumbing", TradeSpecialties: "Plumbing" },
|
||||||
|
{ Id: 8, CompanyName: "Metro HVAC", TradeSpecialties: "HVAC" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route("**/api/vendor-operations/sites/10/preferred-vendors", async (route) => {
|
||||||
|
if (route.request().method() === "PUT") {
|
||||||
|
savedPreferences = route.request().postDataJSON();
|
||||||
|
await fulfillJson(route, savedPreferences);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await fulfillJson(route, [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
locationId: 10,
|
||||||
|
vendorId: 7,
|
||||||
|
vendorName: "Gateway Plumbing",
|
||||||
|
trade: "Plumbing",
|
||||||
|
sortOrder: 0,
|
||||||
|
isStale: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
locationId: 10,
|
||||||
|
vendorId: 8,
|
||||||
|
vendorName: "Metro HVAC",
|
||||||
|
trade: "HVAC",
|
||||||
|
sortOrder: 1,
|
||||||
|
isStale: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/vendors/preferences");
|
||||||
|
await page.getByLabel("Site").click();
|
||||||
|
await page.getByRole("option", { name: "St. Louis HQ" }).click();
|
||||||
|
await expect(page.getByText(/1\. Gateway Plumbing/)).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "Move Metro HVAC up" }).click();
|
||||||
|
await expect(page.getByText(/1\. Metro HVAC/)).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "Save preferences" }).click();
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() => savedPreferences)
|
||||||
|
.toEqual([
|
||||||
|
{ vendorId: 8, trade: "HVAC", sortOrder: 0 },
|
||||||
|
{ vendorId: 7, trade: "Plumbing", sortOrder: 1 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("creates a work order and assigns the selected technician with ETA", async ({ page }) => {
|
||||||
|
await authenticate(page);
|
||||||
|
let assignment: unknown;
|
||||||
|
|
||||||
|
await page.route("**/api/Contact/GetLocations", (route) =>
|
||||||
|
fulfillJson(route, { data: [{ Id: 10, Name: "St. Louis HQ" }] }),
|
||||||
|
);
|
||||||
|
await page.route("**/api/Employee/Dropdown", (route) => fulfillJson(route, { data: [] }));
|
||||||
|
await page.route("**/api/DropdownOptions/ByCategory/**", (route) =>
|
||||||
|
fulfillJson(route, { data: [] }),
|
||||||
|
);
|
||||||
|
await page.route("**/api/vendor-operations/availability*", (route) =>
|
||||||
|
fulfillJson(route, {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
companyName: "Gateway Plumbing",
|
||||||
|
contactName: "Taylor Tech",
|
||||||
|
availabilityStatus: "Available",
|
||||||
|
isPreferred: true,
|
||||||
|
conflicts: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route("**/api/WorkOrder/AddWorkorder", (route) =>
|
||||||
|
fulfillJson(route, {
|
||||||
|
data: {
|
||||||
|
Id: 900,
|
||||||
|
WorkerOrderTitle: "Boiler repair",
|
||||||
|
LocationId: 10,
|
||||||
|
Priority: "Sev 1",
|
||||||
|
Status: "Open",
|
||||||
|
Comments: [],
|
||||||
|
AuditLog: [],
|
||||||
|
Dispatches: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route("**/api/vendor-operations/work-orders/900/assignment", async (route) => {
|
||||||
|
assignment = route.request().postDataJSON();
|
||||||
|
await fulfillJson(route, {
|
||||||
|
availabilityStatus: "Available",
|
||||||
|
conflicts: [
|
||||||
|
{
|
||||||
|
workOrderId: 502,
|
||||||
|
workOrderNumber: "WO-502",
|
||||||
|
start: "2026-07-25T12:00:00Z",
|
||||||
|
end: "2026-07-25T14:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route("**/api/WorkOrder/GetWorkorderById*", (route) =>
|
||||||
|
fulfillJson(route, {
|
||||||
|
data: {
|
||||||
|
Id: 900,
|
||||||
|
WorkerOrderTitle: "Boiler repair",
|
||||||
|
LocationId: 10,
|
||||||
|
Priority: "Sev 1",
|
||||||
|
Status: "Open",
|
||||||
|
Comments: [],
|
||||||
|
AuditLog: [],
|
||||||
|
Dispatches: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto("/workorders/new");
|
||||||
|
await page.getByLabel("Title").fill("Boiler repair");
|
||||||
|
await page.getByLabel("Location").click();
|
||||||
|
await page.getByRole("option", { name: "St. Louis HQ" }).click();
|
||||||
|
await page.getByLabel("Priority").click();
|
||||||
|
await page.getByRole("option", { name: "Sev 1" }).click();
|
||||||
|
await page.getByLabel("Scheduled").fill("2026-07-25");
|
||||||
|
await page.getByLabel("Appointment start").fill("2026-07-25T10:00");
|
||||||
|
await page.getByLabel("Appointment end").fill("2026-07-25T12:00");
|
||||||
|
await page.getByLabel("Company + Technician").click();
|
||||||
|
await page.getByRole("option", { name: /Gateway Plumbing · Taylor Tech/ }).click();
|
||||||
|
await page.getByLabel("Assignment ETA").fill("2026-07-25T08:30");
|
||||||
|
await page.getByLabel("Manual ETA override").check();
|
||||||
|
await page.getByRole("button", { name: "Create" }).click();
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() => assignment)
|
||||||
|
.toEqual({
|
||||||
|
vendorId: 7,
|
||||||
|
scheduledStart: "2026-07-25T10:00:00.000Z",
|
||||||
|
scheduledEnd: "2026-07-25T12:00:00.000Z",
|
||||||
|
estimatedArrivalAt: "2026-07-25T08:30:00.000Z",
|
||||||
|
etaManualOverride: true,
|
||||||
|
});
|
||||||
|
await expect(page.getByText(/scheduling conflict/i)).toBeVisible();
|
||||||
|
await expect(page).toHaveURL(/\/workorders\/900$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preserves an existing ETA when editing in a non-UTC timezone", async ({ browser }) => {
|
||||||
|
const context = await browser.newContext({ timezoneId: "America/New_York" });
|
||||||
|
const page = await context.newPage();
|
||||||
|
await authenticate(page);
|
||||||
|
let assignment: Record<string, unknown> | undefined;
|
||||||
|
|
||||||
|
const workOrder = {
|
||||||
|
Id: 901,
|
||||||
|
WorkerOrderTitle: "Boiler repair",
|
||||||
|
LocationId: 10,
|
||||||
|
Priority: "Sev 1",
|
||||||
|
Status: "Open",
|
||||||
|
CreatedDate: "2026-07-20",
|
||||||
|
ScheduledDate: "2026-07-25",
|
||||||
|
Comments: [],
|
||||||
|
AuditLog: [],
|
||||||
|
Dispatches: [
|
||||||
|
{
|
||||||
|
Id: 44,
|
||||||
|
VendorId: 7,
|
||||||
|
Status: "Acknowledged",
|
||||||
|
EstimatedArrivalAt: "2026-07-25T12:30:00.000Z",
|
||||||
|
EtaManualOverride: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
await page.route("**/api/Contact/GetLocations", (route) =>
|
||||||
|
fulfillJson(route, { data: [{ Id: 10, Name: "St. Louis HQ" }] }),
|
||||||
|
);
|
||||||
|
await page.route("**/api/Employee/Dropdown", (route) => fulfillJson(route, { data: [] }));
|
||||||
|
await page.route("**/api/DropdownOptions/ByCategory/**", (route) =>
|
||||||
|
fulfillJson(route, { data: [] }),
|
||||||
|
);
|
||||||
|
await page.route("**/api/vendor-operations/availability*", (route) =>
|
||||||
|
fulfillJson(route, {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
companyName: "Gateway Plumbing",
|
||||||
|
contactName: "Taylor Tech",
|
||||||
|
availabilityStatus: "Available",
|
||||||
|
isPreferred: true,
|
||||||
|
conflicts: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route("**/api/WorkOrder/GetWorkorderById*", (route) =>
|
||||||
|
fulfillJson(route, { data: workOrder }),
|
||||||
|
);
|
||||||
|
await page.route("**/api/WorkOrder/EditWorkorder", (route) =>
|
||||||
|
fulfillJson(route, { data: workOrder }),
|
||||||
|
);
|
||||||
|
await page.route("**/api/vendor-operations/work-orders/901/assignment", async (route) => {
|
||||||
|
assignment = route.request().postDataJSON() as Record<string, unknown>;
|
||||||
|
await fulfillJson(route, { availabilityStatus: "Available", conflicts: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/workorders/edit/901");
|
||||||
|
await expect(page.getByLabel("Assignment ETA")).toHaveValue("2026-07-25T08:30");
|
||||||
|
await page.getByRole("button", { name: "Update" }).click();
|
||||||
|
|
||||||
|
await expect.poll(() => assignment?.estimatedArrivalAt).toBe("2026-07-25T12:30:00.000Z");
|
||||||
|
await context.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("converts explicit local schedule bounds to the correct UTC instants across a DST transition", async ({
|
||||||
|
browser,
|
||||||
|
}) => {
|
||||||
|
// America/New_York springs forward on 2026-03-08 at 02:00 EST -> 03:00 EDT.
|
||||||
|
// The window below straddles the transition: 01:00 is EST (UTC-5) while
|
||||||
|
// 04:00 is EDT (UTC-4). Each bound must resolve its own offset, proving no
|
||||||
|
// fixed-window assumption remains.
|
||||||
|
const context = await browser.newContext({ timezoneId: "America/New_York" });
|
||||||
|
const page = await context.newPage();
|
||||||
|
await authenticate(page);
|
||||||
|
let assignment: Record<string, unknown> | undefined;
|
||||||
|
|
||||||
|
await page.route("**/api/Contact/GetLocations", (route) =>
|
||||||
|
fulfillJson(route, { data: [{ Id: 10, Name: "St. Louis HQ" }] }),
|
||||||
|
);
|
||||||
|
await page.route("**/api/Employee/Dropdown", (route) => fulfillJson(route, { data: [] }));
|
||||||
|
await page.route("**/api/DropdownOptions/ByCategory/**", (route) =>
|
||||||
|
fulfillJson(route, { data: [] }),
|
||||||
|
);
|
||||||
|
await page.route("**/api/vendor-operations/availability*", (route) =>
|
||||||
|
fulfillJson(route, {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
companyName: "Gateway Plumbing",
|
||||||
|
contactName: "Taylor Tech",
|
||||||
|
availabilityStatus: "Available",
|
||||||
|
isPreferred: true,
|
||||||
|
conflicts: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route("**/api/WorkOrder/AddWorkorder", (route) =>
|
||||||
|
fulfillJson(route, {
|
||||||
|
data: {
|
||||||
|
Id: 902,
|
||||||
|
WorkerOrderTitle: "Boiler repair",
|
||||||
|
LocationId: 10,
|
||||||
|
Priority: "Sev 1",
|
||||||
|
Status: "Open",
|
||||||
|
Comments: [],
|
||||||
|
AuditLog: [],
|
||||||
|
Dispatches: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route("**/api/vendor-operations/work-orders/902/assignment", async (route) => {
|
||||||
|
assignment = route.request().postDataJSON() as Record<string, unknown>;
|
||||||
|
await fulfillJson(route, { availabilityStatus: "Available", conflicts: [] });
|
||||||
|
});
|
||||||
|
await page.route("**/api/WorkOrder/GetWorkorderById*", (route) =>
|
||||||
|
fulfillJson(route, {
|
||||||
|
data: {
|
||||||
|
Id: 902,
|
||||||
|
WorkerOrderTitle: "Boiler repair",
|
||||||
|
LocationId: 10,
|
||||||
|
Priority: "Sev 1",
|
||||||
|
Status: "Open",
|
||||||
|
Comments: [],
|
||||||
|
AuditLog: [],
|
||||||
|
Dispatches: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto("/workorders/new");
|
||||||
|
await page.getByLabel("Title").fill("Boiler repair");
|
||||||
|
await page.getByLabel("Location").click();
|
||||||
|
await page.getByRole("option", { name: "St. Louis HQ" }).click();
|
||||||
|
await page.getByLabel("Priority").click();
|
||||||
|
await page.getByRole("option", { name: "Sev 1" }).click();
|
||||||
|
await page.getByLabel("Appointment start").fill("2026-03-08T01:00");
|
||||||
|
await page.getByLabel("Appointment end").fill("2026-03-08T04:00");
|
||||||
|
await page.getByLabel("Company + Technician").click();
|
||||||
|
await page.getByRole("option", { name: /Gateway Plumbing · Taylor Tech/ }).click();
|
||||||
|
await page.getByRole("button", { name: "Create" }).click();
|
||||||
|
|
||||||
|
await expect.poll(() => assignment?.scheduledStart).toBe("2026-03-08T06:00:00.000Z");
|
||||||
|
await expect.poll(() => assignment?.scheduledEnd).toBe("2026-03-08T08:00:00.000Z");
|
||||||
|
await context.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("focuses the Vendor & Time section when arriving from a no-vendor alert", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await authenticate(page);
|
||||||
|
await page.route("**/api/Contact/GetLocations", (route) =>
|
||||||
|
fulfillJson(route, { data: [{ Id: 10, Name: "St. Louis HQ" }] }),
|
||||||
|
);
|
||||||
|
await page.route("**/api/Employee/Dropdown", (route) => fulfillJson(route, { data: [] }));
|
||||||
|
await page.route("**/api/DropdownOptions/ByCategory/**", (route) =>
|
||||||
|
fulfillJson(route, { data: [] }),
|
||||||
|
);
|
||||||
|
await page.route("**/api/vendor-operations/availability*", (route) =>
|
||||||
|
fulfillJson(route, {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
companyName: "Gateway Plumbing",
|
||||||
|
contactName: "Taylor Tech",
|
||||||
|
availabilityStatus: "Available",
|
||||||
|
isPreferred: true,
|
||||||
|
conflicts: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route("**/api/WorkOrder/GetWorkorderById*", (route) =>
|
||||||
|
fulfillJson(route, {
|
||||||
|
data: {
|
||||||
|
Id: 501,
|
||||||
|
WorkerOrderTitle: "Boiler repair",
|
||||||
|
LocationId: 10,
|
||||||
|
Priority: "Sev 1",
|
||||||
|
Status: "Open",
|
||||||
|
Comments: [],
|
||||||
|
AuditLog: [],
|
||||||
|
Dispatches: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto("/workorders/edit/501?assignVendor=1");
|
||||||
|
|
||||||
|
const vendorSection = page.getByRole("region", { name: "Vendor & Time" });
|
||||||
|
await expect(vendorSection).toBeVisible();
|
||||||
|
await expect(vendorSection).toBeFocused();
|
||||||
|
await page.getByLabel("Company + Technician").click();
|
||||||
|
const option = page.getByRole("option", { name: /Gateway Plumbing · Taylor Tech/ });
|
||||||
|
await expect(option).toContainText("Unknown");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -10,6 +10,10 @@ export default defineConfig({
|
||||||
use: {
|
use: {
|
||||||
// Port 4173 isolates Playwright from the app's normal port 3000 server.
|
// Port 4173 isolates Playwright from the app's normal port 3000 server.
|
||||||
baseURL: "http://localhost:4173",
|
baseURL: "http://localhost:4173",
|
||||||
|
// Pin the browser timezone to UTC so date-to-ISO assertions in the default
|
||||||
|
// create flow are deterministic regardless of host locale. DST-transition
|
||||||
|
// scenarios override this with their own non-UTC context.
|
||||||
|
timezoneId: "UTC",
|
||||||
trace: "on-first-retry",
|
trace: "on-first-retry",
|
||||||
},
|
},
|
||||||
projects: [
|
projects: [
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,6 @@
|
||||||
"maxLines": 782,
|
"maxLines": 782,
|
||||||
"reason": "Dispatch detail modal; oversized legacy component pending extraction of sections/fields."
|
"reason": "Dispatch detail modal; oversized legacy component pending extraction of sections/fields."
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"path": "src/app/v/[token]/dispatch/[id].tsx",
|
|
||||||
"maxLines": 651,
|
|
||||||
"reason": "Vendor-portal dispatch detail route; legacy page pending decomposition."
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"path": "src/app/(protected)/vendors/index.tsx",
|
"path": "src/app/(protected)/vendors/index.tsx",
|
||||||
"maxLines": 612,
|
"maxLines": 612,
|
||||||
|
|
|
||||||
104
src/app/(protected)/notifications/index.tsx
Normal file
104
src/app/(protected)/notifications/index.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
CircularProgress,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { Link } from "react-router";
|
||||||
|
import { vendorOperationsApi } from "@/domain/vendor-operations/api/vendor-operations-api";
|
||||||
|
|
||||||
|
export default function NotificationsPage() {
|
||||||
|
const {
|
||||||
|
data = [],
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["vendor-operations", "notifications"],
|
||||||
|
queryFn: vendorOperationsApi.notifications,
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const groups = data.reduce((result, item) => {
|
||||||
|
const items = result.get(item.group) ?? [];
|
||||||
|
items.push(item);
|
||||||
|
result.set(item.group, items);
|
||||||
|
return result;
|
||||||
|
}, new Map<string, typeof data>());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className="flex flex-col gap-4 p-4">
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h5" component="h1">
|
||||||
|
Items requiring attention
|
||||||
|
</Typography>
|
||||||
|
<Typography color="text.secondary">
|
||||||
|
Medium-severity vendor reminders refresh automatically. They do not create banners or
|
||||||
|
toasts.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
{isLoading && <CircularProgress aria-label="Loading notifications" />}
|
||||||
|
{Boolean(error) && <Alert severity="error">{error?.message}</Alert>}
|
||||||
|
{!isLoading && !error && data.length === 0 && (
|
||||||
|
<Paper variant="outlined" className="p-6">
|
||||||
|
<Typography>No vendor alerts require attention.</Typography>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
{[...groups.entries()].map(([group, items]) => (
|
||||||
|
<Paper key={group} variant="outlined" className="p-4">
|
||||||
|
<Stack direction="row" spacing={1} className="mb-3" sx={{ alignItems: "center" }}>
|
||||||
|
<Typography variant="h6">{group}</Typography>
|
||||||
|
<Chip label={items.length} size="small" />
|
||||||
|
</Stack>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
{items.map((item) => (
|
||||||
|
<Paper key={item.id} variant="outlined" className="p-3">
|
||||||
|
<Stack
|
||||||
|
direction={{ xs: "column", md: "row" }}
|
||||||
|
sx={{ gap: 2, justifyContent: "space-between" }}
|
||||||
|
>
|
||||||
|
<Box>
|
||||||
|
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
||||||
|
<Chip label={item.severity} color="warning" size="small" />
|
||||||
|
<Typography sx={{ fontWeight: 600 }}>{item.title}</Typography>
|
||||||
|
</Stack>
|
||||||
|
<Typography color="text.secondary" className="mt-1">
|
||||||
|
{item.workOrderNumber ??
|
||||||
|
item.workOrders?.map((workOrder) => workOrder.workOrderNumber).join(" ↔ ")}
|
||||||
|
</Typography>
|
||||||
|
{item.scheduledAt != null && (
|
||||||
|
<Typography variant="caption">
|
||||||
|
Scheduled {new Date(item.scheduledAt).toLocaleString()}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
||||||
|
{item.action != null && (
|
||||||
|
<Button component={Link} to={item.action.href} variant="contained">
|
||||||
|
{item.action.label}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{item.actions?.map((action) => (
|
||||||
|
<Button
|
||||||
|
key={action.href}
|
||||||
|
component={Link}
|
||||||
|
to={action.href}
|
||||||
|
variant="outlined"
|
||||||
|
>
|
||||||
|
{action.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
123
src/app/(protected)/vendors/insights.tsx
vendored
Normal file
123
src/app/(protected)/vendors/insights.tsx
vendored
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { vendorOperationsApi } from "@/domain/vendor-operations/api/vendor-operations-api";
|
||||||
|
|
||||||
|
function percent(value: number) {
|
||||||
|
return `${(Number(value || 0) * 100).toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VendorInsightsPage() {
|
||||||
|
const [from, setFrom] = useState("");
|
||||||
|
const [to, setTo] = useState("");
|
||||||
|
const [trade, setTrade] = useState("");
|
||||||
|
const params = { from: from || undefined, to: to || undefined, trade: trade || undefined };
|
||||||
|
const {
|
||||||
|
data = [],
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["vendor-operations", "insights", params],
|
||||||
|
queryFn: () => vendorOperationsApi.insights(params),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className="flex flex-col gap-4 p-4">
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h5" component="h1">
|
||||||
|
Vendor performance insights
|
||||||
|
</Typography>
|
||||||
|
<Typography color="text.secondary">
|
||||||
|
Metrics include inactive vendors for historical reporting and use the same filtered
|
||||||
|
dispatch population.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Paper variant="outlined" className="p-4">
|
||||||
|
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||||
|
<TextField
|
||||||
|
label="From"
|
||||||
|
type="date"
|
||||||
|
value={from}
|
||||||
|
onChange={(event) => setFrom(event.target.value)}
|
||||||
|
slotProps={{ inputLabel: { shrink: true } }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="To"
|
||||||
|
type="date"
|
||||||
|
value={to}
|
||||||
|
onChange={(event) => setTo(event.target.value)}
|
||||||
|
slotProps={{ inputLabel: { shrink: true } }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Trade"
|
||||||
|
value={trade}
|
||||||
|
onChange={(event) => setTrade(event.target.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => void vendorOperationsApi.downloadInsights("csv", params)}
|
||||||
|
>
|
||||||
|
Export CSV
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => void vendorOperationsApi.downloadInsights("pdf", params)}
|
||||||
|
>
|
||||||
|
Export PDF
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
{isLoading && <CircularProgress aria-label="Loading vendor insights" />}
|
||||||
|
{Boolean(error) && <Alert severity="error">{error?.message}</Alert>}
|
||||||
|
{!isLoading && !error && data.length === 0 && (
|
||||||
|
<Alert severity="info">No data matches the selected filters.</Alert>
|
||||||
|
)}
|
||||||
|
{data.length > 0 && (
|
||||||
|
<Paper variant="outlined" sx={{ overflowX: "auto" }}>
|
||||||
|
<Table aria-label="Vendor performance metrics">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Vendor</TableCell>
|
||||||
|
<TableCell align="right">Completed</TableCell>
|
||||||
|
<TableCell align="right">Cancellation / refusal</TableCell>
|
||||||
|
<TableCell align="right">On-time arrival</TableCell>
|
||||||
|
<TableCell align="right">On-time completion</TableCell>
|
||||||
|
<TableCell align="right">Avg. cycle</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{data.map((row) => (
|
||||||
|
<TableRow key={row.vendorId}>
|
||||||
|
<TableCell>{row.vendor}</TableCell>
|
||||||
|
<TableCell align="right">{row.completedJobs}</TableCell>
|
||||||
|
<TableCell align="right">{percent(row.cancellationRefusalRate)}</TableCell>
|
||||||
|
<TableCell align="right">{percent(row.onTimeArrivalRate)}</TableCell>
|
||||||
|
<TableCell align="right">{percent(row.onTimeCompletionRate)}</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
{row.averageCycleHours == null
|
||||||
|
? "Insufficient data"
|
||||||
|
: `${Number(row.averageCycleHours).toFixed(1)}h`}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
254
src/app/(protected)/vendors/preferences.tsx
vendored
Normal file
254
src/app/(protected)/vendors/preferences.tsx
vendored
Normal file
|
|
@ -0,0 +1,254 @@
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Autocomplete,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
IconButton,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
TextField,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { ArrowDown, ArrowUp, Trash2 } from "lucide-react";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { vendorOperationsApi } from "@/domain/vendor-operations/api/vendor-operations-api";
|
||||||
|
import type { SiteVendorPreference } from "@/domain/vendor-operations/types/vendor-operations";
|
||||||
|
import {
|
||||||
|
useWorkOrderLocations,
|
||||||
|
useWorkOrderVendors,
|
||||||
|
} from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||||
|
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||||
|
|
||||||
|
const EMPTY_PREFERENCES: SiteVendorPreference[] = [];
|
||||||
|
|
||||||
|
type AddVendorPreferenceControlsProps = {
|
||||||
|
trade: string;
|
||||||
|
onTradeChange: (value: string) => void;
|
||||||
|
vendorOptions: VendorDropdownItem[];
|
||||||
|
vendors: VendorDropdownItem[];
|
||||||
|
vendorId: number | null;
|
||||||
|
onVendorChange: (vendorId: number | null) => void;
|
||||||
|
onAdd: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function AddVendorPreferenceControls({
|
||||||
|
trade,
|
||||||
|
onTradeChange,
|
||||||
|
vendorOptions,
|
||||||
|
vendors,
|
||||||
|
vendorId,
|
||||||
|
onVendorChange,
|
||||||
|
onAdd,
|
||||||
|
}: AddVendorPreferenceControlsProps) {
|
||||||
|
return (
|
||||||
|
<Paper variant="outlined" className="p-4">
|
||||||
|
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||||
|
<TextField
|
||||||
|
label="Trade scope (optional)"
|
||||||
|
value={trade}
|
||||||
|
onChange={(event) => onTradeChange(event.target.value)}
|
||||||
|
/>
|
||||||
|
<Autocomplete
|
||||||
|
options={vendorOptions}
|
||||||
|
getOptionLabel={(option) =>
|
||||||
|
`${option.companyName}${option.tradeSpecialties ? ` · ${option.tradeSpecialties}` : ""}`
|
||||||
|
}
|
||||||
|
value={vendors.find((vendor) => Number(vendor.id) === vendorId) ?? null}
|
||||||
|
onChange={(_event, option) => onVendorChange(option ? Number(option.id) : null)}
|
||||||
|
renderInput={(params) => <TextField {...params} label="Vendor / technician" />}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button variant="contained" onClick={onAdd} disabled={vendorId == null}>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type PreferenceRowProps = {
|
||||||
|
item: SiteVendorPreference;
|
||||||
|
index: number;
|
||||||
|
total: number;
|
||||||
|
onMove: (index: number, offset: number) => void;
|
||||||
|
onRemove: (index: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function PreferenceRow({ item, index, total, onMove, onRemove }: PreferenceRowProps) {
|
||||||
|
return (
|
||||||
|
<Paper variant="outlined" className="p-3">
|
||||||
|
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
||||||
|
<Text className="flex-1">
|
||||||
|
{index + 1}. {item.vendorName} {item.trade ? `· ${item.trade}` : "· All trades"}
|
||||||
|
</Text>
|
||||||
|
{item.isStale && (
|
||||||
|
<Alert severity="warning">
|
||||||
|
<Text as="span">Stale preference</Text>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<IconButton
|
||||||
|
aria-label={`Move ${item.vendorName} up`}
|
||||||
|
onClick={() => onMove(index, -1)}
|
||||||
|
disabled={index === 0}
|
||||||
|
>
|
||||||
|
<ArrowUp size={18} />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
aria-label={`Move ${item.vendorName} down`}
|
||||||
|
onClick={() => onMove(index, 1)}
|
||||||
|
disabled={index === total - 1}
|
||||||
|
>
|
||||||
|
<ArrowDown size={18} />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton aria-label={`Remove ${item.vendorName}`} onClick={() => onRemove(index)}>
|
||||||
|
<Trash2 size={18} />
|
||||||
|
</IconButton>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VendorPreferencesPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [locationId, setLocationId] = useState<string | number>("");
|
||||||
|
const [draft, setDraft] = useState<SiteVendorPreference[]>([]);
|
||||||
|
const [isDirty, setIsDirty] = useState(false);
|
||||||
|
const [trade, setTrade] = useState("");
|
||||||
|
const [vendorId, setVendorId] = useState<number | null>(null);
|
||||||
|
const { data: locations = [] } = useWorkOrderLocations();
|
||||||
|
const { data: vendors = [] } = useWorkOrderVendors(trade || undefined);
|
||||||
|
const queryKey = ["vendor-operations", "site-preferences", locationId];
|
||||||
|
const { data: serverPreferences = EMPTY_PREFERENCES, error } = useQuery({
|
||||||
|
queryKey,
|
||||||
|
queryFn: () => vendorOperationsApi.sitePreferences(locationId),
|
||||||
|
enabled: locationId !== "",
|
||||||
|
});
|
||||||
|
|
||||||
|
// A background refetch must not clobber in-flight edits, so only adopt server
|
||||||
|
// data while the draft is clean (initial load, site change, or after a save).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isDirty) {
|
||||||
|
setDraft(serverPreferences);
|
||||||
|
}
|
||||||
|
}, [isDirty, serverPreferences]);
|
||||||
|
|
||||||
|
const vendorOptions = useMemo(() => {
|
||||||
|
const selectedVendorIds = new Set(draft.map((preference) => preference.vendorId));
|
||||||
|
return vendors.filter((vendor) => !selectedVendorIds.has(Number(vendor.id)));
|
||||||
|
}, [draft, vendors]);
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
vendorOperationsApi.replaceSitePreferences(
|
||||||
|
locationId,
|
||||||
|
draft.map((item, sortOrder) => ({ vendorId: item.vendorId, trade: item.trade, sortOrder })),
|
||||||
|
),
|
||||||
|
onMutate: async () => {
|
||||||
|
await queryClient.cancelQueries({ queryKey });
|
||||||
|
},
|
||||||
|
onSuccess: (savedPreferences) => {
|
||||||
|
queryClient.setQueryData(queryKey, savedPreferences);
|
||||||
|
setDraft(savedPreferences);
|
||||||
|
setIsDirty(false);
|
||||||
|
toast.success("Preferred vendors updated");
|
||||||
|
},
|
||||||
|
onError: () => toast.error("Unable to save preferred vendors. Please try again."),
|
||||||
|
});
|
||||||
|
|
||||||
|
const move = (index: number, offset: number) => {
|
||||||
|
setDraft((current) => {
|
||||||
|
const target = index + offset;
|
||||||
|
if (target < 0 || target >= current.length) return current;
|
||||||
|
const next = [...current];
|
||||||
|
[next[index], next[target]] = [next[target]!, next[index]!];
|
||||||
|
return next.map((item, sortOrder) => ({ ...item, sortOrder }));
|
||||||
|
});
|
||||||
|
setIsDirty(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const add = () => {
|
||||||
|
const vendor = vendors.find((item) => Number(item.id) === vendorId);
|
||||||
|
if (!vendor || locationId === "") return;
|
||||||
|
setDraft((current) => [
|
||||||
|
...current,
|
||||||
|
{
|
||||||
|
id: -Date.now(),
|
||||||
|
locationId: Number(locationId),
|
||||||
|
vendorId: Number(vendor.id),
|
||||||
|
vendorName: vendor.companyName,
|
||||||
|
trade: trade.trim() || undefined,
|
||||||
|
sortOrder: current.length,
|
||||||
|
isStale: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
setVendorId(null);
|
||||||
|
setIsDirty(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = (index: number) => {
|
||||||
|
setDraft((current) => current.filter((_, row) => row !== index));
|
||||||
|
setIsDirty(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectLocation = (nextLocationId: string | number) => {
|
||||||
|
setLocationId(nextLocationId);
|
||||||
|
setDraft(EMPTY_PREFERENCES);
|
||||||
|
setIsDirty(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className="flex flex-col gap-4 p-4">
|
||||||
|
<Box>
|
||||||
|
<Text variant="title" as="h1">
|
||||||
|
Preferred vendors by site
|
||||||
|
</Text>
|
||||||
|
<Text variant="description" tone="muted">
|
||||||
|
Preferred vendors rank first, but availability, conflict, trade, active-state, and
|
||||||
|
permission checks still apply.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Paper variant="outlined" className="p-4">
|
||||||
|
<Autocomplete
|
||||||
|
options={locations}
|
||||||
|
getOptionLabel={(option) => option.name}
|
||||||
|
value={locations.find((location) => location.id === locationId) ?? null}
|
||||||
|
onChange={(_event, option) => selectLocation(option?.id ?? "")}
|
||||||
|
renderInput={(params) => <TextField {...params} label="Site" />}
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
|
<Text variant="error" when={Boolean(error)}>
|
||||||
|
Unable to load preferred vendors. Please try again.
|
||||||
|
</Text>
|
||||||
|
{locationId !== "" && (
|
||||||
|
<>
|
||||||
|
<AddVendorPreferenceControls
|
||||||
|
trade={trade}
|
||||||
|
onTradeChange={setTrade}
|
||||||
|
vendorOptions={vendorOptions}
|
||||||
|
vendors={vendors}
|
||||||
|
vendorId={vendorId}
|
||||||
|
onVendorChange={setVendorId}
|
||||||
|
onAdd={add}
|
||||||
|
/>
|
||||||
|
<Stack spacing={1}>
|
||||||
|
{draft.map((item, index) => (
|
||||||
|
<PreferenceRow
|
||||||
|
key={`${item.vendorId}-${item.trade ?? "all"}`}
|
||||||
|
item={item}
|
||||||
|
index={index}
|
||||||
|
total={draft.length}
|
||||||
|
onMove={move}
|
||||||
|
onRemove={remove}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
<Button variant="contained" onClick={() => save.mutate()} disabled={save.isPending}>
|
||||||
|
{save.isPending ? "Saving…" : "Save preferences"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,153 @@
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Autocomplete,
|
||||||
|
Box,
|
||||||
|
Chip,
|
||||||
|
FormControlLabel,
|
||||||
|
Stack,
|
||||||
|
Switch,
|
||||||
|
TextField,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import type { VendorAvailability } from "@/domain/vendor-operations/types/vendor-operations";
|
||||||
|
|
||||||
|
function SectionTitle({ children }: { children: string }) {
|
||||||
|
return (
|
||||||
|
<Text variant="label" className="mb-3 font-semibold uppercase tracking-wide">
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type VendorAndTimeSectionProps = {
|
||||||
|
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
availableVendors: VendorAvailability[];
|
||||||
|
isLoadingVendors: boolean;
|
||||||
|
selectedVendorId: number | null;
|
||||||
|
onSelectVendor: (id: number | null) => void;
|
||||||
|
estimatedArrivalAt: string;
|
||||||
|
onEtaChange: (value: string) => void;
|
||||||
|
etaManualOverride: boolean;
|
||||||
|
onOverrideChange: (value: boolean) => void;
|
||||||
|
scheduledStartLocal: string;
|
||||||
|
onScheduledStartChange: (value: string) => void;
|
||||||
|
scheduledEndLocal: string;
|
||||||
|
onScheduledEndChange: (value: string) => void;
|
||||||
|
scheduleBoundsError: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function VendorAndTimeSection({
|
||||||
|
containerRef,
|
||||||
|
availableVendors,
|
||||||
|
isLoadingVendors,
|
||||||
|
selectedVendorId,
|
||||||
|
onSelectVendor,
|
||||||
|
estimatedArrivalAt,
|
||||||
|
onEtaChange,
|
||||||
|
etaManualOverride,
|
||||||
|
onOverrideChange,
|
||||||
|
scheduledStartLocal,
|
||||||
|
onScheduledStartChange,
|
||||||
|
scheduledEndLocal,
|
||||||
|
onScheduledEndChange,
|
||||||
|
scheduleBoundsError,
|
||||||
|
}: VendorAndTimeSectionProps) {
|
||||||
|
const hasBoundsError = Boolean(scheduleBoundsError);
|
||||||
|
return (
|
||||||
|
<Box ref={containerRef} role="region" aria-label="Vendor & Time" tabIndex={-1}>
|
||||||
|
<SectionTitle>Vendor & Time</SectionTitle>
|
||||||
|
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||||
|
<TextField
|
||||||
|
label="Appointment start"
|
||||||
|
type="datetime-local"
|
||||||
|
value={scheduledStartLocal}
|
||||||
|
onChange={(event) => onScheduledStartChange(event.target.value)}
|
||||||
|
error={hasBoundsError}
|
||||||
|
slotProps={{ inputLabel: { shrink: true } }}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Appointment end"
|
||||||
|
type="datetime-local"
|
||||||
|
value={scheduledEndLocal}
|
||||||
|
onChange={(event) => onScheduledEndChange(event.target.value)}
|
||||||
|
error={hasBoundsError}
|
||||||
|
slotProps={{ inputLabel: { shrink: true } }}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
<Text variant="error" when={hasBoundsError} className="mt-1">
|
||||||
|
{scheduleBoundsError}
|
||||||
|
</Text>
|
||||||
|
<Stack direction={{ xs: "column", md: "row" }} spacing={2} className="mt-2">
|
||||||
|
<Autocomplete
|
||||||
|
options={availableVendors}
|
||||||
|
loading={isLoadingVendors}
|
||||||
|
getOptionLabel={(option) =>
|
||||||
|
`${option.companyName}${option.contactName ? ` · ${option.contactName}` : ""}`
|
||||||
|
}
|
||||||
|
value={availableVendors.find((vendor) => vendor.id === selectedVendorId) ?? null}
|
||||||
|
onChange={(_event, option) => onSelectVendor(option?.id ?? null)}
|
||||||
|
renderOption={(props, option) => (
|
||||||
|
<li {...props} key={option.id}>
|
||||||
|
<Stack className="w-full">
|
||||||
|
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
||||||
|
<Text as="span">
|
||||||
|
{option.companyName}
|
||||||
|
{option.contactName ? ` · ${option.contactName}` : ""}
|
||||||
|
</Text>
|
||||||
|
{option.isPreferred && <Chip label="Preferred" color="primary" size="small" />}
|
||||||
|
<Chip
|
||||||
|
label={option.availabilityStatus}
|
||||||
|
color={
|
||||||
|
option.availabilityStatus === "Available"
|
||||||
|
? "success"
|
||||||
|
: option.availabilityStatus === "Unavailable"
|
||||||
|
? "error"
|
||||||
|
: "default"
|
||||||
|
}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
{option.conflicts.length > 0 && (
|
||||||
|
<Text variant="caption" tone="error">
|
||||||
|
Conflicts with{" "}
|
||||||
|
{option.conflicts.map((conflict) => conflict.workOrderNumber).join(", ")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
renderInput={(params) => <TextField {...params} label="Company + Technician" />}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Assignment ETA"
|
||||||
|
type="datetime-local"
|
||||||
|
value={estimatedArrivalAt}
|
||||||
|
onChange={(event) => onEtaChange(event.target.value)}
|
||||||
|
slotProps={{ inputLabel: { shrink: true } }}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={etaManualOverride}
|
||||||
|
onChange={(event) => onOverrideChange(event.target.checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Manual ETA override"
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
{selectedVendorId != null &&
|
||||||
|
availableVendors.find((vendor) => vendor.id === selectedVendorId)?.availabilityStatus ===
|
||||||
|
"Unknown" && (
|
||||||
|
<Alert severity="warning" className="mt-2">
|
||||||
|
<Text as="span">
|
||||||
|
Availability is unknown. This selection is not treated as confirmed availability.
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
319
src/app/(protected)/workorders/_components/work-order-fields.tsx
Normal file
319
src/app/(protected)/workorders/_components/work-order-fields.tsx
Normal file
|
|
@ -0,0 +1,319 @@
|
||||||
|
import { Autocomplete, Box, MenuItem, Stack, TextField } from "@mui/material";
|
||||||
|
import { Controller } from "react-hook-form";
|
||||||
|
import type { Control, FieldErrors, UseFormRegister } from "react-hook-form";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import type { DropdownOption } from "@/domain/settings/dropdown-options/types/dropdown-option";
|
||||||
|
import type { WorkOrderFormSchemaValues } from "@/domain/work-orders/schemas/work-order-schema";
|
||||||
|
import {
|
||||||
|
WORK_ORDER_PRIORITY_OPTIONS,
|
||||||
|
WORK_ORDER_STATUS_OPTIONS,
|
||||||
|
type LocationOption,
|
||||||
|
} from "@/domain/work-orders/types/work-order";
|
||||||
|
import type { EmployeeOption } from "@/domain/work-orders/types/work-order-common";
|
||||||
|
|
||||||
|
function SectionTitle({ children }: { children: string }) {
|
||||||
|
return (
|
||||||
|
<Text variant="label" className="mb-3 font-semibold uppercase tracking-wide">
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type FormFieldHandlers = {
|
||||||
|
register: UseFormRegister<WorkOrderFormSchemaValues>;
|
||||||
|
control: Control<WorkOrderFormSchemaValues>;
|
||||||
|
errors: FieldErrors<WorkOrderFormSchemaValues>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WorkOrderDetailsFieldsProps = FormFieldHandlers & {
|
||||||
|
locationOptions: LocationOption[];
|
||||||
|
isLoadingLocations: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WorkOrderDetailsFields({
|
||||||
|
register,
|
||||||
|
control,
|
||||||
|
errors,
|
||||||
|
locationOptions,
|
||||||
|
isLoadingLocations,
|
||||||
|
}: WorkOrderDetailsFieldsProps) {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<SectionTitle>Work Order Details</SectionTitle>
|
||||||
|
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||||
|
<TextField
|
||||||
|
{...register("workOrderNumber")}
|
||||||
|
label="Customer WO"
|
||||||
|
placeholder="Customer work order number"
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
{...register("title")}
|
||||||
|
label="Title"
|
||||||
|
required
|
||||||
|
error={Boolean(errors.title)}
|
||||||
|
helperText={errors.title?.message}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
<Stack direction={{ xs: "column", md: "row" }} spacing={2} className="mt-4">
|
||||||
|
<Controller
|
||||||
|
name="locationId"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Autocomplete
|
||||||
|
options={locationOptions}
|
||||||
|
loading={isLoadingLocations}
|
||||||
|
getOptionLabel={(option: LocationOption) => option.name}
|
||||||
|
value={
|
||||||
|
locationOptions.find((location) => String(location.id) === field.value) ?? null
|
||||||
|
}
|
||||||
|
onChange={(_event, option) => field.onChange(option ? String(option.id) : "")}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label="Location"
|
||||||
|
required
|
||||||
|
error={Boolean(errors.locationId)}
|
||||||
|
helperText={errors.locationId?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
{...register("source")}
|
||||||
|
label="Source"
|
||||||
|
placeholder="e.g., Amazon APM, Manual"
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClassificationFieldsProps = {
|
||||||
|
control: Control<WorkOrderFormSchemaValues>;
|
||||||
|
problems: DropdownOption[];
|
||||||
|
trades: DropdownOption[];
|
||||||
|
subTrades: DropdownOption[];
|
||||||
|
tradeValue: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ClassificationFields({
|
||||||
|
control,
|
||||||
|
problems,
|
||||||
|
trades,
|
||||||
|
subTrades,
|
||||||
|
tradeValue,
|
||||||
|
}: ClassificationFieldsProps) {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<SectionTitle>Classification</SectionTitle>
|
||||||
|
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||||
|
<Controller
|
||||||
|
name="problem"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField {...field} value={field.value ?? ""} label="Problem" select fullWidth>
|
||||||
|
<MenuItem value="">Select Problem</MenuItem>
|
||||||
|
{problems.map((option) => (
|
||||||
|
<MenuItem key={String(option.id)} value={option.value}>
|
||||||
|
{option.value}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="trade"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField {...field} value={field.value ?? ""} label="Trade" select fullWidth>
|
||||||
|
<MenuItem value="">Select Trade</MenuItem>
|
||||||
|
{trades.map((option) => (
|
||||||
|
<MenuItem key={String(option.id)} value={option.value}>
|
||||||
|
{option.value}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="subTrade"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
value={field.value ?? ""}
|
||||||
|
label="Sub-Trade"
|
||||||
|
select
|
||||||
|
disabled={!tradeValue}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<MenuItem value="">
|
||||||
|
{tradeValue ? "Select Sub-Trade" : "Select a Trade first"}
|
||||||
|
</MenuItem>
|
||||||
|
{subTrades.map((option) => (
|
||||||
|
<MenuItem key={String(option.id)} value={option.value}>
|
||||||
|
{option.value}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScheduleFieldsProps = FormFieldHandlers & {
|
||||||
|
employees: EmployeeOption[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ScheduleFields({ register, control, errors, employees }: ScheduleFieldsProps) {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<SectionTitle>Schedule & Priority</SectionTitle>
|
||||||
|
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||||
|
<Controller
|
||||||
|
name="priority"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
value={field.value ?? ""}
|
||||||
|
label="Priority"
|
||||||
|
select
|
||||||
|
required
|
||||||
|
error={Boolean(errors.priority)}
|
||||||
|
helperText={errors.priority?.message}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<MenuItem value="">Select Priority</MenuItem>
|
||||||
|
{WORK_ORDER_PRIORITY_OPTIONS.map((priority) => (
|
||||||
|
<MenuItem key={priority} value={priority}>
|
||||||
|
{priority}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="status"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
value={field.value ?? "Open"}
|
||||||
|
label="Status"
|
||||||
|
select
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
{WORK_ORDER_STATUS_OPTIONS.map((status) => (
|
||||||
|
<MenuItem key={status} value={status}>
|
||||||
|
{status}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="assignedTo"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Autocomplete
|
||||||
|
freeSolo
|
||||||
|
options={employees.map((employee) => employee.name)}
|
||||||
|
value={field.value}
|
||||||
|
onChange={(_event, value) => field.onChange(value ?? "")}
|
||||||
|
onInputChange={(_event, value) => field.onChange(value)}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField {...params} label="Assigned To" placeholder="Assigned to" />
|
||||||
|
)}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} className="mt-4">
|
||||||
|
<TextField
|
||||||
|
{...register("createdDate")}
|
||||||
|
label="Created"
|
||||||
|
type="date"
|
||||||
|
required
|
||||||
|
error={Boolean(errors.createdDate)}
|
||||||
|
helperText={errors.createdDate?.message}
|
||||||
|
slotProps={{ inputLabel: { shrink: true } }}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
{...register("dueDate")}
|
||||||
|
label="Due Date"
|
||||||
|
type="date"
|
||||||
|
slotProps={{ inputLabel: { shrink: true } }}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
{...register("scheduledDate")}
|
||||||
|
label="Scheduled"
|
||||||
|
type="date"
|
||||||
|
slotProps={{ inputLabel: { shrink: true } }}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
{...register("completedDate")}
|
||||||
|
label="Completed"
|
||||||
|
type="date"
|
||||||
|
slotProps={{ inputLabel: { shrink: true } }}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FinancialFields({
|
||||||
|
register,
|
||||||
|
}: {
|
||||||
|
register: UseFormRegister<WorkOrderFormSchemaValues>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<SectionTitle>Financial</SectionTitle>
|
||||||
|
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||||
|
<TextField
|
||||||
|
{...register("vendorNTE")}
|
||||||
|
label="Vendor NTE"
|
||||||
|
type="number"
|
||||||
|
fullWidth
|
||||||
|
slotProps={{ htmlInput: { step: "0.01", min: 0 } }}
|
||||||
|
/>
|
||||||
|
<TextField {...register("po")} label="Customer PO" fullWidth />
|
||||||
|
<TextField {...register("tt")} label="TT" placeholder="Trouble ticket number" fullWidth />
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DescriptionFields({
|
||||||
|
register,
|
||||||
|
}: {
|
||||||
|
register: UseFormRegister<WorkOrderFormSchemaValues>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<SectionTitle>Description</SectionTitle>
|
||||||
|
<TextField
|
||||||
|
{...register("description")}
|
||||||
|
label="Description"
|
||||||
|
multiline
|
||||||
|
rows={8}
|
||||||
|
placeholder="Detailed description of the work to be performed"
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,62 +1,141 @@
|
||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Controller, useForm, useWatch } from "react-hook-form";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useForm, useWatch } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Link as RouterLink, useNavigate, useParams } from "react-router";
|
import { Link as RouterLink, useNavigate, useParams, useSearchParams } from "react-router";
|
||||||
import {
|
import { Box, Breadcrumbs, Button, CircularProgress, Link, Paper, Stack } from "@mui/material";
|
||||||
Alert,
|
import { Text } from "@/components/ui/text";
|
||||||
Autocomplete,
|
import { vendorOperationsApi } from "@/domain/vendor-operations/api/vendor-operations-api";
|
||||||
Box,
|
|
||||||
Breadcrumbs,
|
|
||||||
Button,
|
|
||||||
CircularProgress,
|
|
||||||
Link,
|
|
||||||
MenuItem,
|
|
||||||
Paper,
|
|
||||||
Stack,
|
|
||||||
TextField,
|
|
||||||
Typography,
|
|
||||||
} from "@mui/material";
|
|
||||||
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
|
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
|
||||||
import { mapWorkOrderDetailToFormValues } from "@/domain/work-orders/mappers/work-order-mapper";
|
import { mapWorkOrderDetailToFormValues } from "@/domain/work-orders/mappers/work-order-mapper";
|
||||||
import {
|
import {
|
||||||
workOrderFormSchema,
|
workOrderFormSchema,
|
||||||
type WorkOrderFormSchemaValues,
|
type WorkOrderFormSchemaValues,
|
||||||
} from "@/domain/work-orders/schemas/work-order-schema";
|
} from "@/domain/work-orders/schemas/work-order-schema";
|
||||||
import {
|
import { createDefaultWorkOrderFormValues } from "@/domain/work-orders/types/work-order";
|
||||||
createDefaultWorkOrderFormValues,
|
|
||||||
WORK_ORDER_PRIORITY_OPTIONS,
|
|
||||||
WORK_ORDER_STATUS_OPTIONS,
|
|
||||||
type LocationOption,
|
|
||||||
} from "@/domain/work-orders/types/work-order";
|
|
||||||
import {
|
import {
|
||||||
useWorkOrderDetail,
|
useWorkOrderDetail,
|
||||||
useWorkOrderEmployeesDropdown,
|
useWorkOrderEmployeesDropdown,
|
||||||
useWorkOrderLocations,
|
useWorkOrderLocations,
|
||||||
} from "@/domain/work-orders/use-cases/use-work-order-detail";
|
} from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||||
|
import { useSaveWorkOrder } from "@/domain/work-orders/use-cases/use-save-work-order";
|
||||||
|
import { toDateTimeLocalInputValue } from "@/lib/time-utils";
|
||||||
import {
|
import {
|
||||||
useCreateWorkOrder,
|
ClassificationFields,
|
||||||
useUpdateWorkOrder,
|
DescriptionFields,
|
||||||
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
FinancialFields,
|
||||||
|
ScheduleFields,
|
||||||
|
WorkOrderDetailsFields,
|
||||||
|
} from "@/app/(protected)/workorders/_components/work-order-fields";
|
||||||
|
import { VendorAndTimeSection } from "@/app/(protected)/workorders/_components/vendor-assignment-section";
|
||||||
|
|
||||||
function SectionTitle({ children }: { children: string }) {
|
type ScheduleBounds = {
|
||||||
return (
|
kind: "valid" | "invalid" | "open";
|
||||||
<Typography variant="subtitle2" className="mb-3 font-semibold uppercase tracking-wide">
|
startIso: string | undefined;
|
||||||
{children}
|
endIso: string | undefined;
|
||||||
</Typography>
|
error: string | null;
|
||||||
);
|
};
|
||||||
|
|
||||||
|
function computeScheduleBounds(startLocal: string, endLocal: string): ScheduleBounds {
|
||||||
|
const hasStart = startLocal.trim() !== "";
|
||||||
|
const hasEnd = endLocal.trim() !== "";
|
||||||
|
|
||||||
|
if (hasStart && hasEnd) {
|
||||||
|
const start = new Date(startLocal);
|
||||||
|
const end = new Date(endLocal);
|
||||||
|
if (
|
||||||
|
!Number.isNaN(start.getTime()) &&
|
||||||
|
!Number.isNaN(end.getTime()) &&
|
||||||
|
end.getTime() > start.getTime()
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
kind: "valid",
|
||||||
|
startIso: start.toISOString(),
|
||||||
|
endIso: end.toISOString(),
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: "invalid",
|
||||||
|
startIso: undefined,
|
||||||
|
endIso: undefined,
|
||||||
|
error: "Schedule end must be later than schedule start.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasStart || hasEnd) {
|
||||||
|
return {
|
||||||
|
kind: "invalid",
|
||||||
|
startIso: undefined,
|
||||||
|
endIso: undefined,
|
||||||
|
error: "Provide both a schedule start and end, or leave both blank.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { kind: "open", startIso: undefined, endIso: undefined, error: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function WorkOrderFormPage() {
|
function useVendorSchedule(
|
||||||
|
scheduledStartLocal: string,
|
||||||
|
scheduledEndLocal: string,
|
||||||
|
locationValue: string,
|
||||||
|
tradeValue: string,
|
||||||
|
) {
|
||||||
|
const scheduleBounds = useMemo(
|
||||||
|
() => computeScheduleBounds(scheduledStartLocal, scheduledEndLocal),
|
||||||
|
[scheduledStartLocal, scheduledEndLocal],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: availableVendors = [], isLoading: isLoadingVendors } = useQuery({
|
||||||
|
queryKey: [
|
||||||
|
"vendor-operations",
|
||||||
|
"availability",
|
||||||
|
scheduleBounds.startIso ?? null,
|
||||||
|
scheduleBounds.endIso ?? null,
|
||||||
|
locationValue,
|
||||||
|
tradeValue,
|
||||||
|
],
|
||||||
|
queryFn: () =>
|
||||||
|
vendorOperationsApi.availability({
|
||||||
|
start: scheduleBounds.startIso,
|
||||||
|
end: scheduleBounds.endIso,
|
||||||
|
locationId: locationValue || undefined,
|
||||||
|
trade: tradeValue || undefined,
|
||||||
|
}),
|
||||||
|
enabled: scheduleBounds.kind !== "invalid",
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const truthfulVendors =
|
||||||
|
scheduleBounds.kind === "valid"
|
||||||
|
? availableVendors
|
||||||
|
: availableVendors.map((vendor) => ({
|
||||||
|
...vendor,
|
||||||
|
availabilityStatus: "Unknown" as const,
|
||||||
|
conflicts: [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { availableVendors: truthfulVendors, isLoadingVendors, scheduleBounds };
|
||||||
|
}
|
||||||
|
|
||||||
|
function useWorkOrderFormState() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
const isEdit = Boolean(id);
|
const isEdit = Boolean(id);
|
||||||
|
const assignVendorRequested = searchParams.get("assignVendor") === "1";
|
||||||
|
|
||||||
const { data: detail, isLoading: isLoadingDetail, error: detailError } = useWorkOrderDetail(id);
|
const { data: detail, isLoading: isLoadingDetail, error: detailError } = useWorkOrderDetail(id);
|
||||||
const { data: locations = [], isLoading: isLoadingLocations } = useWorkOrderLocations();
|
const { data: locations = [], isLoading: isLoadingLocations } = useWorkOrderLocations();
|
||||||
const { data: employees = [] } = useWorkOrderEmployeesDropdown();
|
const { data: employees = [] } = useWorkOrderEmployeesDropdown();
|
||||||
const { data: problems = [] } = useDropdownOptionsByCategory("Problem");
|
const { data: problems = [] } = useDropdownOptionsByCategory("Problem");
|
||||||
const createMutation = useCreateWorkOrder();
|
const saveMutation = useSaveWorkOrder();
|
||||||
const updateMutation = useUpdateWorkOrder(id ?? "");
|
const vendorSectionRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [selectedVendorId, setSelectedVendorId] = useState<number | null>(null);
|
||||||
|
const [estimatedArrivalAt, setEstimatedArrivalAt] = useState("");
|
||||||
|
const [etaManualOverride, setEtaManualOverride] = useState(false);
|
||||||
|
const [scheduledStartLocal, setScheduledStartLocal] = useState("");
|
||||||
|
const [scheduledEndLocal, setScheduledEndLocal] = useState("");
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
|
|
@ -71,6 +150,14 @@ export default function WorkOrderFormPage() {
|
||||||
});
|
});
|
||||||
|
|
||||||
const tradeValue = useWatch({ control, name: "trade" });
|
const tradeValue = useWatch({ control, name: "trade" });
|
||||||
|
const locationValue = useWatch({ control, name: "locationId" });
|
||||||
|
|
||||||
|
const { availableVendors, isLoadingVendors, scheduleBounds } = useVendorSchedule(
|
||||||
|
scheduledStartLocal,
|
||||||
|
scheduledEndLocal,
|
||||||
|
locationValue,
|
||||||
|
tradeValue,
|
||||||
|
);
|
||||||
|
|
||||||
const { data: subTrades = [] } = useDropdownOptionsByCategory("SubTrade", {
|
const { data: subTrades = [] } = useDropdownOptionsByCategory("SubTrade", {
|
||||||
parent: tradeValue || undefined,
|
parent: tradeValue || undefined,
|
||||||
|
|
@ -81,6 +168,10 @@ export default function WorkOrderFormPage() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isEdit && detail) {
|
if (isEdit && detail) {
|
||||||
reset(mapWorkOrderDetailToFormValues(detail) as WorkOrderFormSchemaValues);
|
reset(mapWorkOrderDetailToFormValues(detail) as WorkOrderFormSchemaValues);
|
||||||
|
const dispatch = detail.dispatches.find((item) => item.status !== "Cancelled");
|
||||||
|
setSelectedVendorId(dispatch?.vendorId ? Number(dispatch.vendorId) : null);
|
||||||
|
setEstimatedArrivalAt(toDateTimeLocalInputValue(dispatch?.estimatedArrivalAt));
|
||||||
|
setEtaManualOverride(Boolean(dispatch?.etaManualOverride));
|
||||||
}
|
}
|
||||||
}, [detail, isEdit, reset]);
|
}, [detail, isEdit, reset]);
|
||||||
|
|
||||||
|
|
@ -90,23 +181,120 @@ export default function WorkOrderFormPage() {
|
||||||
}
|
}
|
||||||
}, [tradeValue, setValue]);
|
}, [tradeValue, setValue]);
|
||||||
|
|
||||||
const locationOptions = useMemo(() => locations, [locations]);
|
|
||||||
|
|
||||||
const isSaving = createMutation.isPending || updateMutation.isPending;
|
|
||||||
const isLoading = isEdit && isLoadingDetail;
|
const isLoading = isEdit && isLoadingDetail;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (assignVendorRequested && !isLoading) {
|
||||||
|
const node = vendorSectionRef.current;
|
||||||
|
if (node) {
|
||||||
|
node.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
|
node.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [assignVendorRequested, isLoading]);
|
||||||
|
|
||||||
const onSubmit = (values: WorkOrderFormSchemaValues) => {
|
const onSubmit = (values: WorkOrderFormSchemaValues) => {
|
||||||
if (isEdit && id) {
|
if (scheduleBounds.kind === "invalid") {
|
||||||
updateMutation.mutate(values);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
createMutation.mutate(values);
|
saveMutation.mutate({
|
||||||
|
workOrderId: isEdit ? id : undefined,
|
||||||
|
values,
|
||||||
|
vendorAssignment:
|
||||||
|
selectedVendorId == null
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
vendorId: selectedVendorId,
|
||||||
|
scheduledStart: scheduleBounds.startIso,
|
||||||
|
scheduledEnd: scheduleBounds.endIso,
|
||||||
|
estimatedArrivalAt: estimatedArrivalAt
|
||||||
|
? new Date(estimatedArrivalAt).toISOString()
|
||||||
|
: undefined,
|
||||||
|
etaManualOverride,
|
||||||
|
},
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
navigate(isEdit && id ? `/workorders/${id}` : "/workorders");
|
navigate(isEdit && id ? `/workorders/${id}` : "/workorders");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
isEdit,
|
||||||
|
detail,
|
||||||
|
isLoading,
|
||||||
|
detailError,
|
||||||
|
isSaving: saveMutation.isPending,
|
||||||
|
register,
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
errors,
|
||||||
|
locationOptions: locations,
|
||||||
|
isLoadingLocations,
|
||||||
|
employees,
|
||||||
|
problems,
|
||||||
|
trades,
|
||||||
|
subTrades,
|
||||||
|
tradeValue,
|
||||||
|
vendorSectionRef,
|
||||||
|
availableVendors,
|
||||||
|
isLoadingVendors,
|
||||||
|
selectedVendorId,
|
||||||
|
setSelectedVendorId,
|
||||||
|
estimatedArrivalAt,
|
||||||
|
setEstimatedArrivalAt,
|
||||||
|
etaManualOverride,
|
||||||
|
setEtaManualOverride,
|
||||||
|
scheduledStartLocal,
|
||||||
|
setScheduledStartLocal,
|
||||||
|
scheduledEndLocal,
|
||||||
|
setScheduledEndLocal,
|
||||||
|
scheduleBoundsError: scheduleBounds.error,
|
||||||
|
isScheduleBoundsInvalid: scheduleBounds.kind === "invalid",
|
||||||
|
onSubmit,
|
||||||
|
handleCancel,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WorkOrderFormPage() {
|
||||||
|
const {
|
||||||
|
isEdit,
|
||||||
|
id,
|
||||||
|
detail,
|
||||||
|
isLoading,
|
||||||
|
detailError,
|
||||||
|
isSaving,
|
||||||
|
register,
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
errors,
|
||||||
|
locationOptions,
|
||||||
|
isLoadingLocations,
|
||||||
|
employees,
|
||||||
|
problems,
|
||||||
|
trades,
|
||||||
|
subTrades,
|
||||||
|
tradeValue,
|
||||||
|
vendorSectionRef,
|
||||||
|
availableVendors,
|
||||||
|
isLoadingVendors,
|
||||||
|
selectedVendorId,
|
||||||
|
setSelectedVendorId,
|
||||||
|
estimatedArrivalAt,
|
||||||
|
setEstimatedArrivalAt,
|
||||||
|
etaManualOverride,
|
||||||
|
setEtaManualOverride,
|
||||||
|
scheduledStartLocal,
|
||||||
|
setScheduledStartLocal,
|
||||||
|
scheduledEndLocal,
|
||||||
|
setScheduledEndLocal,
|
||||||
|
scheduleBoundsError,
|
||||||
|
isScheduleBoundsInvalid,
|
||||||
|
onSubmit,
|
||||||
|
handleCancel,
|
||||||
|
} = useWorkOrderFormState();
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<Box className="flex justify-center p-12">
|
<Box className="flex justify-center p-12">
|
||||||
|
|
@ -118,9 +306,7 @@ export default function WorkOrderFormPage() {
|
||||||
if (detailError) {
|
if (detailError) {
|
||||||
return (
|
return (
|
||||||
<Box className="p-4">
|
<Box className="p-4">
|
||||||
<Alert severity="error">
|
<Text variant="error">Unable to load this work order. Please try again.</Text>
|
||||||
{detailError instanceof Error ? detailError.message : "Failed to load work order"}
|
|
||||||
</Alert>
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -131,232 +317,66 @@ export default function WorkOrderFormPage() {
|
||||||
<Link component={RouterLink} to="/workorders" underline="hover">
|
<Link component={RouterLink} to="/workorders" underline="hover">
|
||||||
Work Orders
|
Work Orders
|
||||||
</Link>
|
</Link>
|
||||||
<Typography
|
<Text as="span">{isEdit ? "Edit" : "Create"}</Text>
|
||||||
sx={{
|
|
||||||
color: "text.primary",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isEdit ? "Edit" : "Create"}
|
|
||||||
</Typography>
|
|
||||||
</Breadcrumbs>
|
</Breadcrumbs>
|
||||||
<Typography variant="h5" component="h1">
|
<Text variant="title" as="h1">
|
||||||
{isEdit ? `Edit Work Order ${detail?.internalWONumber ?? id}` : "Create a Work Order"}
|
{isEdit ? `Edit Work Order ${detail?.internalWONumber ?? id}` : "Create a Work Order"}
|
||||||
</Typography>
|
</Text>
|
||||||
<Paper variant="outlined" className="p-6">
|
<Paper variant="outlined" className="p-6">
|
||||||
<Box component="form" onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-8">
|
<Box component="form" onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-8">
|
||||||
<Box>
|
<WorkOrderDetailsFields
|
||||||
<SectionTitle>Work Order Details</SectionTitle>
|
register={register}
|
||||||
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
control={control}
|
||||||
<TextField
|
errors={errors}
|
||||||
{...register("workOrderNumber")}
|
locationOptions={locationOptions}
|
||||||
label="Customer WO"
|
isLoadingLocations={isLoadingLocations}
|
||||||
placeholder="Customer work order number"
|
/>
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
{...register("title")}
|
|
||||||
label="Title"
|
|
||||||
required
|
|
||||||
error={Boolean(errors.title)}
|
|
||||||
helperText={errors.title?.message}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
<Stack direction={{ xs: "column", md: "row" }} spacing={2} className="mt-4">
|
|
||||||
<Controller
|
|
||||||
name="locationId"
|
|
||||||
control={control}
|
|
||||||
render={({ field }) => (
|
|
||||||
<Autocomplete
|
|
||||||
options={locationOptions}
|
|
||||||
loading={isLoadingLocations}
|
|
||||||
getOptionLabel={(option: LocationOption) => option.name}
|
|
||||||
value={locationOptions.find((loc) => String(loc.id) === field.value) ?? null}
|
|
||||||
onChange={(_event, option) => field.onChange(option ? String(option.id) : "")}
|
|
||||||
renderInput={(params) => (
|
|
||||||
<TextField
|
|
||||||
{...params}
|
|
||||||
label="Location"
|
|
||||||
required
|
|
||||||
error={Boolean(errors.locationId)}
|
|
||||||
helperText={errors.locationId?.message}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
{...register("source")}
|
|
||||||
label="Source"
|
|
||||||
placeholder="e.g., Amazon APM, Manual"
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box>
|
<VendorAndTimeSection
|
||||||
<SectionTitle>Classification</SectionTitle>
|
containerRef={vendorSectionRef}
|
||||||
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
availableVendors={availableVendors}
|
||||||
<TextField {...register("problem")} label="Problem" select fullWidth>
|
isLoadingVendors={isLoadingVendors}
|
||||||
<MenuItem value="">Select Problem</MenuItem>
|
selectedVendorId={selectedVendorId}
|
||||||
{problems.map((option) => (
|
onSelectVendor={setSelectedVendorId}
|
||||||
<MenuItem key={String(option.id)} value={option.value}>
|
estimatedArrivalAt={estimatedArrivalAt}
|
||||||
{option.value}
|
onEtaChange={setEstimatedArrivalAt}
|
||||||
</MenuItem>
|
etaManualOverride={etaManualOverride}
|
||||||
))}
|
onOverrideChange={setEtaManualOverride}
|
||||||
</TextField>
|
scheduledStartLocal={scheduledStartLocal}
|
||||||
<TextField {...register("trade")} label="Trade" select fullWidth>
|
onScheduledStartChange={setScheduledStartLocal}
|
||||||
<MenuItem value="">Select Trade</MenuItem>
|
scheduledEndLocal={scheduledEndLocal}
|
||||||
{trades.map((option) => (
|
onScheduledEndChange={setScheduledEndLocal}
|
||||||
<MenuItem key={String(option.id)} value={option.value}>
|
scheduleBoundsError={scheduleBoundsError}
|
||||||
{option.value}
|
/>
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</TextField>
|
|
||||||
<TextField
|
|
||||||
{...register("subTrade")}
|
|
||||||
label="Sub-Trade"
|
|
||||||
select
|
|
||||||
disabled={!tradeValue}
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
<MenuItem value="">
|
|
||||||
{tradeValue ? "Select Sub-Trade" : "Select a Trade first"}
|
|
||||||
</MenuItem>
|
|
||||||
{subTrades.map((option) => (
|
|
||||||
<MenuItem key={String(option.id)} value={option.value}>
|
|
||||||
{option.value}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</TextField>
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box>
|
<ClassificationFields
|
||||||
<SectionTitle>Schedule & Priority</SectionTitle>
|
control={control}
|
||||||
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
problems={problems}
|
||||||
<TextField
|
trades={trades}
|
||||||
{...register("priority")}
|
subTrades={subTrades}
|
||||||
label="Priority"
|
tradeValue={tradeValue}
|
||||||
select
|
/>
|
||||||
required
|
|
||||||
error={Boolean(errors.priority)}
|
|
||||||
helperText={errors.priority?.message}
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
<MenuItem value="">Select Priority</MenuItem>
|
|
||||||
{WORK_ORDER_PRIORITY_OPTIONS.map((priority) => (
|
|
||||||
<MenuItem key={priority} value={priority}>
|
|
||||||
{priority}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</TextField>
|
|
||||||
<TextField {...register("status")} label="Status" select required fullWidth>
|
|
||||||
{WORK_ORDER_STATUS_OPTIONS.map((status) => (
|
|
||||||
<MenuItem key={status} value={status}>
|
|
||||||
{status}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</TextField>
|
|
||||||
<Controller
|
|
||||||
name="assignedTo"
|
|
||||||
control={control}
|
|
||||||
render={({ field }) => (
|
|
||||||
<Autocomplete
|
|
||||||
freeSolo
|
|
||||||
options={employees.map((employee) => employee.name)}
|
|
||||||
value={field.value}
|
|
||||||
onChange={(_event, value) => field.onChange(value ?? "")}
|
|
||||||
onInputChange={(_event, value) => field.onChange(value)}
|
|
||||||
renderInput={(params) => (
|
|
||||||
<TextField {...params} label="Assigned To" placeholder="Assigned to" />
|
|
||||||
)}
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} className="mt-4">
|
|
||||||
<TextField
|
|
||||||
{...register("createdDate")}
|
|
||||||
label="Created"
|
|
||||||
type="date"
|
|
||||||
required
|
|
||||||
error={Boolean(errors.createdDate)}
|
|
||||||
helperText={errors.createdDate?.message}
|
|
||||||
slotProps={{ inputLabel: { shrink: true } }}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
{...register("dueDate")}
|
|
||||||
label="Due Date"
|
|
||||||
type="date"
|
|
||||||
slotProps={{ inputLabel: { shrink: true } }}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
{...register("scheduledDate")}
|
|
||||||
label="Scheduled"
|
|
||||||
type="date"
|
|
||||||
slotProps={{ inputLabel: { shrink: true } }}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
{...register("completedDate")}
|
|
||||||
label="Completed"
|
|
||||||
type="date"
|
|
||||||
slotProps={{ inputLabel: { shrink: true } }}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box>
|
<ScheduleFields
|
||||||
<SectionTitle>Financial</SectionTitle>
|
register={register}
|
||||||
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
control={control}
|
||||||
<TextField
|
errors={errors}
|
||||||
{...register("vendorNTE")}
|
employees={employees}
|
||||||
label="Vendor NTE"
|
/>
|
||||||
type="number"
|
|
||||||
fullWidth
|
|
||||||
slotProps={{
|
|
||||||
htmlInput: { step: "0.01", min: 0 },
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<TextField {...register("po")} label="Customer PO" fullWidth />
|
|
||||||
<TextField
|
|
||||||
{...register("tt")}
|
|
||||||
label="TT"
|
|
||||||
placeholder="Trouble ticket number"
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box>
|
<FinancialFields register={register} />
|
||||||
<SectionTitle>Description</SectionTitle>
|
|
||||||
<TextField
|
|
||||||
{...register("description")}
|
|
||||||
label="Description"
|
|
||||||
multiline
|
|
||||||
rows={8}
|
|
||||||
placeholder="Detailed description of the work to be performed"
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Stack
|
<DescriptionFields register={register} />
|
||||||
direction="row"
|
|
||||||
spacing={2}
|
<Stack direction="row" spacing={2} sx={{ justifyContent: "flex-end" }}>
|
||||||
sx={{
|
|
||||||
justifyContent: "flex-end",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button variant="outlined" onClick={handleCancel} disabled={isSaving}>
|
<Button variant="outlined" onClick={handleCancel} disabled={isSaving}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" variant="contained" disabled={isSaving}>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
disabled={isSaving || isScheduleBoundsInvalid}
|
||||||
|
>
|
||||||
{isSaving ? "Saving..." : isEdit ? "Update" : "Create"}
|
{isSaving ? "Saving..." : isEdit ? "Update" : "Create"}
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
|
||||||
|
|
@ -1,405 +1,21 @@
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { Link, useParams } from "react-router";
|
import { useParams } from "react-router";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { SignaturePad } from "@/app/v/_components/signature-pad";
|
|
||||||
import { useVendorPortal } from "@/app/v/_components/vendor-portal-context";
|
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { DispatchDetailSections } from "@/app/v/[token]/dispatch/_components/dispatch-detail-sections";
|
||||||
|
import { DispatchSummaryCard } from "@/app/v/[token]/dispatch/_components/dispatch-summary-card";
|
||||||
|
import { 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 {
|
import type { VendorPortalDispatchDetail } from "@/domain/vendor-portal/types/vendor-portal";
|
||||||
formatVendorPortalDateTime,
|
|
||||||
vendorPortalStatusClass,
|
|
||||||
} from "@/domain/vendor-portal/lib/status-helpers";
|
|
||||||
import type {
|
|
||||||
VendorPortalChecklistItem,
|
|
||||||
VendorPortalComment,
|
|
||||||
VendorPortalDispatchDetail,
|
|
||||||
VendorPortalSignoff,
|
|
||||||
VendorPortalUpliftRequest,
|
|
||||||
} from "@/domain/vendor-portal/types/vendor-portal";
|
|
||||||
import { queryKeys } from "@/infra/query-key/query-key";
|
import { queryKeys } from "@/infra/query-key/query-key";
|
||||||
|
|
||||||
function ChecklistSection({
|
|
||||||
token,
|
|
||||||
dispatchId,
|
|
||||||
items,
|
|
||||||
locked,
|
|
||||||
onItemUpdated,
|
|
||||||
}: {
|
|
||||||
token: string;
|
|
||||||
dispatchId: number;
|
|
||||||
items: VendorPortalChecklistItem[];
|
|
||||||
locked: boolean;
|
|
||||||
onItemUpdated: (item: VendorPortalChecklistItem) => void;
|
|
||||||
}) {
|
|
||||||
const toggle = async (item: VendorPortalChecklistItem) => {
|
|
||||||
if (locked) return;
|
|
||||||
try {
|
|
||||||
const updated = await vendorPortalApi.updateChecklistItem(
|
|
||||||
token,
|
|
||||||
dispatchId,
|
|
||||||
item.id,
|
|
||||||
!item.isCompleted,
|
|
||||||
);
|
|
||||||
onItemUpdated(updated);
|
|
||||||
} catch (error) {
|
|
||||||
alert(error instanceof Error ? error.message : "Failed to update checklist");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!items.length) {
|
|
||||||
return <div className="vp-muted">No checklist items for this dispatch.</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{items.map((item) => (
|
|
||||||
<div key={item.id} className={`vp-checklist-item ${item.isCompleted ? "done" : ""}`}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id={`cl-${item.id}`}
|
|
||||||
checked={item.isCompleted}
|
|
||||||
onChange={() => void toggle(item)}
|
|
||||||
disabled={locked}
|
|
||||||
/>
|
|
||||||
<label htmlFor={`cl-${item.id}`} style={{ flex: 1 }}>
|
|
||||||
{item.itemText}
|
|
||||||
</label>
|
|
||||||
{item.isCompleted && Boolean(item.completedAt) && (
|
|
||||||
<span className="vp-muted" style={{ fontSize: 12 }}>
|
|
||||||
{formatVendorPortalDateTime(item.completedAt)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CommentsSection({
|
|
||||||
token,
|
|
||||||
dispatchId,
|
|
||||||
comments,
|
|
||||||
onAdded,
|
|
||||||
}: {
|
|
||||||
token: string;
|
|
||||||
dispatchId: number;
|
|
||||||
comments: VendorPortalComment[];
|
|
||||||
onAdded: (comment: VendorPortalComment) => void;
|
|
||||||
}) {
|
|
||||||
const [text, setText] = useState("");
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
|
|
||||||
const submit = async (event: React.FormEvent) => {
|
|
||||||
event.preventDefault();
|
|
||||||
if (!text.trim()) return;
|
|
||||||
setSubmitting(true);
|
|
||||||
try {
|
|
||||||
const added = await vendorPortalApi.addComment(token, dispatchId, text.trim());
|
|
||||||
onAdded(added);
|
|
||||||
setText("");
|
|
||||||
} catch (error) {
|
|
||||||
alert(error instanceof Error ? error.message : "Failed to post comment");
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{comments.length === 0 && <div className="vp-muted">No comments yet.</div>}
|
|
||||||
{comments.map((comment) => (
|
|
||||||
<div key={comment.id} className={`vp-comment ${comment.commentType || ""}`}>
|
|
||||||
<div className="vp-comment-meta">
|
|
||||||
{comment.commenter || "Unknown"} · {formatVendorPortalDateTime(comment.createdDate)}
|
|
||||||
</div>
|
|
||||||
<div>{comment.commenttext}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<form onSubmit={(event) => void submit(event)} style={{ marginTop: 12 }}>
|
|
||||||
<textarea
|
|
||||||
className="vp-textarea"
|
|
||||||
placeholder="Add a comment for the dispatcher…"
|
|
||||||
value={text}
|
|
||||||
onChange={(event) => setText(event.target.value)}
|
|
||||||
/>
|
|
||||||
<div className="vp-actions">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="vp-btn vp-btn-primary"
|
|
||||||
disabled={submitting || !text.trim()}
|
|
||||||
>
|
|
||||||
{submitting ? "Posting…" : "Post Comment"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function UpliftRequestsSection({
|
|
||||||
token,
|
|
||||||
dispatchId,
|
|
||||||
currentNte,
|
|
||||||
upliftRequests,
|
|
||||||
locked,
|
|
||||||
onChanged,
|
|
||||||
}: {
|
|
||||||
token: string;
|
|
||||||
dispatchId: number;
|
|
||||||
currentNte?: number;
|
|
||||||
upliftRequests: VendorPortalUpliftRequest[];
|
|
||||||
locked: boolean;
|
|
||||||
onChanged: () => Promise<void>;
|
|
||||||
}) {
|
|
||||||
const [requested, setRequested] = useState("");
|
|
||||||
const [reason, setReason] = useState("");
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
|
|
||||||
const pending = upliftRequests.find((request) => request.status === "Pending");
|
|
||||||
|
|
||||||
const submit = async (event: React.FormEvent) => {
|
|
||||||
event.preventDefault();
|
|
||||||
const amount = Number.parseFloat(requested);
|
|
||||||
if (!Number.isFinite(amount) || amount <= 0) {
|
|
||||||
alert("Enter a valid requested NTE amount.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (currentNte != null && amount <= Number(currentNte)) {
|
|
||||||
alert(
|
|
||||||
`Requested NTE must be greater than the current NTE ($${Number(currentNte).toFixed(2)}).`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!reason.trim()) {
|
|
||||||
alert("Please include a reason for the uplift request.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSubmitting(true);
|
|
||||||
try {
|
|
||||||
await vendorPortalApi.requestUplift(token, dispatchId, {
|
|
||||||
requestedNTE: amount,
|
|
||||||
reason: reason.trim(),
|
|
||||||
});
|
|
||||||
setRequested("");
|
|
||||||
setReason("");
|
|
||||||
await onChanged();
|
|
||||||
} catch (error) {
|
|
||||||
alert(error instanceof Error ? error.message : "Failed to request uplift");
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const cancel = async (requestId: number) => {
|
|
||||||
if (!window.confirm("Cancel this uplift request?")) return;
|
|
||||||
try {
|
|
||||||
await vendorPortalApi.cancelUplift(token, dispatchId, requestId);
|
|
||||||
await onChanged();
|
|
||||||
} catch (error) {
|
|
||||||
alert(error instanceof Error ? error.message : "Failed to cancel uplift");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{upliftRequests.length === 0 && !pending && (
|
|
||||||
<div className="vp-muted" style={{ marginBottom: 12 }}>
|
|
||||||
No uplift requests yet. Use the form below if the work will exceed the current NTE.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{upliftRequests.map((request) => (
|
|
||||||
<div key={request.id} className="vp-uplift-row">
|
|
||||||
<div className="vp-row">
|
|
||||||
<strong>Requested</strong>
|
|
||||||
<span>
|
|
||||||
${Number(request.requestedNTE).toFixed(2)} (was $
|
|
||||||
{Number(request.currentNTE ?? 0).toFixed(2)})
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="vp-row">
|
|
||||||
<strong>Status</strong>
|
|
||||||
<span>
|
|
||||||
<span className={`vp-uplift-status-${String(request.status).toLowerCase()}`}>
|
|
||||||
{request.status}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{Boolean(request.vendorReason) && (
|
|
||||||
<div className="vp-row">
|
|
||||||
<strong>Reason</strong>
|
|
||||||
<span>{request.vendorReason}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{request.status === "Pending" && (
|
|
||||||
<div className="vp-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="vp-btn vp-btn-ghost"
|
|
||||||
onClick={() => void cancel(request.id)}
|
|
||||||
>
|
|
||||||
Cancel Request
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{!locked && !pending && (
|
|
||||||
<form onSubmit={(event) => void submit(event)} style={{ marginTop: 12 }}>
|
|
||||||
<div className="vp-row">
|
|
||||||
<strong>Current NTE</strong>
|
|
||||||
<span>${Number(currentNte ?? 0).toFixed(2)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="vp-row">
|
|
||||||
<strong>New total NTE</strong>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
min="0"
|
|
||||||
className="vp-input"
|
|
||||||
value={requested}
|
|
||||||
onChange={(event) => setRequested(event.target.value)}
|
|
||||||
placeholder="0.00"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="vp-row">
|
|
||||||
<strong>Reason</strong>
|
|
||||||
<textarea
|
|
||||||
className="vp-textarea"
|
|
||||||
value={reason}
|
|
||||||
onChange={(event) => setReason(event.target.value)}
|
|
||||||
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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SignoffSection({
|
|
||||||
token,
|
|
||||||
dispatchId,
|
|
||||||
existing,
|
|
||||||
signoffType,
|
|
||||||
nameLabel,
|
|
||||||
onAdded,
|
|
||||||
}: {
|
|
||||||
token: string;
|
|
||||||
dispatchId: number;
|
|
||||||
existing: VendorPortalSignoff[];
|
|
||||||
signoffType: string;
|
|
||||||
nameLabel: string;
|
|
||||||
onAdded: () => void;
|
|
||||||
}) {
|
|
||||||
const [name, setName] = useState("");
|
|
||||||
const [method, setMethod] = useState<"drawn" | "typed">("drawn");
|
|
||||||
const [typedSig, setTypedSig] = useState("");
|
|
||||||
const [drawnSig, setDrawnSig] = useState("");
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
|
|
||||||
const existingSignoff = existing.find((signoff) => signoff.signoffType === signoffType);
|
|
||||||
|
|
||||||
if (existingSignoff) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="vp-row">
|
|
||||||
<strong>Signed by</strong>
|
|
||||||
<span>{existingSignoff.name}</span>
|
|
||||||
</div>
|
|
||||||
<div className="vp-row">
|
|
||||||
<strong>Method</strong>
|
|
||||||
<span>{existingSignoff.signatureMethod}</span>
|
|
||||||
</div>
|
|
||||||
<div className="vp-row">
|
|
||||||
<strong>Signed at</strong>
|
|
||||||
<span>{formatVendorPortalDateTime(existingSignoff.signedAt)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const submit = async (event: React.FormEvent) => {
|
|
||||||
event.preventDefault();
|
|
||||||
const signature = method === "drawn" ? drawnSig : typedSig;
|
|
||||||
if (!name.trim() || !signature) {
|
|
||||||
alert("Name and signature are required");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSubmitting(true);
|
|
||||||
try {
|
|
||||||
await vendorPortalApi.addSignoff(token, dispatchId, {
|
|
||||||
name: name.trim(),
|
|
||||||
signature,
|
|
||||||
signatureMethod: method,
|
|
||||||
signoffType,
|
|
||||||
});
|
|
||||||
onAdded();
|
|
||||||
} catch (error) {
|
|
||||||
alert(error instanceof Error ? error.message : "Failed to submit signoff");
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={(event) => void submit(event)}>
|
|
||||||
<div className="vp-row">
|
|
||||||
<strong>{nameLabel}</strong>
|
|
||||||
<input
|
|
||||||
className="vp-input"
|
|
||||||
value={name}
|
|
||||||
onChange={(event) => setName(event.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="vp-row">
|
|
||||||
<strong>Method</strong>
|
|
||||||
<div>
|
|
||||||
<label style={{ marginRight: 12 }}>
|
|
||||||
<input type="radio" checked={method === "drawn"} onChange={() => setMethod("drawn")} />{" "}
|
|
||||||
Drawn
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<input type="radio" checked={method === "typed"} onChange={() => setMethod("typed")} />{" "}
|
|
||||||
Typed
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{method === "drawn" ? (
|
|
||||||
<div style={{ marginTop: 8 }}>
|
|
||||||
<SignaturePad onChange={setDrawnSig} />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="vp-row">
|
|
||||||
<strong>Signature</strong>
|
|
||||||
<input
|
|
||||||
className="vp-input"
|
|
||||||
value={typedSig}
|
|
||||||
onChange={(event) => setTypedSig(event.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="vp-actions">
|
|
||||||
<button type="submit" className="vp-btn vp-btn-success" disabled={submitting}>
|
|
||||||
{submitting ? "Signing…" : "Submit Signoff"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function VendorPortalDispatchPage() {
|
export default function VendorPortalDispatchPage() {
|
||||||
const { token } = useVendorPortal();
|
const { token } = useVendorPortal();
|
||||||
const { id = "" } = useParams();
|
const { id = "" } = useParams();
|
||||||
const dispatchId = Number(id);
|
const dispatchId = Number(id);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [actionError, setActionError] = useState("");
|
||||||
|
|
||||||
const { data, isLoading, error, refetch } = useQuery({
|
const { data, isLoading, error, refetch } = useQuery({
|
||||||
queryKey: queryKeys.vendorPortal.dispatch(token, dispatchId),
|
queryKey: queryKeys.vendorPortal.dispatch(token, dispatchId),
|
||||||
|
|
@ -414,33 +30,18 @@ export default function VendorPortalDispatchPage() {
|
||||||
|
|
||||||
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("");
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
await action();
|
await action();
|
||||||
await refresh();
|
await refresh();
|
||||||
} catch (err) {
|
} catch {
|
||||||
alert(err instanceof Error ? err.message : "Action failed");
|
setActionError("Unable to update this dispatch. Please try again.");
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading && !data) {
|
|
||||||
return <div className="vp-card vp-muted">Loading dispatch…</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error && !data) {
|
|
||||||
return (
|
|
||||||
<Text variant="error" className="vp-error">
|
|
||||||
{error.message}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!data) return null;
|
|
||||||
|
|
||||||
const locked = data.status === "Verified" || data.status === "Cancelled";
|
|
||||||
|
|
||||||
const updateLocal = (
|
const updateLocal = (
|
||||||
updater: (current: VendorPortalDispatchDetail) => VendorPortalDispatchDetail,
|
updater: (current: VendorPortalDispatchDetail) => VendorPortalDispatchDetail,
|
||||||
) => {
|
) => {
|
||||||
|
|
@ -453,199 +54,59 @@ export default function VendorPortalDispatchPage() {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const requestCancel = () => {
|
||||||
|
const reason = window.prompt("Reason for cancel request?");
|
||||||
|
if (reason == null) return;
|
||||||
|
void runAction(() => vendorPortalApi.requestCancel(token, dispatchId, reason));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading && !data) {
|
||||||
|
return (
|
||||||
|
<Text variant="feedback" className="vp-card vp-muted">
|
||||||
|
Loading dispatch…
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && !data) {
|
||||||
|
return <Text variant="error">Unable to load this dispatch. Please try again.</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
const locked = data.status === "Verified" || data.status === "Cancelled";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Link to={`/v/${token}/dashboard`} className="vp-back-link">
|
<DispatchSummaryCard
|
||||||
← Back to dispatches
|
token={token}
|
||||||
</Link>
|
data={data}
|
||||||
|
locked={locked}
|
||||||
|
busy={busy}
|
||||||
|
onAccept={() => void runAction(() => vendorPortalApi.accept(token, dispatchId))}
|
||||||
|
onStart={() =>
|
||||||
|
void runAction(() => vendorPortalApi.changeStatus(token, dispatchId, "In Progress"))
|
||||||
|
}
|
||||||
|
onComplete={() =>
|
||||||
|
void runAction(
|
||||||
|
() => vendorPortalApi.changeStatus(token, dispatchId, "Completed"),
|
||||||
|
"Mark this dispatch as completed?",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onRequestCancel={requestCancel}
|
||||||
|
/>
|
||||||
|
<Text variant="error" when={Boolean(actionError)}>
|
||||||
|
{actionError}
|
||||||
|
</Text>
|
||||||
|
|
||||||
<div className="vp-card">
|
<DispatchDetailSections
|
||||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
|
token={token}
|
||||||
<div>
|
dispatchId={dispatchId}
|
||||||
<Text variant="title">{data.dispatchNumber}</Text>
|
data={data}
|
||||||
<div className="vp-muted">{data.workOrder?.workerOrderTitle}</div>
|
locked={locked}
|
||||||
</div>
|
refresh={refresh}
|
||||||
<span className={vendorPortalStatusClass(data.status)}>{data.status}</span>
|
updateLocal={updateLocal}
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
<div style={{ marginTop: 12 }}>
|
|
||||||
{data.workOrder != null && Boolean(data.workOrder.internalWONumber) && (
|
|
||||||
<div className="vp-row">
|
|
||||||
<strong>WO #</strong>
|
|
||||||
<span>{data.workOrder.internalWONumber}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{Boolean(data.poNumber) && (
|
|
||||||
<div className="vp-row">
|
|
||||||
<strong>PO #</strong>
|
|
||||||
<span>{data.poNumber}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{data.nteAmount != null && (
|
|
||||||
<div className="vp-row">
|
|
||||||
<strong>NTE</strong>
|
|
||||||
<span>${Number(data.nteAmount).toFixed(2)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{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>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="vp-actions">
|
|
||||||
{data.status === "Sent" && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="vp-btn vp-btn-success"
|
|
||||||
disabled={busy}
|
|
||||||
onClick={() => void runAction(() => vendorPortalApi.accept(token, dispatchId))}
|
|
||||||
>
|
|
||||||
Accept Dispatch
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{data.status === "Acknowledged" && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="vp-btn vp-btn-primary"
|
|
||||||
disabled={busy}
|
|
||||||
onClick={() =>
|
|
||||||
void runAction(() => vendorPortalApi.changeStatus(token, dispatchId, "In Progress"))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Start Work
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{data.status === "In Progress" && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="vp-btn vp-btn-primary"
|
|
||||||
disabled={busy}
|
|
||||||
onClick={() =>
|
|
||||||
void runAction(
|
|
||||||
() => vendorPortalApi.changeStatus(token, dispatchId, "Completed"),
|
|
||||||
"Mark this dispatch as completed?",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Mark Completed
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{!locked && data.status !== "Sent" && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="vp-btn vp-btn-warn"
|
|
||||||
disabled={busy}
|
|
||||||
onClick={() => {
|
|
||||||
const reason = window.prompt("Reason for cancel request?");
|
|
||||||
if (reason == null) return;
|
|
||||||
void runAction(() => vendorPortalApi.requestCancel(token, dispatchId, reason));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Request Cancel
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="vp-card">
|
|
||||||
<Text variant="heading">Checklist</Text>
|
|
||||||
<ChecklistSection
|
|
||||||
token={token}
|
|
||||||
dispatchId={dispatchId}
|
|
||||||
items={data.checklist ?? []}
|
|
||||||
locked={locked}
|
|
||||||
onItemUpdated={(updated) =>
|
|
||||||
updateLocal((current) => ({
|
|
||||||
...current,
|
|
||||||
checklist: (current.checklist ?? []).map((item) =>
|
|
||||||
item.id === updated.id ? { ...item, ...updated } : item,
|
|
||||||
),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="vp-card">
|
|
||||||
<Text variant="heading">NTE Uplift Requests</Text>
|
|
||||||
<UpliftRequestsSection
|
|
||||||
token={token}
|
|
||||||
dispatchId={dispatchId}
|
|
||||||
currentNte={data.nteAmount}
|
|
||||||
upliftRequests={data.upliftRequests ?? []}
|
|
||||||
locked={locked}
|
|
||||||
onChanged={refresh}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="vp-card">
|
|
||||||
<Text variant="heading">Customer Signoff</Text>
|
|
||||||
<SignoffSection
|
|
||||||
token={token}
|
|
||||||
dispatchId={dispatchId}
|
|
||||||
existing={data.signoffs ?? []}
|
|
||||||
signoffType="customer"
|
|
||||||
nameLabel="Customer name"
|
|
||||||
onAdded={() => void refresh()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="vp-card">
|
|
||||||
<Text variant="heading">Vendor Signoff</Text>
|
|
||||||
<SignoffSection
|
|
||||||
token={token}
|
|
||||||
dispatchId={dispatchId}
|
|
||||||
existing={data.signoffs ?? []}
|
|
||||||
signoffType="vendor"
|
|
||||||
nameLabel="Your name"
|
|
||||||
onAdded={() => void refresh()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="vp-card">
|
|
||||||
<Text variant="heading">Comments</Text>
|
|
||||||
<CommentsSection
|
|
||||||
token={token}
|
|
||||||
dispatchId={dispatchId}
|
|
||||||
comments={data.comments ?? []}
|
|
||||||
onAdded={(comment) =>
|
|
||||||
updateLocal((current) => ({
|
|
||||||
...current,
|
|
||||||
comments: [...(current.comments ?? []), comment],
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
70
src/app/v/[token]/dispatch/_components/checklist-section.tsx
Normal file
70
src/app/v/[token]/dispatch/_components/checklist-section.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
|
||||||
|
import { formatVendorPortalDateTime } from "@/domain/vendor-portal/lib/status-helpers";
|
||||||
|
import type { VendorPortalChecklistItem } from "@/domain/vendor-portal/types/vendor-portal";
|
||||||
|
|
||||||
|
type ChecklistSectionProps = {
|
||||||
|
token: string;
|
||||||
|
dispatchId: number;
|
||||||
|
items: VendorPortalChecklistItem[];
|
||||||
|
locked: boolean;
|
||||||
|
onItemUpdated: (item: VendorPortalChecklistItem) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ChecklistSection({
|
||||||
|
token,
|
||||||
|
dispatchId,
|
||||||
|
items,
|
||||||
|
locked,
|
||||||
|
onItemUpdated,
|
||||||
|
}: ChecklistSectionProps) {
|
||||||
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
|
|
||||||
|
const toggle = async (item: VendorPortalChecklistItem) => {
|
||||||
|
if (locked) return;
|
||||||
|
setErrorMessage("");
|
||||||
|
try {
|
||||||
|
const updated = await vendorPortalApi.updateChecklistItem(
|
||||||
|
token,
|
||||||
|
dispatchId,
|
||||||
|
item.id,
|
||||||
|
!item.isCompleted,
|
||||||
|
);
|
||||||
|
onItemUpdated(updated);
|
||||||
|
} catch {
|
||||||
|
setErrorMessage("Unable to update the checklist. Please try again.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!items.length) {
|
||||||
|
return <Text tone="muted">No checklist items for this dispatch.</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Text variant="error" when={Boolean(errorMessage)}>
|
||||||
|
{errorMessage}
|
||||||
|
</Text>
|
||||||
|
{items.map((item) => (
|
||||||
|
<div key={item.id} className={`vp-checklist-item ${item.isCompleted ? "done" : ""}`}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id={`cl-${item.id}`}
|
||||||
|
checked={item.isCompleted}
|
||||||
|
onChange={() => void toggle(item)}
|
||||||
|
disabled={locked}
|
||||||
|
/>
|
||||||
|
<label htmlFor={`cl-${item.id}`} style={{ flex: 1 }}>
|
||||||
|
{item.itemText}
|
||||||
|
</label>
|
||||||
|
{item.isCompleted && Boolean(item.completedAt) && (
|
||||||
|
<span className="vp-muted" style={{ fontSize: 12 }}>
|
||||||
|
{formatVendorPortalDateTime(item.completedAt)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
src/app/v/[token]/dispatch/_components/comments-section.tsx
Normal file
71
src/app/v/[token]/dispatch/_components/comments-section.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
|
||||||
|
import { formatVendorPortalDateTime } from "@/domain/vendor-portal/lib/status-helpers";
|
||||||
|
import type { VendorPortalComment } from "@/domain/vendor-portal/types/vendor-portal";
|
||||||
|
|
||||||
|
type CommentsSectionProps = {
|
||||||
|
token: string;
|
||||||
|
dispatchId: number;
|
||||||
|
comments: VendorPortalComment[];
|
||||||
|
onAdded: (comment: VendorPortalComment) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CommentsSection({ token, dispatchId, comments, onAdded }: CommentsSectionProps) {
|
||||||
|
const [text, setText] = useState("");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
|
|
||||||
|
const submit = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!text.trim()) return;
|
||||||
|
setErrorMessage("");
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const added = await vendorPortalApi.addComment(token, dispatchId, text.trim());
|
||||||
|
onAdded(added);
|
||||||
|
setText("");
|
||||||
|
} catch {
|
||||||
|
setErrorMessage("Unable to post your comment. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{comments.length === 0 && <Text tone="muted">No comments yet.</Text>}
|
||||||
|
{comments.map((comment) => (
|
||||||
|
<div key={comment.id} className={`vp-comment ${comment.commentType || ""}`}>
|
||||||
|
<div className="vp-comment-meta">
|
||||||
|
{comment.commenter || "Unknown"} · {formatVendorPortalDateTime(comment.createdDate)}
|
||||||
|
</div>
|
||||||
|
<div>{comment.commenttext}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Text variant="error" when={Boolean(errorMessage)}>
|
||||||
|
{errorMessage}
|
||||||
|
</Text>
|
||||||
|
<form onSubmit={(event) => void submit(event)} style={{ marginTop: 12 }}>
|
||||||
|
<textarea
|
||||||
|
className="vp-textarea"
|
||||||
|
placeholder="Add a comment for the dispatcher…"
|
||||||
|
value={text}
|
||||||
|
onChange={(event) => {
|
||||||
|
setText(event.target.value);
|
||||||
|
setErrorMessage("");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="vp-actions">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="vp-btn vp-btn-primary"
|
||||||
|
disabled={submitting || !text.trim()}
|
||||||
|
>
|
||||||
|
{submitting ? "Posting…" : "Post Comment"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { ChecklistSection } from "@/app/v/[token]/dispatch/_components/checklist-section";
|
||||||
|
import { CommentsSection } from "@/app/v/[token]/dispatch/_components/comments-section";
|
||||||
|
import { SignoffSection } from "@/app/v/[token]/dispatch/_components/signoff-section";
|
||||||
|
import { UpliftRequestsSection } from "@/app/v/[token]/dispatch/_components/uplift-requests-section";
|
||||||
|
import { VendorPortalDocuments } from "@/app/v/_components/vendor-portal-documents";
|
||||||
|
import type { VendorPortalDispatchDetail } from "@/domain/vendor-portal/types/vendor-portal";
|
||||||
|
|
||||||
|
type DispatchDetailSectionsProps = {
|
||||||
|
token: string;
|
||||||
|
dispatchId: number;
|
||||||
|
data: VendorPortalDispatchDetail;
|
||||||
|
locked: boolean;
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
updateLocal: (
|
||||||
|
updater: (current: VendorPortalDispatchDetail) => VendorPortalDispatchDetail,
|
||||||
|
) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DispatchDetailSections({
|
||||||
|
token,
|
||||||
|
dispatchId,
|
||||||
|
data,
|
||||||
|
locked,
|
||||||
|
refresh,
|
||||||
|
updateLocal,
|
||||||
|
}: DispatchDetailSectionsProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="vp-card">
|
||||||
|
<Text variant="heading">Checklist</Text>
|
||||||
|
<ChecklistSection
|
||||||
|
token={token}
|
||||||
|
dispatchId={dispatchId}
|
||||||
|
items={data.checklist ?? []}
|
||||||
|
locked={locked}
|
||||||
|
onItemUpdated={(updated) =>
|
||||||
|
updateLocal((current) => ({
|
||||||
|
...current,
|
||||||
|
checklist: (current.checklist ?? []).map((item) =>
|
||||||
|
item.id === updated.id ? { ...item, ...updated } : item,
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="vp-card">
|
||||||
|
<Text variant="heading">Completion documents</Text>
|
||||||
|
<VendorPortalDocuments
|
||||||
|
token={token}
|
||||||
|
dispatchId={dispatchId}
|
||||||
|
documents={data.documents ?? []}
|
||||||
|
locked={locked}
|
||||||
|
onChanged={refresh}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="vp-card">
|
||||||
|
<Text variant="heading">NTE Uplift Requests</Text>
|
||||||
|
<UpliftRequestsSection
|
||||||
|
token={token}
|
||||||
|
dispatchId={dispatchId}
|
||||||
|
currentNte={data.nteAmount}
|
||||||
|
upliftRequests={data.upliftRequests ?? []}
|
||||||
|
locked={locked}
|
||||||
|
onChanged={refresh}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="vp-card">
|
||||||
|
<Text variant="heading">Customer Signoff</Text>
|
||||||
|
<SignoffSection
|
||||||
|
token={token}
|
||||||
|
dispatchId={dispatchId}
|
||||||
|
existing={data.signoffs ?? []}
|
||||||
|
signoffType="customer"
|
||||||
|
nameLabel="Customer name"
|
||||||
|
onAdded={() => void refresh()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="vp-card">
|
||||||
|
<Text variant="heading">Vendor Signoff</Text>
|
||||||
|
<SignoffSection
|
||||||
|
token={token}
|
||||||
|
dispatchId={dispatchId}
|
||||||
|
existing={data.signoffs ?? []}
|
||||||
|
signoffType="vendor"
|
||||||
|
nameLabel="Your name"
|
||||||
|
onAdded={() => void refresh()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="vp-card">
|
||||||
|
<Text variant="heading">Comments</Text>
|
||||||
|
<CommentsSection
|
||||||
|
token={token}
|
||||||
|
dispatchId={dispatchId}
|
||||||
|
comments={data.comments ?? []}
|
||||||
|
onAdded={(comment) =>
|
||||||
|
updateLocal((current) => ({
|
||||||
|
...current,
|
||||||
|
comments: [...(current.comments ?? []), comment],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
type DispatchStatusActionsProps = {
|
||||||
|
status: string;
|
||||||
|
locked: boolean;
|
||||||
|
busy: boolean;
|
||||||
|
onAccept: () => void;
|
||||||
|
onStart: () => void;
|
||||||
|
onComplete: () => void;
|
||||||
|
onRequestCancel: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DispatchStatusActions({
|
||||||
|
status,
|
||||||
|
locked,
|
||||||
|
busy,
|
||||||
|
onAccept,
|
||||||
|
onStart,
|
||||||
|
onComplete,
|
||||||
|
onRequestCancel,
|
||||||
|
}: DispatchStatusActionsProps) {
|
||||||
|
return (
|
||||||
|
<div className="vp-actions">
|
||||||
|
{status === "Sent" && (
|
||||||
|
<button type="button" className="vp-btn vp-btn-success" disabled={busy} onClick={onAccept}>
|
||||||
|
Accept Dispatch
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{status === "Acknowledged" && (
|
||||||
|
<button type="button" className="vp-btn vp-btn-primary" disabled={busy} onClick={onStart}>
|
||||||
|
Start Work
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{status === "In Progress" && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="vp-btn vp-btn-primary"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={onComplete}
|
||||||
|
>
|
||||||
|
Mark Completed
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!locked && status !== "Sent" && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="vp-btn vp-btn-warn"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={onRequestCancel}
|
||||||
|
>
|
||||||
|
Request Cancel
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
124
src/app/v/[token]/dispatch/_components/dispatch-summary-card.tsx
Normal file
124
src/app/v/[token]/dispatch/_components/dispatch-summary-card.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
import { Link } from "react-router";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import {
|
||||||
|
formatVendorPortalDateTime,
|
||||||
|
vendorPortalStatusClass,
|
||||||
|
} from "@/domain/vendor-portal/lib/status-helpers";
|
||||||
|
import type { VendorPortalDispatchDetail } from "@/domain/vendor-portal/types/vendor-portal";
|
||||||
|
import { DispatchStatusActions } from "@/app/v/[token]/dispatch/_components/dispatch-status-actions";
|
||||||
|
|
||||||
|
type DispatchSummaryCardProps = {
|
||||||
|
token: string;
|
||||||
|
data: VendorPortalDispatchDetail;
|
||||||
|
locked: boolean;
|
||||||
|
busy: boolean;
|
||||||
|
onAccept: () => void;
|
||||||
|
onStart: () => void;
|
||||||
|
onComplete: () => void;
|
||||||
|
onRequestCancel: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DispatchSummaryCard({
|
||||||
|
token,
|
||||||
|
data,
|
||||||
|
locked,
|
||||||
|
busy,
|
||||||
|
onAccept,
|
||||||
|
onStart,
|
||||||
|
onComplete,
|
||||||
|
onRequestCancel,
|
||||||
|
}: DispatchSummaryCardProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Link to={`/v/${token}/dashboard`} className="vp-back-link">
|
||||||
|
← Back to dispatches
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="vp-card">
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
|
||||||
|
<div>
|
||||||
|
<Text variant="title">{data.dispatchNumber}</Text>
|
||||||
|
<div className="vp-muted">{data.workOrder?.workerOrderTitle}</div>
|
||||||
|
</div>
|
||||||
|
<span className={vendorPortalStatusClass(data.status)}>{data.status}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
{data.workOrder != null && Boolean(data.workOrder.internalWONumber) && (
|
||||||
|
<div className="vp-row">
|
||||||
|
<strong>WO #</strong>
|
||||||
|
<span>{data.workOrder.internalWONumber}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{Boolean(data.poNumber) && (
|
||||||
|
<div className="vp-row">
|
||||||
|
<strong>PO #</strong>
|
||||||
|
<span>{data.poNumber}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{data.nteAmount != null && (
|
||||||
|
<div className="vp-row">
|
||||||
|
<strong>NTE</strong>
|
||||||
|
<span>${Number(data.nteAmount).toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="vp-row">
|
||||||
|
<strong>Invoice</strong>
|
||||||
|
<span>
|
||||||
|
{data.invoiceStatus || "Not Submitted"}
|
||||||
|
{data.invoiceNumber ? ` · ${data.invoiceNumber}` : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="vp-row">
|
||||||
|
<strong>Payment</strong>
|
||||||
|
<span>{data.paymentStatus || "Unavailable"}</span>
|
||||||
|
</div>
|
||||||
|
{Boolean(data.scheduledDate) && (
|
||||||
|
<div className="vp-row">
|
||||||
|
<strong>Scheduled</strong>
|
||||||
|
<span>{formatVendorPortalDateTime(data.scheduledDate)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data.location != null && (
|
||||||
|
<>
|
||||||
|
<Text variant="heading" sx={{ marginTop: 2 }}>
|
||||||
|
Location
|
||||||
|
</Text>
|
||||||
|
<div>{data.location.name}</div>
|
||||||
|
<div className="vp-muted">
|
||||||
|
{[
|
||||||
|
data.location.address,
|
||||||
|
data.location.city,
|
||||||
|
data.location.state,
|
||||||
|
data.location.zipCode,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", ")}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{Boolean(data.description || data.workOrder?.description) && (
|
||||||
|
<>
|
||||||
|
<Text variant="heading" sx={{ marginTop: 2 }}>
|
||||||
|
Description
|
||||||
|
</Text>
|
||||||
|
<div>{data.description || data.workOrder?.description}</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DispatchStatusActions
|
||||||
|
status={data.status}
|
||||||
|
locked={locked}
|
||||||
|
busy={busy}
|
||||||
|
onAccept={onAccept}
|
||||||
|
onStart={onStart}
|
||||||
|
onComplete={onComplete}
|
||||||
|
onRequestCancel={onRequestCancel}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
135
src/app/v/[token]/dispatch/_components/signoff-section.tsx
Normal file
135
src/app/v/[token]/dispatch/_components/signoff-section.tsx
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { SignaturePad } from "@/app/v/_components/signature-pad";
|
||||||
|
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
|
||||||
|
import { formatVendorPortalDateTime } from "@/domain/vendor-portal/lib/status-helpers";
|
||||||
|
import type { VendorPortalSignoff } from "@/domain/vendor-portal/types/vendor-portal";
|
||||||
|
|
||||||
|
type SignoffSectionProps = {
|
||||||
|
token: string;
|
||||||
|
dispatchId: number;
|
||||||
|
existing: VendorPortalSignoff[];
|
||||||
|
signoffType: string;
|
||||||
|
nameLabel: string;
|
||||||
|
onAdded: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SignoffSection({
|
||||||
|
token,
|
||||||
|
dispatchId,
|
||||||
|
existing,
|
||||||
|
signoffType,
|
||||||
|
nameLabel,
|
||||||
|
onAdded,
|
||||||
|
}: SignoffSectionProps) {
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [method, setMethod] = useState<"drawn" | "typed">("drawn");
|
||||||
|
const [typedSig, setTypedSig] = useState("");
|
||||||
|
const [drawnSig, setDrawnSig] = useState("");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
|
|
||||||
|
const existingSignoff = existing.find((signoff) => signoff.signoffType === signoffType);
|
||||||
|
|
||||||
|
if (existingSignoff) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="vp-row">
|
||||||
|
<strong>Signed by</strong>
|
||||||
|
<span>{existingSignoff.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="vp-row">
|
||||||
|
<strong>Method</strong>
|
||||||
|
<span>{existingSignoff.signatureMethod}</span>
|
||||||
|
</div>
|
||||||
|
<div className="vp-row">
|
||||||
|
<strong>Signed at</strong>
|
||||||
|
<span>{formatVendorPortalDateTime(existingSignoff.signedAt)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const submit = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const signature = method === "drawn" ? drawnSig : typedSig;
|
||||||
|
if (!name.trim() || !signature) {
|
||||||
|
setErrorMessage("Name and signature are required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setErrorMessage("");
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await vendorPortalApi.addSignoff(token, dispatchId, {
|
||||||
|
name: name.trim(),
|
||||||
|
signature,
|
||||||
|
signatureMethod: method,
|
||||||
|
signoffType,
|
||||||
|
});
|
||||||
|
onAdded();
|
||||||
|
} catch {
|
||||||
|
setErrorMessage("Unable to submit the signoff. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={(event) => void submit(event)}>
|
||||||
|
<div className="vp-row">
|
||||||
|
<strong>{nameLabel}</strong>
|
||||||
|
<input
|
||||||
|
className="vp-input"
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => {
|
||||||
|
setName(event.target.value);
|
||||||
|
setErrorMessage("");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="vp-row">
|
||||||
|
<strong>Method</strong>
|
||||||
|
<div>
|
||||||
|
<label style={{ marginRight: 12 }}>
|
||||||
|
<input type="radio" checked={method === "drawn"} onChange={() => setMethod("drawn")} />{" "}
|
||||||
|
Drawn
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="radio" checked={method === "typed"} onChange={() => setMethod("typed")} />{" "}
|
||||||
|
Typed
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{method === "drawn" ? (
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<SignaturePad
|
||||||
|
onChange={(signature) => {
|
||||||
|
setDrawnSig(signature);
|
||||||
|
setErrorMessage("");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="vp-row">
|
||||||
|
<strong>Signature</strong>
|
||||||
|
<input
|
||||||
|
className="vp-input"
|
||||||
|
value={typedSig}
|
||||||
|
onChange={(event) => {
|
||||||
|
setTypedSig(event.target.value);
|
||||||
|
setErrorMessage("");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Text variant="error" when={Boolean(errorMessage)}>
|
||||||
|
{errorMessage}
|
||||||
|
</Text>
|
||||||
|
<div className="vp-actions">
|
||||||
|
<button type="submit" className="vp-btn vp-btn-success" disabled={submitting}>
|
||||||
|
{submitting ? "Signing…" : "Submit Signoff"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,169 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
|
||||||
|
import type { VendorPortalUpliftRequest } from "@/domain/vendor-portal/types/vendor-portal";
|
||||||
|
|
||||||
|
type UpliftRequestsSectionProps = {
|
||||||
|
token: string;
|
||||||
|
dispatchId: number;
|
||||||
|
currentNte?: number;
|
||||||
|
upliftRequests: VendorPortalUpliftRequest[];
|
||||||
|
locked: boolean;
|
||||||
|
onChanged: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function validateUpliftRequest(requested: string, currentNte: number | undefined, reason: string) {
|
||||||
|
const amount = Number.parseFloat(requested);
|
||||||
|
if (!Number.isFinite(amount) || amount <= 0) {
|
||||||
|
return "Enter a valid requested NTE amount.";
|
||||||
|
}
|
||||||
|
if (currentNte != null && amount <= currentNte) {
|
||||||
|
return `Requested NTE must be greater than the current NTE ($${currentNte.toFixed(2)}).`;
|
||||||
|
}
|
||||||
|
if (!reason.trim()) {
|
||||||
|
return "Please include a reason for the uplift request.";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UpliftRequestsSection({
|
||||||
|
token,
|
||||||
|
dispatchId,
|
||||||
|
currentNte,
|
||||||
|
upliftRequests,
|
||||||
|
locked,
|
||||||
|
onChanged,
|
||||||
|
}: UpliftRequestsSectionProps) {
|
||||||
|
const [requested, setRequested] = useState("");
|
||||||
|
const [reason, setReason] = useState("");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
|
|
||||||
|
const pending = upliftRequests.find((request) => request.status === "Pending");
|
||||||
|
|
||||||
|
const submit = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const amount = Number.parseFloat(requested);
|
||||||
|
const validationError = validateUpliftRequest(requested, currentNte, reason);
|
||||||
|
setErrorMessage(validationError);
|
||||||
|
if (validationError) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await vendorPortalApi.requestUplift(token, dispatchId, {
|
||||||
|
requestedNTE: amount,
|
||||||
|
reason: reason.trim(),
|
||||||
|
});
|
||||||
|
setRequested("");
|
||||||
|
setReason("");
|
||||||
|
await onChanged();
|
||||||
|
} catch {
|
||||||
|
setErrorMessage("Unable to request the NTE uplift. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancel = async (requestId: number) => {
|
||||||
|
if (!window.confirm("Cancel this uplift request?")) return;
|
||||||
|
setErrorMessage("");
|
||||||
|
try {
|
||||||
|
await vendorPortalApi.cancelUplift(token, dispatchId, requestId);
|
||||||
|
await onChanged();
|
||||||
|
} catch {
|
||||||
|
setErrorMessage("Unable to cancel the NTE uplift request. Please try again.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{upliftRequests.length === 0 && !pending && (
|
||||||
|
<div className="vp-muted" style={{ marginBottom: 12 }}>
|
||||||
|
No uplift requests yet. Use the form below if the work will exceed the current NTE.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{upliftRequests.map((request) => (
|
||||||
|
<div key={request.id} className="vp-uplift-row">
|
||||||
|
<div className="vp-row">
|
||||||
|
<strong>Requested</strong>
|
||||||
|
<span>
|
||||||
|
${Number(request.requestedNTE).toFixed(2)} (was $
|
||||||
|
{Number(request.currentNTE ?? 0).toFixed(2)})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="vp-row">
|
||||||
|
<strong>Status</strong>
|
||||||
|
<span>
|
||||||
|
<span className={`vp-uplift-status-${String(request.status).toLowerCase()}`}>
|
||||||
|
{request.status}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{Boolean(request.vendorReason) && (
|
||||||
|
<div className="vp-row">
|
||||||
|
<strong>Reason</strong>
|
||||||
|
<span>{request.vendorReason}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{request.status === "Pending" && (
|
||||||
|
<div className="vp-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="vp-btn vp-btn-ghost"
|
||||||
|
onClick={() => void cancel(request.id)}
|
||||||
|
>
|
||||||
|
Cancel Request
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Text variant="error" when={Boolean(errorMessage)}>
|
||||||
|
{errorMessage}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{!locked && !pending && (
|
||||||
|
<form onSubmit={(event) => void submit(event)} style={{ marginTop: 12 }}>
|
||||||
|
<div className="vp-row">
|
||||||
|
<strong>Current NTE</strong>
|
||||||
|
<span>${Number(currentNte ?? 0).toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="vp-row">
|
||||||
|
<strong>New total NTE</strong>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
className="vp-input"
|
||||||
|
value={requested}
|
||||||
|
onChange={(event) => {
|
||||||
|
setRequested(event.target.value);
|
||||||
|
setErrorMessage("");
|
||||||
|
}}
|
||||||
|
placeholder="0.00"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="vp-row">
|
||||||
|
<strong>Reason</strong>
|
||||||
|
<textarea
|
||||||
|
className="vp-textarea"
|
||||||
|
value={reason}
|
||||||
|
onChange={(event) => {
|
||||||
|
setReason(event.target.value);
|
||||||
|
setErrorMessage("");
|
||||||
|
}}
|
||||||
|
placeholder="Why is the uplift needed?"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="vp-actions">
|
||||||
|
<button type="submit" className="vp-btn vp-btn-primary" disabled={submitting}>
|
||||||
|
{submitting ? "Submitting…" : "Request Uplift"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
162
src/app/v/_components/vendor-portal-documents.tsx
Normal file
162
src/app/v/_components/vendor-portal-documents.tsx
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { VendorPortalFeedback } from "@/app/v/_components/vendor-portal-feedback";
|
||||||
|
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
|
||||||
|
import { validateVendorDocument } from "@/domain/vendor-portal/lib/document-validation";
|
||||||
|
import type { VendorPortalDocument } from "@/domain/vendor-portal/types/vendor-portal";
|
||||||
|
|
||||||
|
type VendorPortalDocumentsProps = {
|
||||||
|
token: string;
|
||||||
|
dispatchId: number;
|
||||||
|
documents: VendorPortalDocument[];
|
||||||
|
locked: boolean;
|
||||||
|
onChanged: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UploadRequest = {
|
||||||
|
file: File;
|
||||||
|
replacementId?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function VendorPortalDocuments({
|
||||||
|
token,
|
||||||
|
dispatchId,
|
||||||
|
documents,
|
||||||
|
locked,
|
||||||
|
onChanged,
|
||||||
|
}: VendorPortalDocumentsProps) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [replacementId, setReplacementId] = useState<number>();
|
||||||
|
const [progress, setProgress] = useState<number>();
|
||||||
|
const [validationError, setValidationError] = useState<string>();
|
||||||
|
|
||||||
|
const uploadMutation = useMutation({
|
||||||
|
mutationFn: ({ file, replacementId: requestedReplacementId }: UploadRequest) =>
|
||||||
|
vendorPortalApi.uploadDocument({
|
||||||
|
token,
|
||||||
|
dispatchId,
|
||||||
|
file,
|
||||||
|
replacesDocumentId: requestedReplacementId,
|
||||||
|
onProgress: setProgress,
|
||||||
|
}),
|
||||||
|
onSuccess: onChanged,
|
||||||
|
onSettled: () => {
|
||||||
|
setProgress(undefined);
|
||||||
|
setReplacementId(undefined);
|
||||||
|
if (inputRef.current) {
|
||||||
|
inputRef.current.value = "";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const chooseFile = (documentId?: number) => {
|
||||||
|
setValidationError(undefined);
|
||||||
|
uploadMutation.reset();
|
||||||
|
setReplacementId(documentId);
|
||||||
|
inputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadSelectedFile = (file?: File) => {
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validationMessage = validateVendorDocument(file);
|
||||||
|
if (validationMessage) {
|
||||||
|
setValidationError(validationMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setValidationError(undefined);
|
||||||
|
setProgress(0);
|
||||||
|
uploadMutation.mutate({ file, replacementId });
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadError =
|
||||||
|
validationError ??
|
||||||
|
(uploadMutation.error ? "Unable to upload the document. Please try again." : undefined);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
aria-label="Choose completion document"
|
||||||
|
accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png"
|
||||||
|
hidden
|
||||||
|
onChange={(event) => uploadSelectedFile(event.target.files?.[0])}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{documents.length === 0 && (
|
||||||
|
<VendorPortalFeedback>No completion documents uploaded.</VendorPortalFeedback>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{documents.map((document) => (
|
||||||
|
<article key={document.id} className="vp-uplift-row">
|
||||||
|
<div className="vp-row">
|
||||||
|
<strong>File</strong>
|
||||||
|
<span>
|
||||||
|
{document.originalFileName} · v{document.version}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="vp-row">
|
||||||
|
<strong>Security scan</strong>
|
||||||
|
<span>{document.scanStatus}</span>
|
||||||
|
</div>
|
||||||
|
<div className="vp-row">
|
||||||
|
<strong>Review</strong>
|
||||||
|
<span>{document.reviewStatus}</span>
|
||||||
|
</div>
|
||||||
|
{Boolean(document.rejectionReason) && (
|
||||||
|
<VendorPortalFeedback tone="error">{document.rejectionReason}</VendorPortalFeedback>
|
||||||
|
)}
|
||||||
|
<div className="vp-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="vp-btn vp-btn-ghost"
|
||||||
|
disabled={!document.canDownload}
|
||||||
|
onClick={() => void vendorPortalApi.downloadDocument(token, dispatchId, document)}
|
||||||
|
>
|
||||||
|
{document.canDownload ? "Download" : "Quarantined"}
|
||||||
|
</button>
|
||||||
|
{!locked && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="vp-btn vp-btn-ghost"
|
||||||
|
disabled={uploadMutation.isPending}
|
||||||
|
onClick={() => chooseFile(document.id)}
|
||||||
|
>
|
||||||
|
Replace
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{progress !== undefined && (
|
||||||
|
<div role="status" aria-live="polite">
|
||||||
|
<progress max={100} value={progress} style={{ width: "100%" }} />
|
||||||
|
<VendorPortalFeedback>Uploading… {progress}%</VendorPortalFeedback>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{Boolean(uploadError) && (
|
||||||
|
<VendorPortalFeedback tone="error">{uploadError}</VendorPortalFeedback>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!locked && (
|
||||||
|
<div className="vp-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="vp-btn vp-btn-primary"
|
||||||
|
disabled={uploadMutation.isPending}
|
||||||
|
onClick={() => chooseFile()}
|
||||||
|
>
|
||||||
|
Upload document
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
src/app/v/_components/vendor-portal-feedback.tsx
Normal file
19
src/app/v/_components/vendor-portal-feedback.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
|
||||||
|
type VendorPortalFeedbackProps = {
|
||||||
|
children: ReactNode;
|
||||||
|
tone?: "error" | "muted";
|
||||||
|
};
|
||||||
|
|
||||||
|
export function VendorPortalFeedback({ children, tone = "muted" }: VendorPortalFeedbackProps) {
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
variant={tone === "error" ? "error" : "feedback"}
|
||||||
|
tone={tone}
|
||||||
|
className={tone === "error" ? "vp-error" : "vp-muted"}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,9 @@ import {
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
FileText,
|
FileText,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
|
Bell,
|
||||||
|
BarChart3,
|
||||||
|
Star,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
export type NavItemDef = {
|
export type NavItemDef = {
|
||||||
|
|
@ -34,6 +37,7 @@ export const navGroups: NavGroup[] = [
|
||||||
items: [
|
items: [
|
||||||
{ label: "Work Orders", icon: ClipboardList, path: "/workorders", badge: 11 },
|
{ label: "Work Orders", icon: ClipboardList, path: "/workorders", badge: 11 },
|
||||||
{ label: "Dashboard", icon: LayoutDashboard, path: "/" },
|
{ label: "Dashboard", icon: LayoutDashboard, path: "/" },
|
||||||
|
{ label: "Notifications", icon: Bell, path: "/notifications" },
|
||||||
// { label: "Reports", icon: BarChart2, path: "/reports" },
|
// { label: "Reports", icon: BarChart2, path: "/reports" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
@ -42,6 +46,8 @@ export const navGroups: NavGroup[] = [
|
||||||
items: [
|
items: [
|
||||||
{ label: "Vendors", icon: Building2, path: "/vendors" },
|
{ label: "Vendors", icon: Building2, path: "/vendors" },
|
||||||
{ label: "Vendor POs", icon: FileText, path: "/vendor-pos" },
|
{ label: "Vendor POs", icon: FileText, path: "/vendor-pos" },
|
||||||
|
{ label: "Vendor Insights", icon: BarChart3, path: "/vendors/insights" },
|
||||||
|
{ label: "Site Preferences", icon: Star, path: "/vendors/preferences" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
81
src/domain/vendor-operations/api/vendor-operations-api.ts
Normal file
81
src/domain/vendor-operations/api/vendor-operations-api.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import { api, apiGet, apiPut } from "@/api/api";
|
||||||
|
import type {
|
||||||
|
SiteVendorPreference,
|
||||||
|
VendorAssignmentResult,
|
||||||
|
VendorAvailability,
|
||||||
|
VendorAssignmentPayload,
|
||||||
|
VendorInsight,
|
||||||
|
VendorNotification,
|
||||||
|
} from "@/domain/vendor-operations/types/vendor-operations";
|
||||||
|
|
||||||
|
const base = "vendor-operations";
|
||||||
|
|
||||||
|
function searchParams(values: Record<string, string | number | undefined>) {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(values).filter(
|
||||||
|
(entry): entry is [string, string | number] => entry[1] !== undefined,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type InsightParams = {
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
locationId?: string | number;
|
||||||
|
trade?: string;
|
||||||
|
vendorId?: string | number;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function saveDownload(path: string, fileName: string, params: InsightParams): Promise<void> {
|
||||||
|
const blob = await api.get(`${base}/${path}`, { searchParams: searchParams(params) }).blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement("a");
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = fileName;
|
||||||
|
anchor.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const vendorOperationsApi = {
|
||||||
|
notifications: async (): Promise<VendorNotification[]> => {
|
||||||
|
const response = await apiGet<{ items?: VendorNotification[]; Items?: VendorNotification[] }>(
|
||||||
|
`${base}/notifications`,
|
||||||
|
);
|
||||||
|
return response.items ?? response.Items ?? [];
|
||||||
|
},
|
||||||
|
|
||||||
|
availability: async (params: {
|
||||||
|
start?: string;
|
||||||
|
end?: string;
|
||||||
|
locationId?: string | number;
|
||||||
|
trade?: string;
|
||||||
|
}): Promise<VendorAvailability[]> => {
|
||||||
|
const response = await apiGet<{ data?: VendorAvailability[]; Data?: VendorAvailability[] }>(
|
||||||
|
`${base}/availability`,
|
||||||
|
{ searchParams: searchParams(params) },
|
||||||
|
);
|
||||||
|
return response.data ?? response.Data ?? [];
|
||||||
|
},
|
||||||
|
|
||||||
|
insights: async (params: InsightParams): Promise<VendorInsight[]> => {
|
||||||
|
const response = await apiGet<{ data?: VendorInsight[]; Data?: VendorInsight[] }>(
|
||||||
|
`${base}/insights`,
|
||||||
|
{ searchParams: searchParams(params) },
|
||||||
|
);
|
||||||
|
return response.data ?? response.Data ?? [];
|
||||||
|
},
|
||||||
|
|
||||||
|
downloadInsights: (format: "csv" | "pdf", params: InsightParams) =>
|
||||||
|
saveDownload(`insights.${format}`, `vendor-insights.${format}`, params),
|
||||||
|
|
||||||
|
sitePreferences: (locationId: string | number) =>
|
||||||
|
apiGet<SiteVendorPreference[]>(`${base}/sites/${locationId}/preferred-vendors`),
|
||||||
|
|
||||||
|
replaceSitePreferences: (
|
||||||
|
locationId: string | number,
|
||||||
|
preferences: Array<{ vendorId: number; trade?: string; sortOrder: number }>,
|
||||||
|
) => apiPut<SiteVendorPreference[]>(`${base}/sites/${locationId}/preferred-vendors`, preferences),
|
||||||
|
|
||||||
|
assignVendor: (workOrderId: string | number, payload: VendorAssignmentPayload) =>
|
||||||
|
apiPut<VendorAssignmentResult>(`${base}/work-orders/${workOrderId}/assignment`, payload),
|
||||||
|
};
|
||||||
79
src/domain/vendor-operations/types/vendor-operations.ts
Normal file
79
src/domain/vendor-operations/types/vendor-operations.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
export interface VendorNotificationAction {
|
||||||
|
label: string;
|
||||||
|
href: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VendorNotification {
|
||||||
|
id: string;
|
||||||
|
type: "NoVendor" | "VendorConflict";
|
||||||
|
severity: "Medium";
|
||||||
|
group: "No Vendor" | "Vendor Conflict";
|
||||||
|
title: string;
|
||||||
|
workOrderId?: number;
|
||||||
|
workOrderNumber?: string;
|
||||||
|
triggeredAt: string;
|
||||||
|
scheduledAt?: string;
|
||||||
|
action?: VendorNotificationAction;
|
||||||
|
actions?: VendorNotificationAction[];
|
||||||
|
workOrders?: Array<{
|
||||||
|
workOrderId: number;
|
||||||
|
workOrderNumber?: string;
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VendorAvailability {
|
||||||
|
id: number;
|
||||||
|
companyName: string;
|
||||||
|
contactName?: string;
|
||||||
|
tradeSpecialties?: string;
|
||||||
|
availabilityStatus: "Available" | "Unavailable" | "Unknown";
|
||||||
|
availabilityUpdatedAt?: string;
|
||||||
|
isPreferred: boolean;
|
||||||
|
preferenceRank?: number;
|
||||||
|
conflicts: Array<{
|
||||||
|
workOrderId: number;
|
||||||
|
workOrderNumber?: string;
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VendorInsight {
|
||||||
|
vendorId: number;
|
||||||
|
vendor: string;
|
||||||
|
completedJobs: number;
|
||||||
|
cancellationRefusalRate: number;
|
||||||
|
onTimeArrivalRate: number;
|
||||||
|
onTimeCompletionRate: number;
|
||||||
|
averageCycleHours?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SiteVendorPreference {
|
||||||
|
id: number;
|
||||||
|
locationId: number;
|
||||||
|
vendorId: number;
|
||||||
|
vendorName: string;
|
||||||
|
trade?: string;
|
||||||
|
sortOrder: number;
|
||||||
|
isStale: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VendorAssignmentPayload {
|
||||||
|
vendorId: number;
|
||||||
|
scheduledStart?: string;
|
||||||
|
scheduledEnd?: string;
|
||||||
|
estimatedArrivalAt?: string;
|
||||||
|
etaManualOverride: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VendorAssignmentResult {
|
||||||
|
availabilityStatus: "Available" | "Unavailable" | "Unknown";
|
||||||
|
conflicts: Array<{
|
||||||
|
workOrderId: number;
|
||||||
|
workOrderNumber?: string;
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ import type {
|
||||||
VendorPortalComment,
|
VendorPortalComment,
|
||||||
VendorPortalDispatchDetail,
|
VendorPortalDispatchDetail,
|
||||||
VendorPortalDispatchSummary,
|
VendorPortalDispatchSummary,
|
||||||
|
VendorPortalDocument,
|
||||||
VendorPortalSession,
|
VendorPortalSession,
|
||||||
VendorPortalSignoff,
|
VendorPortalSignoff,
|
||||||
} from "@/domain/vendor-portal/types/vendor-portal";
|
} from "@/domain/vendor-portal/types/vendor-portal";
|
||||||
|
|
@ -14,6 +15,66 @@ function resolveVendorPortalUrl(path: string): string {
|
||||||
return `${base}/${API_PATHS.vendorPortal.base}${path}`;
|
return `${base}/${API_PATHS.vendorPortal.base}${path}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UploadVendorDocumentOptions = {
|
||||||
|
token: string;
|
||||||
|
dispatchId: string | number;
|
||||||
|
file: File;
|
||||||
|
replacesDocumentId?: number;
|
||||||
|
onProgress: (percent: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function uploadVendorDocument({
|
||||||
|
token,
|
||||||
|
dispatchId,
|
||||||
|
file,
|
||||||
|
replacesDocumentId,
|
||||||
|
onProgress,
|
||||||
|
}: UploadVendorDocumentOptions): Promise<VendorPortalDocument> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = new XMLHttpRequest();
|
||||||
|
request.open("POST", resolveVendorPortalUrl(`/dispatches/${dispatchId}/documents`));
|
||||||
|
request.setRequestHeader("X-Vendor-Token", token);
|
||||||
|
request.upload.addEventListener("progress", (event) => {
|
||||||
|
if (event.lengthComputable) onProgress(Math.round((event.loaded / event.total) * 100));
|
||||||
|
});
|
||||||
|
request.addEventListener("load", () => {
|
||||||
|
const payload = JSON.parse(request.responseText || "{}") as Record<string, unknown>;
|
||||||
|
if (request.status < 200 || request.status >= 300) {
|
||||||
|
reject(new Error(String(payload.message ?? payload.Message ?? `HTTP ${request.status}`)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve((payload.data ?? payload.Data ?? payload) as VendorPortalDocument);
|
||||||
|
});
|
||||||
|
request.addEventListener("error", () => reject(new Error("Upload failed")));
|
||||||
|
const body = new FormData();
|
||||||
|
body.append("file", file);
|
||||||
|
if (replacesDocumentId !== undefined) {
|
||||||
|
body.append("replacesDocumentId", String(replacesDocumentId));
|
||||||
|
}
|
||||||
|
request.send(body);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadVendorDocument(
|
||||||
|
token: string,
|
||||||
|
dispatchId: string | number,
|
||||||
|
document: VendorPortalDocument,
|
||||||
|
): Promise<void> {
|
||||||
|
const response = await fetch(
|
||||||
|
resolveVendorPortalUrl(`/dispatches/${dispatchId}/documents/${document.id}`),
|
||||||
|
{ headers: { "X-Vendor-Token": token } },
|
||||||
|
);
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error(response.status === 423 ? "File is still being scanned" : "Download failed");
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = window.document.createElement("a");
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = document.originalFileName;
|
||||||
|
anchor.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
async function vendorPortalCall<T>(
|
async function vendorPortalCall<T>(
|
||||||
token: string,
|
token: string,
|
||||||
path: string,
|
path: string,
|
||||||
|
|
@ -131,4 +192,7 @@ export const vendorPortalApi = {
|
||||||
`${API_PATHS.vendorPortal.dispatches}/${id}/uplift-request/${requestId}/cancel`,
|
`${API_PATHS.vendorPortal.dispatches}/${id}/uplift-request/${requestId}/cancel`,
|
||||||
{ method: "POST" },
|
{ method: "POST" },
|
||||||
),
|
),
|
||||||
|
|
||||||
|
uploadDocument: uploadVendorDocument,
|
||||||
|
downloadDocument: downloadVendorDocument,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
12
src/domain/vendor-portal/lib/document-validation.ts
Normal file
12
src/domain/vendor-portal/lib/document-validation.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
const MAX_DOCUMENT_BYTES = 10 * 1024 * 1024;
|
||||||
|
const ALLOWED_DOCUMENT_TYPES = new Set(["application/pdf", "image/jpeg", "image/png"]);
|
||||||
|
|
||||||
|
export function validateVendorDocument(file: File): string | undefined {
|
||||||
|
if (!ALLOWED_DOCUMENT_TYPES.has(file.type)) {
|
||||||
|
return "Only PDF, JPG, and PNG files are allowed.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > MAX_DOCUMENT_BYTES) {
|
||||||
|
return "Files must be 10 MB or smaller.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,11 +14,30 @@ export interface VendorPortalDispatchSummary {
|
||||||
scheduledDate?: string;
|
scheduledDate?: string;
|
||||||
status: string;
|
status: string;
|
||||||
poNumber?: string;
|
poNumber?: string;
|
||||||
|
invoiceNumber?: string;
|
||||||
|
invoiceStatus?: string;
|
||||||
|
paymentStatus?: string;
|
||||||
nteAmount?: number;
|
nteAmount?: number;
|
||||||
dispatchedAt?: string;
|
dispatchedAt?: string;
|
||||||
completedDate?: string;
|
completedDate?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VendorPortalDocument {
|
||||||
|
id: number;
|
||||||
|
originalFileName: string;
|
||||||
|
contentType: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
scanStatus: "Pending" | "Passed" | "Rejected";
|
||||||
|
reviewStatus: "Processing" | "Approved" | "Rejected";
|
||||||
|
rejectionReason?: string;
|
||||||
|
version: number;
|
||||||
|
replacesDocumentId?: number;
|
||||||
|
createdDate?: string;
|
||||||
|
scannedAt?: string;
|
||||||
|
reviewedAt?: string;
|
||||||
|
canDownload: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface VendorPortalChecklistItem {
|
export interface VendorPortalChecklistItem {
|
||||||
id: number;
|
id: number;
|
||||||
itemText: string;
|
itemText: string;
|
||||||
|
|
@ -77,4 +96,5 @@ export interface VendorPortalDispatchDetail extends VendorPortalDispatchSummary
|
||||||
comments: VendorPortalComment[];
|
comments: VendorPortalComment[];
|
||||||
upliftRequests: VendorPortalUpliftRequest[];
|
upliftRequests: VendorPortalUpliftRequest[];
|
||||||
signoffs: VendorPortalSignoff[];
|
signoffs: VendorPortalSignoff[];
|
||||||
|
documents: VendorPortalDocument[];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ import type {
|
||||||
LocationOption,
|
LocationOption,
|
||||||
TaskListTemplate,
|
TaskListTemplate,
|
||||||
UpdateDispatchPayload,
|
UpdateDispatchPayload,
|
||||||
VendorDropdownItem,
|
|
||||||
WorkOrderAuditEntry,
|
WorkOrderAuditEntry,
|
||||||
WorkOrderComment,
|
WorkOrderComment,
|
||||||
WorkOrderDetail,
|
WorkOrderDetail,
|
||||||
|
|
@ -258,6 +257,8 @@ export function mapWorkOrderDispatchSummary(raw: unknown): WorkOrderDispatchSumm
|
||||||
nteAmount: readNumber(item, "nteAmount", "NTEAmount"),
|
nteAmount: readNumber(item, "nteAmount", "NTEAmount"),
|
||||||
status: readString(item, "status", "Status"),
|
status: readString(item, "status", "Status"),
|
||||||
dispatchedAt: readString(item, "dispatchedAt", "DispatchedAt"),
|
dispatchedAt: readString(item, "dispatchedAt", "DispatchedAt"),
|
||||||
|
estimatedArrivalAt: readString(item, "estimatedArrivalAt", "EstimatedArrivalAt"),
|
||||||
|
etaManualOverride: readBool(item, "etaManualOverride", "EtaManualOverride"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -411,23 +412,7 @@ export function mapEmployeeOptions(rawItems: unknown[]): EmployeeOption[] {
|
||||||
return rawItems.map(mapEmployeeOption);
|
return rawItems.map(mapEmployeeOption);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapVendorDropdownItem(raw: unknown): VendorDropdownItem {
|
export { mapVendorDropdownItem, mapVendorDropdownItems } from "./work-order-vendor-mapper";
|
||||||
const item = asRecord(raw);
|
|
||||||
const email = readString(item, "email", "Email");
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: readId(item),
|
|
||||||
companyName: readString(item, "companyName", "CompanyName"),
|
|
||||||
tradeSpecialties: readString(item, "tradeSpecialties", "TradeSpecialties"),
|
|
||||||
address: readString(item, "address", "Address"),
|
|
||||||
distanceMiles: readNumber(item, "distanceMiles", "DistanceMiles"),
|
|
||||||
email: email || undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mapVendorDropdownItems(rawItems: unknown[]): VendorDropdownItem[] {
|
|
||||||
return rawItems.map(mapVendorDropdownItem);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mapTaskListTemplate(raw: unknown): TaskListTemplate {
|
export function mapTaskListTemplate(raw: unknown): TaskListTemplate {
|
||||||
const item = asRecord(raw);
|
const item = asRecord(raw);
|
||||||
|
|
@ -507,6 +492,8 @@ export function mapDispatchDetail(raw: unknown): DispatchDetail {
|
||||||
status: readString(item, "status", "Status"),
|
status: readString(item, "status", "Status"),
|
||||||
nteAmount: readNumber(item, "nteAmount", "NTEAmount"),
|
nteAmount: readNumber(item, "nteAmount", "NTEAmount"),
|
||||||
scheduledDate: readString(item, "scheduledDate", "ScheduledDate"),
|
scheduledDate: readString(item, "scheduledDate", "ScheduledDate"),
|
||||||
|
estimatedArrivalAt: readString(item, "estimatedArrivalAt", "EstimatedArrivalAt"),
|
||||||
|
etaManualOverride: readBool(item, "etaManualOverride", "EtaManualOverride"),
|
||||||
completedDate: readString(item, "completedDate", "CompletedDate"),
|
completedDate: readString(item, "completedDate", "CompletedDate"),
|
||||||
description: readString(item, "description", "Description"),
|
description: readString(item, "description", "Description"),
|
||||||
dispatchedAt: readString(item, "dispatchedAt", "DispatchedAt"),
|
dispatchedAt: readString(item, "dispatchedAt", "DispatchedAt"),
|
||||||
|
|
@ -530,6 +517,8 @@ export function mapDispatchToVendorPayload(
|
||||||
VendorIds: payload.vendorIds,
|
VendorIds: payload.vendorIds,
|
||||||
NTEAmount: payload.nteAmount,
|
NTEAmount: payload.nteAmount,
|
||||||
ScheduledDate: payload.scheduledDate,
|
ScheduledDate: payload.scheduledDate,
|
||||||
|
EstimatedArrivalAt: payload.estimatedArrivalAt,
|
||||||
|
EtaManualOverride: payload.etaManualOverride ?? false,
|
||||||
Description: payload.description,
|
Description: payload.description,
|
||||||
TaskListTemplateId: payload.taskListTemplateId,
|
TaskListTemplateId: payload.taskListTemplateId,
|
||||||
CustomChecklistItems: payload.customChecklistItems,
|
CustomChecklistItems: payload.customChecklistItems,
|
||||||
|
|
@ -542,6 +531,8 @@ export function mapUpdateDispatchPayload(payload: UpdateDispatchPayload): Record
|
||||||
Status: payload.status,
|
Status: payload.status,
|
||||||
NTEAmount: payload.nteAmount,
|
NTEAmount: payload.nteAmount,
|
||||||
ScheduledDate: payload.scheduledDate,
|
ScheduledDate: payload.scheduledDate,
|
||||||
|
EstimatedArrivalAt: payload.estimatedArrivalAt,
|
||||||
|
EtaManualOverride: payload.etaManualOverride ?? false,
|
||||||
CompletedDate: payload.completedDate,
|
CompletedDate: payload.completedDate,
|
||||||
Description: payload.description,
|
Description: payload.description,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
60
src/domain/work-orders/mappers/work-order-vendor-mapper.ts
Normal file
60
src/domain/work-orders/mappers/work-order-vendor-mapper.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||||
|
|
||||||
|
function asRecord(raw: unknown): Record<string, unknown> {
|
||||||
|
return typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readId(record: Record<string, unknown>): string | number {
|
||||||
|
const value = record.id ?? record.Id;
|
||||||
|
if (typeof value === "string" || typeof value === "number") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function readString(record: Record<string, unknown>, ...keys: string[]): string {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = record[key];
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === "number") {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function readNumber(record: Record<string, unknown>, ...keys: string[]): number | null {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = record[key];
|
||||||
|
if (typeof value === "number") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === "string" && value !== "") {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isNaN(parsed)) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapVendorDropdownItem(raw: unknown): VendorDropdownItem {
|
||||||
|
const item = asRecord(raw);
|
||||||
|
const email = readString(item, "email", "Email");
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: readId(item),
|
||||||
|
companyName: readString(item, "companyName", "CompanyName"),
|
||||||
|
tradeSpecialties: readString(item, "tradeSpecialties", "TradeSpecialties"),
|
||||||
|
address: readString(item, "address", "Address"),
|
||||||
|
distanceMiles: readNumber(item, "distanceMiles", "DistanceMiles"),
|
||||||
|
email: email || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapVendorDropdownItems(rawItems: unknown[]): VendorDropdownItem[] {
|
||||||
|
return rawItems.map(mapVendorDropdownItem);
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,8 @@ export interface WorkOrderDispatchSummary {
|
||||||
nteAmount: number | null;
|
nteAmount: number | null;
|
||||||
status: string;
|
status: string;
|
||||||
dispatchedAt: string;
|
dispatchedAt: string;
|
||||||
|
estimatedArrivalAt: string;
|
||||||
|
etaManualOverride: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkOrderVendorUpdatePayload {
|
export interface WorkOrderVendorUpdatePayload {
|
||||||
|
|
@ -59,6 +61,8 @@ export interface DispatchDetail {
|
||||||
status: string;
|
status: string;
|
||||||
nteAmount: number | null;
|
nteAmount: number | null;
|
||||||
scheduledDate: string;
|
scheduledDate: string;
|
||||||
|
estimatedArrivalAt: string;
|
||||||
|
etaManualOverride: boolean;
|
||||||
completedDate: string;
|
completedDate: string;
|
||||||
description: string;
|
description: string;
|
||||||
dispatchedAt: string;
|
dispatchedAt: string;
|
||||||
|
|
@ -76,6 +80,8 @@ export interface DispatchToVendorPayload {
|
||||||
vendorIds: (string | number)[];
|
vendorIds: (string | number)[];
|
||||||
nteAmount: number | null;
|
nteAmount: number | null;
|
||||||
scheduledDate: string | null;
|
scheduledDate: string | null;
|
||||||
|
estimatedArrivalAt?: string | null;
|
||||||
|
etaManualOverride?: boolean;
|
||||||
description: string;
|
description: string;
|
||||||
taskListTemplateId: number | null;
|
taskListTemplateId: number | null;
|
||||||
customChecklistItems: string[] | null;
|
customChecklistItems: string[] | null;
|
||||||
|
|
@ -86,6 +92,8 @@ export interface UpdateDispatchPayload {
|
||||||
status: string;
|
status: string;
|
||||||
nteAmount: number | null;
|
nteAmount: number | null;
|
||||||
scheduledDate: string | null;
|
scheduledDate: string | null;
|
||||||
|
estimatedArrivalAt?: string | null;
|
||||||
|
etaManualOverride?: boolean;
|
||||||
completedDate: string | null;
|
completedDate: string | null;
|
||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
83
src/domain/work-orders/use-cases/use-save-work-order.ts
Normal file
83
src/domain/work-orders/use-cases/use-save-work-order.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
|
import { vendorOperationsApi } from "@/domain/vendor-operations/api/vendor-operations-api";
|
||||||
|
import type {
|
||||||
|
VendorAssignmentPayload,
|
||||||
|
VendorAssignmentResult,
|
||||||
|
} from "@/domain/vendor-operations/types/vendor-operations";
|
||||||
|
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
|
||||||
|
import type { WorkOrderFormSchemaValues } from "@/domain/work-orders/schemas/work-order-schema";
|
||||||
|
import { queryKeys } from "@/infra/query-key/query-key";
|
||||||
|
|
||||||
|
type SaveWorkOrderInput = {
|
||||||
|
workOrderId?: string | number;
|
||||||
|
values: WorkOrderFormSchemaValues;
|
||||||
|
vendorAssignment?: VendorAssignmentPayload;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SaveWorkOrderResult = {
|
||||||
|
workOrderId: string | number;
|
||||||
|
assignment?: VendorAssignmentResult;
|
||||||
|
};
|
||||||
|
|
||||||
|
class PartialWorkOrderSaveError extends Error {
|
||||||
|
constructor(public readonly workOrderId: string | number) {
|
||||||
|
super("Work order saved, but the vendor assignment could not be completed.");
|
||||||
|
this.name = "PartialWorkOrderSaveError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSaveWorkOrder() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
workOrderId,
|
||||||
|
values,
|
||||||
|
vendorAssignment,
|
||||||
|
}: SaveWorkOrderInput): Promise<SaveWorkOrderResult> => {
|
||||||
|
const savedWorkOrder = workOrderId
|
||||||
|
? await workOrdersApi.update(workOrderId, values)
|
||||||
|
: await workOrdersApi.create(values);
|
||||||
|
|
||||||
|
let assignment: VendorAssignmentResult | undefined;
|
||||||
|
if (vendorAssignment) {
|
||||||
|
try {
|
||||||
|
assignment = await vendorOperationsApi.assignVendor(savedWorkOrder.id, vendorAssignment);
|
||||||
|
} catch {
|
||||||
|
throw new PartialWorkOrderSaveError(savedWorkOrder.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { workOrderId: savedWorkOrder.id, assignment };
|
||||||
|
},
|
||||||
|
onSuccess: ({ workOrderId, assignment }) => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.workOrders.detail(workOrderId),
|
||||||
|
});
|
||||||
|
toast.success("Work order saved successfully");
|
||||||
|
if (assignment?.conflicts.length) {
|
||||||
|
const count = assignment.conflicts.length;
|
||||||
|
toast.warning(`Vendor assigned with ${count} scheduling conflict${count > 1 ? "s" : ""}.`);
|
||||||
|
}
|
||||||
|
navigate(`/workorders/${workOrderId}`);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
if (error instanceof PartialWorkOrderSaveError) {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.workOrders.detail(error.workOrderId),
|
||||||
|
});
|
||||||
|
toast.error(error.message);
|
||||||
|
navigate(`/workorders/${error.workOrderId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.error("Unable to save the work order. Please try again.");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -118,3 +118,28 @@ export function toDateInputValue(value: string | null | undefined): string {
|
||||||
}
|
}
|
||||||
return value.split("T")[0] ?? value;
|
return value.split("T")[0] ?? value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function toDateTimeLocalInputValue(value: string | null | undefined): string {
|
||||||
|
if (!value) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const pad = (part: number) => String(part).padStart(2, "0");
|
||||||
|
|
||||||
|
return [
|
||||||
|
date.getFullYear(),
|
||||||
|
"-",
|
||||||
|
pad(date.getMonth() + 1),
|
||||||
|
"-",
|
||||||
|
pad(date.getDate()),
|
||||||
|
"T",
|
||||||
|
pad(date.getHours()),
|
||||||
|
":",
|
||||||
|
pad(date.getMinutes()),
|
||||||
|
].join("");
|
||||||
|
}
|
||||||
|
|
|
||||||
100
src/test/app/v/vendor-portal-documents.test.tsx
Normal file
100
src/test/app/v/vendor-portal-documents.test.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { VendorPortalDocuments } from "@/app/v/_components/vendor-portal-documents";
|
||||||
|
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
|
||||||
|
import type { VendorPortalDocument } from "@/domain/vendor-portal/types/vendor-portal";
|
||||||
|
import { renderWithProviders } from "@/test/test-utils";
|
||||||
|
|
||||||
|
const quarantinedDocument: VendorPortalDocument = {
|
||||||
|
id: 12,
|
||||||
|
originalFileName: "completion.pdf",
|
||||||
|
contentType: "application/pdf",
|
||||||
|
sizeBytes: 1024,
|
||||||
|
scanStatus: "Pending",
|
||||||
|
reviewStatus: "Processing",
|
||||||
|
version: 1,
|
||||||
|
canDownload: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("VendorPortalDocuments", () => {
|
||||||
|
it("rejects unsupported files before starting an upload", async () => {
|
||||||
|
const upload = vi.spyOn(vendorPortalApi, "uploadDocument");
|
||||||
|
renderWithProviders(
|
||||||
|
<VendorPortalDocuments
|
||||||
|
token="portal-token"
|
||||||
|
dispatchId={7}
|
||||||
|
documents={[]}
|
||||||
|
locked={false}
|
||||||
|
onChanged={vi.fn()}
|
||||||
|
/>,
|
||||||
|
{ withAuth: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("Choose completion document"), {
|
||||||
|
target: { files: [new File(["text"], "notes.txt", { type: "text/plain" })] },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||||
|
"Only PDF, JPG, and PNG files are allowed.",
|
||||||
|
);
|
||||||
|
expect(upload).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps pending documents quarantined", () => {
|
||||||
|
renderWithProviders(
|
||||||
|
<VendorPortalDocuments
|
||||||
|
token="portal-token"
|
||||||
|
dispatchId={7}
|
||||||
|
documents={[quarantinedDocument]}
|
||||||
|
locked={false}
|
||||||
|
onChanged={vi.fn()}
|
||||||
|
/>,
|
||||||
|
{ withAuth: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: "Quarantined" })).toBeDisabled();
|
||||||
|
expect(screen.getByText("Pending")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Processing")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports upload progress and refreshes the dispatch after success", async () => {
|
||||||
|
const onChanged = vi.fn().mockResolvedValue(undefined);
|
||||||
|
let finishUpload: (() => void) | undefined;
|
||||||
|
vi.spyOn(vendorPortalApi, "uploadDocument").mockImplementation(async ({ onProgress }) => {
|
||||||
|
onProgress(65);
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
finishUpload = resolve;
|
||||||
|
});
|
||||||
|
return quarantinedDocument;
|
||||||
|
});
|
||||||
|
renderWithProviders(
|
||||||
|
<VendorPortalDocuments
|
||||||
|
token="portal-token"
|
||||||
|
dispatchId={7}
|
||||||
|
documents={[]}
|
||||||
|
locked={false}
|
||||||
|
onChanged={onChanged}
|
||||||
|
/>,
|
||||||
|
{ withAuth: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("Choose completion document"), {
|
||||||
|
target: {
|
||||||
|
files: [
|
||||||
|
new File(["%PDF-1.4"], "completion.pdf", {
|
||||||
|
type: "application/pdf",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await screen.findByText("Uploading… 65%")).toBeInTheDocument();
|
||||||
|
finishUpload?.();
|
||||||
|
await waitFor(() => expect(onChanged).toHaveBeenCalledOnce());
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue