chore(work-orders): sync platform-polish with wizard-board-create tip
22
.github/workflows/ci.yaml
vendored
|
|
@ -41,3 +41,25 @@ jobs:
|
|||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run verify
|
||||
|
||||
vendor-visual-regression:
|
||||
runs-on: ubuntu-latest
|
||||
container: mcr.microsoft.com/playwright:v1.61.1-noble
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run test:e2e:visual
|
||||
- name: Upload visual diff artifacts
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: vendor-visual-regression-diffs
|
||||
path: |
|
||||
test-results/visual
|
||||
playwright-report-visual
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
|
|
|
|||
1
.gitignore
vendored
|
|
@ -10,6 +10,7 @@
|
|||
/coverage
|
||||
/test-results
|
||||
/playwright-report
|
||||
/playwright-report-visual
|
||||
/blob-report
|
||||
/playwright/.cache
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ dist
|
|||
build
|
||||
node_modules
|
||||
coverage
|
||||
playwright-report-visual
|
||||
package-lock.json
|
||||
*.tsbuildinfo
|
||||
|
||||
|
|
@ -10,4 +11,4 @@ src/pages/**
|
|||
src/**/*.js
|
||||
src/**/*.jsx
|
||||
src/**/*.css
|
||||
!src/styles/globals.css
|
||||
!src/styles/globals.css
|
||||
|
|
|
|||
BIN
e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png
vendored
Normal file
|
After Width: | Height: | Size: 130 KiB |
BIN
e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-detail.png
vendored
Normal file
|
After Width: | Height: | Size: 116 KiB |
BIN
e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png
vendored
Normal file
|
After Width: | Height: | Size: 142 KiB |
BIN
e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png
vendored
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png
vendored
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-filter.png
vendored
Normal file
|
After Width: | Height: | Size: 97 KiB |
BIN
e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png
vendored
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png
vendored
Normal file
|
After Width: | Height: | Size: 111 KiB |
BIN
e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile-navigation.png
vendored
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile.png
vendored
Normal file
|
After Width: | Height: | Size: 46 KiB |
221
e2e/vendor-portal/portal-lifecycle.spec.ts
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
import { expect, test, type Page, type Route } from "@playwright/test";
|
||||
|
||||
const PORTAL_TOKEN = "portal-e2e-token";
|
||||
|
||||
const futureDate = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString();
|
||||
const pastDate = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
interface DispatchDetail {
|
||||
id: number;
|
||||
dispatchNumber: string;
|
||||
workOrderTitle?: string;
|
||||
internalWONumber?: string;
|
||||
status: string;
|
||||
nteAmount?: number;
|
||||
scheduledDate?: string;
|
||||
poNumber?: string;
|
||||
dispatchedAt?: string;
|
||||
completedDate?: string;
|
||||
refusedAt?: string;
|
||||
statusUpdatedAt?: string;
|
||||
description?: string;
|
||||
workOrder?: Record<string, unknown>;
|
||||
location?: Record<string, unknown>;
|
||||
dispatcherContact?: { name?: string; email?: string; phone?: string };
|
||||
checklist: never[];
|
||||
comments: never[];
|
||||
upliftRequests: never[];
|
||||
signoffs: never[];
|
||||
documents: never[];
|
||||
}
|
||||
|
||||
const sentDetail: DispatchDetail = {
|
||||
id: 101,
|
||||
dispatchNumber: "DSP-101",
|
||||
workOrderTitle: "Emergency boiler repair",
|
||||
internalWONumber: "WO-8842",
|
||||
status: "Sent",
|
||||
nteAmount: 1250,
|
||||
scheduledDate: futureDate,
|
||||
poNumber: "PO-555",
|
||||
statusUpdatedAt: "2026-08-09T16:30:00Z",
|
||||
description: "Boiler is leaking; vendor visit required.",
|
||||
workOrder: {
|
||||
workerOrderTitle: "Emergency boiler repair",
|
||||
internalWONumber: "WO-8842",
|
||||
trade: "Plumbing",
|
||||
subTrade: "Backflow Preventers",
|
||||
siteCode: "STL-01",
|
||||
service: "Boiler Repair",
|
||||
},
|
||||
location: {
|
||||
name: "Central Distribution Center",
|
||||
address: "100 Industrial Pkwy",
|
||||
city: "St. Louis",
|
||||
state: "MO",
|
||||
zipCode: "63101",
|
||||
},
|
||||
dispatcherContact: {
|
||||
name: "Dana Field",
|
||||
email: "dana@seahavenind.com",
|
||||
phone: "314-555-0142",
|
||||
},
|
||||
checklist: [],
|
||||
comments: [],
|
||||
upliftRequests: [],
|
||||
signoffs: [],
|
||||
documents: [],
|
||||
};
|
||||
|
||||
const listDispatches = [
|
||||
{
|
||||
id: 101,
|
||||
dispatchNumber: "DSP-101",
|
||||
workOrderTitle: "Emergency boiler repair",
|
||||
internalWONumber: "WO-8842",
|
||||
locationName: "Central Distribution Center",
|
||||
locationCity: "St. Louis",
|
||||
locationState: "MO",
|
||||
status: "Sent",
|
||||
nteAmount: 1250,
|
||||
scheduledDate: futureDate,
|
||||
},
|
||||
{
|
||||
id: 102,
|
||||
dispatchNumber: "DSP-102",
|
||||
workOrderTitle: "HVAC quarterly service",
|
||||
status: "Refused",
|
||||
refusedAt: "2026-08-08T10:00:00Z",
|
||||
},
|
||||
{
|
||||
id: 103,
|
||||
dispatchNumber: "DSP-103",
|
||||
workOrderTitle: "PastAcknowledged job",
|
||||
status: "Acknowledged",
|
||||
scheduledDate: pastDate,
|
||||
},
|
||||
];
|
||||
|
||||
async function fulfill(route: Route, body: unknown, status = 200) {
|
||||
await route.fulfill({
|
||||
status,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async function mockPortalApi(page: Page, options: { sessionError?: boolean } = {}) {
|
||||
const detailState: { current: DispatchDetail } = { current: { ...sentDetail } };
|
||||
|
||||
await page.route("**/api/vendor-portal/session", (route) => {
|
||||
if (options.sessionError) {
|
||||
fulfill(route, { message: "Invalid or expired token" }, 401);
|
||||
return;
|
||||
}
|
||||
fulfill(route, { data: { companyName: "Gateway Plumbing", vendorId: 101 } });
|
||||
});
|
||||
|
||||
await page.route(/\/api\/vendor-portal\/dispatches\/\d+\/refuse$/, async (route) => {
|
||||
const body = route.request().postDataJSON() as { reason?: string };
|
||||
detailState.current = {
|
||||
...detailState.current,
|
||||
status: "Refused",
|
||||
refusedAt: new Date().toISOString(),
|
||||
};
|
||||
fulfill(route, {
|
||||
data: {
|
||||
id: detailState.current.id,
|
||||
status: "Refused",
|
||||
refusedAt: detailState.current.refusedAt,
|
||||
reason: body?.reason,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await page.route(/\/api\/vendor-portal\/dispatches\/\d+$/, (route) =>
|
||||
fulfill(route, { data: detailState.current }),
|
||||
);
|
||||
|
||||
await page.route(/\/api\/vendor-portal\/dispatches(?:\?.*)?$/, (route) =>
|
||||
fulfill(route, { data: listDispatches }),
|
||||
);
|
||||
|
||||
return detailState;
|
||||
}
|
||||
|
||||
test.describe("Vendor portal lifecycle", () => {
|
||||
test("upcoming filter, detail snapshot, refuse dialog, and locked Refused state", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockPortalApi(page);
|
||||
await page.goto(`/v/${PORTAL_TOKEN}/dashboard`);
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Your Work" })).toBeVisible();
|
||||
|
||||
await expect(page.getByRole("button", { name: /^Upcoming/ })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /^Refused/ })).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: /^Upcoming/ }).click();
|
||||
await expect(page.getByText("DSP-101 — Emergency boiler repair")).toBeVisible();
|
||||
await expect(page.getByText("DSP-103")).toHaveCount(0);
|
||||
|
||||
await page.getByText("DSP-101 — Emergency boiler repair").click();
|
||||
await expect(page).toHaveURL(/\/dispatch\/101$/);
|
||||
|
||||
await expect(page.getByRole("heading", { name: "DSP-101" })).toBeVisible();
|
||||
await expect(page.getByText("Boiler Repair", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("STL-01")).toBeVisible();
|
||||
await expect(page.getByText("$1250.00").first()).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "dana@seahavenind.com" })).toBeVisible();
|
||||
await expect(page.getByText("Dana Field")).toBeVisible();
|
||||
await expect(page.getByText("314-555-0142")).toBeVisible();
|
||||
await expect(page.getByText(/Status updated/i)).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Refuse Dispatch" }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "Refuse dispatch" });
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByLabel(/Reason/i).fill("Equipment unavailable this week.");
|
||||
await expect(dialog.getByText(/\d+\/500/)).toBeVisible();
|
||||
await dialog.getByRole("button", { name: "Refuse dispatch" }).click();
|
||||
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await expect(page.getByText("Refused", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(/refused and locked/i)).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Accept Dispatch" })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Refuse Dispatch" })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Submit Signoff" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("covers the refuse dialog at a mobile viewport", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await mockPortalApi(page);
|
||||
await page.goto(`/v/${PORTAL_TOKEN}/dashboard`);
|
||||
|
||||
await page.getByText("DSP-101 — Emergency boiler repair").click();
|
||||
await expect(page).toHaveURL(/\/dispatch\/101$/);
|
||||
|
||||
await page.getByRole("button", { name: "Refuse Dispatch" }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "Refuse dispatch" });
|
||||
await expect(dialog).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(dialog).toHaveCount(0);
|
||||
|
||||
await page.getByRole("button", { name: "Refuse Dispatch" }).click();
|
||||
await page
|
||||
.getByRole("dialog", { name: "Refuse dispatch" })
|
||||
.getByRole("button", {
|
||||
name: "Refuse dispatch",
|
||||
})
|
||||
.click();
|
||||
await expect(page.getByText("Refused", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(/refused and locked/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows an expired-token state when the session is rejected", async ({ page }) => {
|
||||
await mockPortalApi(page, { sessionError: true });
|
||||
await page.goto(`/v/${PORTAL_TOKEN}/dashboard`);
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Access Denied" })).toBeVisible();
|
||||
await expect(page.getByRole("alert")).toContainText(/invalid or expired/i);
|
||||
});
|
||||
});
|
||||
356
e2e/vendors/vendor-uplift-workflow.spec.ts
vendored
Normal file
|
|
@ -0,0 +1,356 @@
|
|||
import { expect, test, type Route } from "@playwright/test";
|
||||
|
||||
const INTERNAL_USER = {
|
||||
token: "uplift-approver-token",
|
||||
expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||
email: "approver@seahavenind.com",
|
||||
userRoles: "Admin,Tier2Approver",
|
||||
fullname: "Uplift Approver",
|
||||
id: "1",
|
||||
};
|
||||
|
||||
async function fulfillJson(route: Route, data: unknown, status = 200) {
|
||||
await route.fulfill({
|
||||
status,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ status: status < 400 ? "Success" : "Error", data }),
|
||||
});
|
||||
}
|
||||
|
||||
test("vendor uploads scanned evidence and submits a dispatch-scoped uplift", async ({ page }) => {
|
||||
let requestBody: Record<string, unknown> | undefined;
|
||||
let uploadPurpose = "";
|
||||
let created = false;
|
||||
|
||||
await page.route("**/api/vendor-portal/**", async (route) => {
|
||||
const request = route.request();
|
||||
const path = new URL(request.url()).pathname;
|
||||
|
||||
if (path.endsWith("/session")) {
|
||||
await fulfillJson(route, { id: 9, companyName: "Gateway Plumbing" });
|
||||
return;
|
||||
}
|
||||
if (path.endsWith("/dispatches/7/documents") && request.method() === "POST") {
|
||||
uploadPurpose = request.postData() ?? "";
|
||||
await fulfillJson(route, {
|
||||
id: 22,
|
||||
originalFileName: "estimate.pdf",
|
||||
scanStatus: "Pending",
|
||||
reviewStatus: "Processing",
|
||||
purpose: "UpliftEvidence",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (path.endsWith("/dispatches/7/documents/22/status")) {
|
||||
await fulfillJson(route, {
|
||||
id: 22,
|
||||
originalFileName: "estimate.pdf",
|
||||
scanStatus: "Passed",
|
||||
reviewStatus: "Approved",
|
||||
purpose: "UpliftEvidence",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (path.endsWith("/dispatches/7/uplift-request") && request.method() === "POST") {
|
||||
requestBody = request.postDataJSON();
|
||||
created = true;
|
||||
await fulfillJson(route, {
|
||||
id: 31,
|
||||
status: "Pending",
|
||||
noApprovalRequired: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (path.endsWith("/dispatches/7")) {
|
||||
await fulfillJson(route, {
|
||||
id: 7,
|
||||
dispatchNumber: "DSP-7",
|
||||
poNumber: "PO-7",
|
||||
status: "In Progress",
|
||||
nteAmount: 100,
|
||||
checklist: [],
|
||||
comments: [],
|
||||
signoffs: [],
|
||||
documents: [],
|
||||
upliftRequests: created
|
||||
? [
|
||||
{
|
||||
id: 31,
|
||||
status: "Pending",
|
||||
currentNTE: 100,
|
||||
requestedNTE: 125,
|
||||
vendorReason: "Replacement compressor",
|
||||
evidenceDocumentId: 22,
|
||||
evidenceFileName: "estimate.pdf",
|
||||
notificationStatus: "Sent",
|
||||
},
|
||||
]
|
||||
: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
await fulfillJson(route, []);
|
||||
});
|
||||
|
||||
await page.goto("/v/portal-token/dispatch/7");
|
||||
await expect(page.getByRole("heading", { name: /DSP-7|PO-7/ })).toBeVisible();
|
||||
|
||||
await page.getByLabel("New total NTE").fill("125");
|
||||
await page.getByLabel("Reason").fill("Replacement compressor");
|
||||
await page.getByLabel("Evidence (PDF/JPG/PNG)").setInputFiles({
|
||||
name: "estimate.pdf",
|
||||
mimeType: "application/pdf",
|
||||
buffer: Buffer.from("%PDF-1.4\nmock estimate"),
|
||||
});
|
||||
await page.getByRole("button", { name: "Request uplift" }).click();
|
||||
|
||||
await expect(page.getByText("Pending", { exact: true })).toBeVisible();
|
||||
expect(uploadPurpose).toContain('name="purpose"');
|
||||
expect(uploadPurpose).toContain("UpliftEvidence");
|
||||
expect(requestBody).toMatchObject({
|
||||
requestedNTE: 125,
|
||||
reason: "Replacement compressor",
|
||||
evidenceDocumentId: 22,
|
||||
});
|
||||
expect(requestBody?.requestKey).toMatch(/^[0-9a-f-]{36}$/i);
|
||||
});
|
||||
|
||||
test("vendor can withdraw a pending uplift from the dispatch", async ({ page }) => {
|
||||
let withdrawnPath = "";
|
||||
let pending = true;
|
||||
|
||||
await page.route("**/api/vendor-portal/**", async (route) => {
|
||||
const request = route.request();
|
||||
const path = new URL(request.url()).pathname;
|
||||
if (path.endsWith("/session")) {
|
||||
await fulfillJson(route, { id: 9, companyName: "Gateway Plumbing" });
|
||||
return;
|
||||
}
|
||||
if (path.endsWith("/dispatches/7/uplift-request/31/withdraw")) {
|
||||
withdrawnPath = path;
|
||||
pending = false;
|
||||
await fulfillJson(route, { id: 31, status: "Withdrawn" });
|
||||
return;
|
||||
}
|
||||
if (path.endsWith("/dispatches/7")) {
|
||||
await fulfillJson(route, {
|
||||
id: 7,
|
||||
dispatchNumber: "DSP-7",
|
||||
status: "In Progress",
|
||||
nteAmount: 100,
|
||||
checklist: [],
|
||||
comments: [],
|
||||
signoffs: [],
|
||||
documents: [],
|
||||
upliftRequests: [
|
||||
{
|
||||
id: 31,
|
||||
status: pending ? "Pending" : "Withdrawn",
|
||||
currentNTE: 100,
|
||||
requestedNTE: 125,
|
||||
vendorReason: "Parts",
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
await fulfillJson(route, []);
|
||||
});
|
||||
|
||||
page.on("dialog", (dialog) => void dialog.accept());
|
||||
await page.goto("/v/portal-token/dispatch/7");
|
||||
await page.getByRole("button", { name: "Withdraw request" }).click();
|
||||
|
||||
await expect(page.getByText("Withdrawn", { exact: true })).toBeVisible();
|
||||
expect(withdrawnPath).toBe("/api/vendor-portal/dispatches/7/uplift-request/31/withdraw");
|
||||
});
|
||||
|
||||
test("vendor receives the no-approval result for a request within the current NTE", async ({
|
||||
page,
|
||||
}) => {
|
||||
let requestBody: Record<string, unknown> | undefined;
|
||||
await page.route("**/api/vendor-portal/**", async (route) => {
|
||||
const request = route.request();
|
||||
const path = new URL(request.url()).pathname;
|
||||
if (path.endsWith("/session")) {
|
||||
await fulfillJson(route, { id: 9, companyName: "Gateway Plumbing" });
|
||||
return;
|
||||
}
|
||||
if (path.endsWith("/dispatches/7/uplift-request") && request.method() === "POST") {
|
||||
requestBody = request.postDataJSON();
|
||||
await fulfillJson(route, { noApprovalRequired: true, status: "Approved" });
|
||||
return;
|
||||
}
|
||||
if (path.endsWith("/dispatches/7")) {
|
||||
await fulfillJson(route, {
|
||||
id: 7,
|
||||
dispatchNumber: "DSP-7",
|
||||
status: "In Progress",
|
||||
nteAmount: 100,
|
||||
checklist: [],
|
||||
comments: [],
|
||||
signoffs: [],
|
||||
documents: [],
|
||||
upliftRequests: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
await fulfillJson(route, []);
|
||||
});
|
||||
|
||||
await page.goto("/v/portal-token/dispatch/7");
|
||||
await page.getByLabel("New total NTE").fill("90");
|
||||
await expect(page.getByLabel("Reason")).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "Request uplift" }).click();
|
||||
|
||||
await expect(
|
||||
page.getByText("No approval required — the existing NTE already covers this amount."),
|
||||
).toBeVisible();
|
||||
expect(requestBody).toMatchObject({ requestedNTE: 90, reason: "" });
|
||||
expect(requestBody).not.toHaveProperty("evidenceDocumentId");
|
||||
});
|
||||
|
||||
test("vendor revises a changes-requested uplift using its existing evidence", async ({ page }) => {
|
||||
let revisionBody: Record<string, unknown> | undefined;
|
||||
let revised = false;
|
||||
await page.route("**/api/vendor-portal/**", async (route) => {
|
||||
const request = route.request();
|
||||
const path = new URL(request.url()).pathname;
|
||||
if (path.endsWith("/session")) {
|
||||
await fulfillJson(route, { id: 9, companyName: "Gateway Plumbing" });
|
||||
return;
|
||||
}
|
||||
if (path.endsWith("/dispatches/7/uplift-request/31/revise")) {
|
||||
revisionBody = request.postDataJSON();
|
||||
revised = true;
|
||||
await fulfillJson(route, { id: 31, status: "Pending" });
|
||||
return;
|
||||
}
|
||||
if (path.endsWith("/dispatches/7")) {
|
||||
await fulfillJson(route, {
|
||||
id: 7,
|
||||
dispatchNumber: "DSP-7",
|
||||
status: "In Progress",
|
||||
nteAmount: 100,
|
||||
checklist: [],
|
||||
comments: [],
|
||||
signoffs: [],
|
||||
documents: [],
|
||||
upliftRequests: [
|
||||
{
|
||||
id: 31,
|
||||
status: revised ? "Pending" : "ChangesRequested",
|
||||
currentNTE: 100,
|
||||
requestedNTE: 125,
|
||||
vendorReason: "Parts",
|
||||
decisionNote: "Itemize the estimate",
|
||||
evidenceDocumentId: 22,
|
||||
evidenceFileName: "estimate.pdf",
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
await fulfillJson(route, []);
|
||||
});
|
||||
|
||||
await page.goto("/v/portal-token/dispatch/7");
|
||||
await page.getByRole("button", { name: "Revise" }).click();
|
||||
await page.getByLabel("New total NTE").fill("120");
|
||||
await page.getByLabel("Reason").fill("Parts itemized in the attached estimate");
|
||||
await page.getByRole("button", { name: "Submit revision" }).click();
|
||||
|
||||
await expect(page.getByText("Pending", { exact: true })).toBeVisible();
|
||||
expect(revisionBody).toEqual({
|
||||
requestedNTE: 120,
|
||||
reason: "Parts itemized in the attached estimate",
|
||||
evidenceDocumentId: 22,
|
||||
});
|
||||
});
|
||||
|
||||
test("internal approver can approve and request changes with audited notes", async ({ page }) => {
|
||||
const decisions: Array<{ path: string; body: unknown }> = [];
|
||||
const statuses = new Map<number, string>([
|
||||
[41, "Pending"],
|
||||
[42, "Pending"],
|
||||
]);
|
||||
|
||||
await page.addInitScript(
|
||||
(user) => localStorage.setItem("auth", JSON.stringify(user)),
|
||||
INTERNAL_USER,
|
||||
);
|
||||
await page.route("**/api/Dashboard/Stats", (route) => fulfillJson(route, {}));
|
||||
await page.route("**/api/User*", (route) => fulfillJson(route, []));
|
||||
await page.route(/\/api\/uplifts(?:[/?]|$)/, async (route) => {
|
||||
const request = route.request();
|
||||
const path = new URL(request.url()).pathname;
|
||||
const action = path.match(/\/uplifts\/(41|42)\/(approve|request-changes)$/);
|
||||
if (action && request.method() === "POST") {
|
||||
const id = Number(action[1]);
|
||||
statuses.set(id, action[2] === "approve" ? "Approved" : "ChangesRequested");
|
||||
decisions.push({ path, body: request.postDataJSON() });
|
||||
await fulfillJson(route, { id, status: statuses.get(id) });
|
||||
return;
|
||||
}
|
||||
|
||||
await fulfillJson(route, {
|
||||
total: 2,
|
||||
items: [
|
||||
{
|
||||
id: 41,
|
||||
dispatchId: 7,
|
||||
dispatchNumber: "DSP-41",
|
||||
poNumber: "PO-41",
|
||||
vendorCompanyName: "Gateway Plumbing",
|
||||
currentNTE: 100,
|
||||
requestedNTE: 125,
|
||||
delta: 25,
|
||||
requiredTier: 1,
|
||||
status: statuses.get(41),
|
||||
canDecide: true,
|
||||
evidenceDocumentId: 22,
|
||||
evidenceFileName: "estimate.pdf",
|
||||
evidenceContentType: "application/pdf",
|
||||
evidenceSizeBytes: 2048,
|
||||
},
|
||||
{
|
||||
id: 42,
|
||||
dispatchId: 8,
|
||||
dispatchNumber: "DSP-42",
|
||||
poNumber: "PO-42",
|
||||
vendorCompanyName: "Metro HVAC",
|
||||
currentNTE: 200,
|
||||
requestedNTE: 260,
|
||||
delta: 60,
|
||||
requiredTier: 2,
|
||||
status: statuses.get(42),
|
||||
canDecide: true,
|
||||
evidenceDocumentId: 23,
|
||||
evidenceFileName: "quote.pdf",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/uplifts");
|
||||
await expect(page.getByRole("heading", { name: "Uplift Queue" })).toBeVisible();
|
||||
|
||||
const approvalRow = page.getByRole("row").filter({ hasText: "DSP-41" });
|
||||
await approvalRow.getByRole("button", { name: "Approve" }).click();
|
||||
await page.getByRole("button", { name: "Confirm" }).click();
|
||||
|
||||
const changesRow = page.getByRole("row").filter({ hasText: "DSP-42" });
|
||||
await changesRow.getByRole("button", { name: "Request Changes" }).click();
|
||||
await page.getByLabel("Note (required)").fill("Please itemize labor and parts.");
|
||||
await page.getByRole("button", { name: "Confirm" }).click();
|
||||
|
||||
await expect
|
||||
.poll(() => decisions)
|
||||
.toEqual([
|
||||
{ path: "/api/uplifts/41/approve", body: {} },
|
||||
{
|
||||
path: "/api/uplifts/42/request-changes",
|
||||
body: { note: "Please itemize labor and parts." },
|
||||
},
|
||||
]);
|
||||
});
|
||||
33
e2e/vendors/vendors.spec.ts
vendored
|
|
@ -537,6 +537,20 @@ test.describe("Vendor directory prototype parity", () => {
|
|||
await mockVendorApi(page, { listError: true });
|
||||
await page.goto("/vendors");
|
||||
|
||||
const mainBox = await page.getByRole("main").boundingBox();
|
||||
expect(mainBox).toMatchObject({ x: 0, width: 390 });
|
||||
const navigation = page.getByRole("navigation", { name: "Primary navigation" });
|
||||
await expect(navigation).not.toBeVisible();
|
||||
|
||||
const openNavigation = page.getByRole("button", { name: "Toggle navigation" });
|
||||
await openNavigation.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(navigation).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Sites" })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(navigation).not.toBeVisible();
|
||||
await expect(openNavigation).toBeFocused();
|
||||
|
||||
await expect(page.getByRole("main").getByRole("alert")).toContainText(
|
||||
/Vendor directory unavailable|failed|server error/i,
|
||||
{ timeout: 15_000 },
|
||||
|
|
@ -549,4 +563,23 @@ test.describe("Vendor directory prototype parity", () => {
|
|||
await captureVisual(page, "/tmp/seahaven-vendor-implemented-mobile.png");
|
||||
await page.getByRole("button", { name: "Close filters" }).click();
|
||||
});
|
||||
|
||||
test("keeps protected shell destinations available and marks their selected state", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockVendorApi(page);
|
||||
|
||||
for (const destination of [
|
||||
{ path: "/workorders", label: "Work Orders" },
|
||||
{ path: "/accounts", label: "Accounts" },
|
||||
{ path: "/locations", label: "Sites" },
|
||||
{ path: "/notifications", label: "Notifications" },
|
||||
]) {
|
||||
await page.goto(destination.path);
|
||||
await expect(page.getByRole("link", { name: destination.label })).toHaveAttribute(
|
||||
"aria-current",
|
||||
"page",
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
261
e2e/vendors/vendors.visual.spec.ts
vendored
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
import { expect, test, type Page, type Route } from "@playwright/test";
|
||||
|
||||
const TEST_USER = {
|
||||
token: "vendor-visual-token",
|
||||
expiration: "2030-01-01T00:00:00.000Z",
|
||||
email: "admin@seahavenind.com",
|
||||
userRoles: "Admin",
|
||||
phoneNumber: "5551234567",
|
||||
fullname: "Vendor Visual Admin",
|
||||
id: "1",
|
||||
};
|
||||
|
||||
const activeVendors = [
|
||||
{
|
||||
Id: 1,
|
||||
CompanyId: 101,
|
||||
CompanyName: "Gateway Plumbing",
|
||||
ContactName: "Adam Whyte",
|
||||
Email: "dispatch@gateway.test",
|
||||
Phone: "314-555-0198",
|
||||
CompanyPhone: "314-555-0100",
|
||||
Address: "1 Market St",
|
||||
City: "St. Louis",
|
||||
State: "MO",
|
||||
Zip: "63101",
|
||||
TradeSpecialties: "Plumbing, Backflow Preventers",
|
||||
GoogleMapsUrl: "https://maps.google.com/gateway",
|
||||
Notes: "Preferred after-hours vendor",
|
||||
TotalJobs: 72,
|
||||
IsActive: true,
|
||||
PreferredContact: "Email",
|
||||
},
|
||||
{
|
||||
Id: 2,
|
||||
CompanyId: 102,
|
||||
CompanyName: "Metro HVAC",
|
||||
ContactName: "Maya Chen",
|
||||
Email: "maya@metro.test",
|
||||
Phone: "314-555-0112",
|
||||
CompanyPhone: "314-555-0110",
|
||||
Address: "2 Pine St",
|
||||
City: "Clayton",
|
||||
State: "MO",
|
||||
Zip: "63105",
|
||||
TradeSpecialties: "HVAC",
|
||||
GoogleMapsUrl: "",
|
||||
Notes: "",
|
||||
TotalJobs: 18,
|
||||
IsActive: true,
|
||||
},
|
||||
];
|
||||
|
||||
const inactiveVendors = [
|
||||
{
|
||||
...activeVendors[0],
|
||||
Id: 3,
|
||||
CompanyId: 103,
|
||||
CompanyName: "Drain Cleaning Bros",
|
||||
ContactName: "Drew Banks",
|
||||
TotalJobs: 151,
|
||||
IsActive: false,
|
||||
},
|
||||
];
|
||||
|
||||
async function fulfillJson(route: Route, body: unknown, status = 200) {
|
||||
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
async function mockVendorApi(page: Page, mode: "default" | "empty" | "error" = "default") {
|
||||
await page.clock.setFixedTime(new Date("2026-08-10T12:00:00.000Z"));
|
||||
await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER);
|
||||
|
||||
await page.route("**/api/**", async (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
|
||||
if (!url.pathname.startsWith("/api/")) {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname.includes("/DropdownOptions/ByCategory/Trade")) {
|
||||
await fulfillJson(route, {
|
||||
data: [
|
||||
{ id: 1, value: "Plumbing" },
|
||||
{ id: 2, value: "HVAC" },
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname.endsWith("/api/Vendor/facets")) {
|
||||
await fulfillJson(route, {
|
||||
data: {
|
||||
companies: activeVendors.map((vendor) => ({
|
||||
companyId: vendor.CompanyId,
|
||||
name: vendor.CompanyName,
|
||||
})),
|
||||
trades: ["Plumbing", "Backflow Preventers", "HVAC"],
|
||||
locations: [
|
||||
{ city: "St. Louis", state: "MO", label: "St. Louis, MO" },
|
||||
{ city: "Clayton", state: "MO", label: "Clayton, MO" },
|
||||
],
|
||||
jobBuckets: [
|
||||
{ id: "under-50", label: "Under 50" },
|
||||
{ id: "50-99", label: "50–99" },
|
||||
{ id: "100-149", label: "100–149" },
|
||||
{ id: "150-plus", label: "150+" },
|
||||
],
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname.endsWith("/api/Vendor/GetVendorList")) {
|
||||
const isCount = url.searchParams.get("pageSize") === "1";
|
||||
if (mode === "error" && !isCount) {
|
||||
await fulfillJson(route, { message: "Vendor directory unavailable" }, 500);
|
||||
return;
|
||||
}
|
||||
const source =
|
||||
mode === "empty"
|
||||
? []
|
||||
: url.searchParams.get("isActive") === "false"
|
||||
? inactiveVendors
|
||||
: activeVendors;
|
||||
await fulfillJson(route, {
|
||||
Data: isCount ? source.slice(0, 1) : source,
|
||||
TotalCount: source.length,
|
||||
PageNumber: 1,
|
||||
PageSize: Number(url.searchParams.get("pageSize") ?? 12),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith("/api/vendor-company-roster")) {
|
||||
await fulfillJson(route, {
|
||||
companyId: 101,
|
||||
rowVersion: "rv-visual",
|
||||
name: "Gateway Plumbing",
|
||||
companyPhone: "314-555-0100",
|
||||
email: "dispatch@gateway.test",
|
||||
address: "1 Market St",
|
||||
city: "St. Louis",
|
||||
state: "MO",
|
||||
zip: "63101",
|
||||
googleMapsUrl: "https://maps.google.com/gateway",
|
||||
notes: "Preferred after-hours vendor",
|
||||
technicians: [
|
||||
{
|
||||
id: 1,
|
||||
contactName: "Adam Whyte",
|
||||
phone: "314-555-0198",
|
||||
email: "dispatch@gateway.test",
|
||||
preferredContact: "Email",
|
||||
tradeSpecialties: "Plumbing, Backflow Preventers",
|
||||
isActive: true,
|
||||
totalJobs: 72,
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await fulfillJson(route, { data: {} });
|
||||
});
|
||||
}
|
||||
|
||||
async function openVendorPage(page: Page, mode: "default" | "empty" | "error" = "default") {
|
||||
await mockVendorApi(page, mode);
|
||||
await page.goto("/vendors");
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
await page.addStyleTag({
|
||||
content: `
|
||||
*, *::before, *::after { transition: none !important; animation: none !important; }
|
||||
.MuiDrawer-paper { transform: none !important; }
|
||||
.Toastify__toast-container { display: none !important; }
|
||||
.tsqd-parent-container { display: none !important; }
|
||||
`,
|
||||
});
|
||||
}
|
||||
|
||||
async function expectStableScreenshot(page: Page, name: string) {
|
||||
await page.waitForTimeout(250);
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
||||
),
|
||||
);
|
||||
await page.screenshot({ animations: "disabled", caret: "hide" });
|
||||
await expect(page).toHaveScreenshot(name);
|
||||
}
|
||||
|
||||
test.describe("Vendor deterministic pixel regression", () => {
|
||||
test("list", async ({ page }) => {
|
||||
await openVendorPage(page);
|
||||
await expect(page.getByText("Gateway Plumbing")).toBeVisible();
|
||||
await expectStableScreenshot(page, "vendor-list.png");
|
||||
});
|
||||
|
||||
test("filter", async ({ page }) => {
|
||||
await openVendorPage(page);
|
||||
await page.getByRole("button", { name: "Open vendor filters" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Filters" })).toBeVisible();
|
||||
await expectStableScreenshot(page, "vendor-filter.png");
|
||||
});
|
||||
|
||||
test("add", async ({ page }) => {
|
||||
await openVendorPage(page);
|
||||
await page.getByRole("button", { name: "Add Vendor", exact: true }).click();
|
||||
await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toBeVisible();
|
||||
await expectStableScreenshot(page, "vendor-add.png");
|
||||
});
|
||||
|
||||
test("detail", async ({ page }) => {
|
||||
await openVendorPage(page);
|
||||
await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click();
|
||||
await expect(page.getByRole("button", { name: "Close drawer" })).toBeVisible();
|
||||
await expectStableScreenshot(page, "vendor-detail.png");
|
||||
});
|
||||
|
||||
test("edit", async ({ page }) => {
|
||||
await openVendorPage(page);
|
||||
await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click();
|
||||
await expect(page.getByRole("button", { name: "Save changes" })).toBeVisible();
|
||||
await expectStableScreenshot(page, "vendor-edit.png");
|
||||
});
|
||||
|
||||
test("inactive", async ({ page }) => {
|
||||
await openVendorPage(page);
|
||||
await page.getByRole("tab", { name: "Inactive (1)" }).click();
|
||||
await expect(page.getByText("Drain Cleaning Bros")).toBeVisible();
|
||||
await expectStableScreenshot(page, "vendor-inactive.png");
|
||||
});
|
||||
|
||||
test("empty", async ({ page }) => {
|
||||
await openVendorPage(page, "empty");
|
||||
await expect(page.getByText("No active technicians found")).toBeVisible();
|
||||
await expectStableScreenshot(page, "vendor-empty.png");
|
||||
});
|
||||
|
||||
test("error", async ({ page }) => {
|
||||
await openVendorPage(page, "error");
|
||||
await expect(page.getByRole("main").getByRole("alert")).toContainText(
|
||||
/server error|vendor directory unavailable/i,
|
||||
);
|
||||
await expectStableScreenshot(page, "vendor-error.png");
|
||||
});
|
||||
|
||||
test("mobile", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openVendorPage(page);
|
||||
await expect(page.getByText("Gateway Plumbing")).toBeVisible();
|
||||
await expectStableScreenshot(page, "vendor-mobile.png");
|
||||
|
||||
await page.getByRole("button", { name: "Toggle navigation" }).click();
|
||||
await expect(page.getByRole("navigation", { name: "Primary navigation" })).toBeVisible();
|
||||
await expectStableScreenshot(page, "vendor-mobile-navigation.png");
|
||||
});
|
||||
});
|
||||
26
package-lock.json
generated
|
|
@ -4087,9 +4087,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.4",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz",
|
||||
"integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
|
||||
"integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -4617,9 +4617,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
|
||||
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
|
||||
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -5341,9 +5341,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.16",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
||||
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
||||
"version": "3.3.18",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
|
|
@ -5652,9 +5652,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.18",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.18.tgz",
|
||||
"integrity": "sha512-xdB1oSLHbz1vRWgCDalrCqEFTWzFlhqFC5tIHLMOSUIjhm3XXQ1qrFy8S/ESr1JYRRXqM3c1QFiMZUJdUTqyMQ==",
|
||||
"version": "8.5.26",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
|
||||
"integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
|
|
@ -5671,7 +5671,7 @@
|
|||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.12",
|
||||
"nanoid": "^3.3.17",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:visual": "playwright test --config playwright.visual.config.ts",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"lint": "eslint . --max-warnings=0",
|
||||
"lint:fix": "eslint . --fix --max-warnings=0",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { defineConfig, devices } from "@playwright/test";
|
|||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
testIgnore: "**/*.visual.spec.ts",
|
||||
fullyParallel: true,
|
||||
forbidOnly: Boolean(process.env.CI),
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
|
|
|
|||
43
playwright.visual.config.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
testMatch: "**/*.visual.spec.ts",
|
||||
fullyParallel: false,
|
||||
forbidOnly: true,
|
||||
retries: 0,
|
||||
workers: 1,
|
||||
reporter: [["list"], ["html", { outputFolder: "playwright-report-visual", open: "never" }]],
|
||||
outputDir: "test-results/visual",
|
||||
snapshotPathTemplate: "{testDir}/__screenshots__/{testFilePath}/{arg}{ext}",
|
||||
expect: {
|
||||
toHaveScreenshot: {
|
||||
animations: "disabled",
|
||||
caret: "hide",
|
||||
// Keep the changed-pixel budget strict while ignoring sub-perceptual
|
||||
// antialiasing differences between local and hosted container runtimes.
|
||||
maxDiffPixels: 5,
|
||||
threshold: 0.1,
|
||||
},
|
||||
},
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
baseURL: "http://localhost:4174",
|
||||
colorScheme: "light",
|
||||
deviceScaleFactor: 1,
|
||||
locale: "en-US",
|
||||
launchOptions: {
|
||||
args: ["--disable-gpu", "--font-render-hinting=none"],
|
||||
},
|
||||
reducedMotion: "reduce",
|
||||
timezoneId: "UTC",
|
||||
trace: "retain-on-failure",
|
||||
viewport: { width: 1440, height: 1000 },
|
||||
},
|
||||
webServer: {
|
||||
command: "npm run dev -- --port 4174 --strictPort",
|
||||
url: "http://localhost:4174",
|
||||
reuseExistingServer: false,
|
||||
timeout: 120_000,
|
||||
},
|
||||
});
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState } from "react";
|
||||
import { Outlet } from "react-router";
|
||||
import { Box } from "@mui/material";
|
||||
import { Box, useMediaQuery, useTheme } from "@mui/material";
|
||||
|
||||
import { ProtectedRoute } from "@/components/auth/protected-route";
|
||||
import { AppSidebar } from "@/components/layout/app-sidebar";
|
||||
|
|
@ -8,19 +8,36 @@ import { getSidebarWidth } from "@/config/menu";
|
|||
import { AppTopbar } from "@/components/layout/app-topbar";
|
||||
|
||||
export default function AdminShellLayout() {
|
||||
const theme = useTheme();
|
||||
const isDesktop = useMediaQuery(theme.breakpoints.up("md"));
|
||||
const [navCollapsed, setNavCollapsed] = useState(false);
|
||||
const sidebarWidth = getSidebarWidth(navCollapsed);
|
||||
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||||
const sidebarWidth = isDesktop ? getSidebarWidth(navCollapsed) : 0;
|
||||
|
||||
const handleToggleNav = () => {
|
||||
if (isDesktop) {
|
||||
setNavCollapsed((value) => !value);
|
||||
return;
|
||||
}
|
||||
|
||||
setMobileNavOpen((value) => !value);
|
||||
};
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<Box className="flex h-screen w-full overflow-hidden bg-background">
|
||||
<AppSidebar collapsed={navCollapsed} />
|
||||
<AppSidebar
|
||||
collapsed={isDesktop ? navCollapsed : false}
|
||||
mobile={!isDesktop}
|
||||
open={isDesktop || mobileNavOpen}
|
||||
onClose={() => setMobileNavOpen(false)}
|
||||
/>
|
||||
|
||||
<Box
|
||||
className="flex h-screen min-w-0 flex-1 flex-col overflow-hidden"
|
||||
sx={{ width: `calc(100% - ${sidebarWidth}px)` }}
|
||||
>
|
||||
<AppTopbar onToggleNav={() => setNavCollapsed((value) => !value)} />
|
||||
<AppTopbar onToggleNav={handleToggleNav} />
|
||||
|
||||
<Box
|
||||
component="main"
|
||||
|
|
|
|||
|
|
@ -27,28 +27,319 @@ import {
|
|||
Tooltip,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { upliftsApi } from "@/domain/uplifts/api/uplifts-api";
|
||||
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
|
||||
import { useApproveUplift, useDenyUplift } from "@/domain/uplifts/use-cases/use-uplift-actions";
|
||||
import { UPLIFT_STATUSES } from "@/domain/uplifts/types/uplift";
|
||||
import {
|
||||
useApproveUplift,
|
||||
useRejectUplift,
|
||||
useRequestChangesUplift,
|
||||
} from "@/domain/uplifts/use-cases/use-uplift-actions";
|
||||
import { useUpliftsQueue } from "@/domain/uplifts/use-cases/use-uplifts-queue";
|
||||
import { formatDateTime } from "@/lib/time-utils";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
const STATUS_OPTIONS = ["Pending", "Approved", "Denied", "Cancelled", ""];
|
||||
const STATUS_OPTIONS = ["", ...UPLIFT_STATUSES];
|
||||
|
||||
type UpliftActionType = "approve" | "reject" | "request-changes";
|
||||
|
||||
type UpliftDecision = { type: UpliftActionType; item: UpliftQueueItem };
|
||||
|
||||
function formatMoney(value: number | null | undefined): string {
|
||||
return `$${Number(value ?? 0).toFixed(2)}`;
|
||||
}
|
||||
|
||||
function formatBytes(value: number | null | undefined): string {
|
||||
if (value == null) return "";
|
||||
if (value < 1024) return `${value} B`;
|
||||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(0)} KB`;
|
||||
return `${(value / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function UpliftQueueHeader({
|
||||
totalCount,
|
||||
isFetching,
|
||||
isLoading,
|
||||
}: {
|
||||
totalCount: number;
|
||||
isFetching: boolean;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Stack direction="row" spacing={2} sx={{ alignItems: "baseline" }}>
|
||||
<Typography variant="h5" component="h1">
|
||||
Uplift Queue
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
{totalCount} total
|
||||
</Typography>
|
||||
{isFetching && !isLoading && <CircularProgress size={16} />}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function UpliftQueueFilters({
|
||||
status,
|
||||
tier,
|
||||
onStatusChange,
|
||||
onTierChange,
|
||||
}: {
|
||||
status: string;
|
||||
tier: string;
|
||||
onStatusChange: (value: string) => void;
|
||||
onTierChange: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<Stack direction="row" spacing={2}>
|
||||
<FormControl size="small" className="min-w-[140px]">
|
||||
<InputLabel>Status</InputLabel>
|
||||
<Select label="Status" value={status} onChange={(e) => onStatusChange(e.target.value)}>
|
||||
{STATUS_OPTIONS.map((s) => (
|
||||
<MenuItem key={s || "all"} value={s}>
|
||||
{s || "All"}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl size="small" className="min-w-[120px]">
|
||||
<InputLabel>Tier</InputLabel>
|
||||
<Select label="Tier" value={tier} onChange={(e) => onTierChange(e.target.value)}>
|
||||
<MenuItem value="">All</MenuItem>
|
||||
<MenuItem value="1">Tier 1</MenuItem>
|
||||
<MenuItem value="2">Tier 2</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function UpliftQueueRow({
|
||||
row,
|
||||
onViewWorkOrder,
|
||||
onDownloadEvidence,
|
||||
onDecide,
|
||||
}: {
|
||||
row: UpliftQueueItem;
|
||||
onViewWorkOrder: (row: UpliftQueueItem) => void;
|
||||
onDownloadEvidence: (row: UpliftQueueItem) => void;
|
||||
onDecide: (type: UpliftActionType, row: UpliftQueueItem) => void;
|
||||
}) {
|
||||
const decisionable = row.status === "Pending";
|
||||
return (
|
||||
<TableRow hover>
|
||||
<TableCell>{row.dispatchNumber}</TableCell>
|
||||
<TableCell>{row.poNumber}</TableCell>
|
||||
<TableCell>{row.vendorCompanyName}</TableCell>
|
||||
<TableCell>{formatMoney(row.currentNTE)}</TableCell>
|
||||
<TableCell>{formatMoney(row.requestedNTE)}</TableCell>
|
||||
<TableCell>
|
||||
<Chip size="small" label={`Tier ${row.requiredTier}`} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip size="small" label={row.status} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{row.evidenceDocumentId != null ? (
|
||||
<Stack direction="column" spacing={0.5}>
|
||||
<Typography variant="caption">{row.evidenceFileName}</Typography>
|
||||
{Boolean(row.evidenceContentType) && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{row.evidenceContentType} · {formatBytes(row.evidenceSizeBytes)}
|
||||
</Typography>
|
||||
)}
|
||||
<Button size="small" variant="text" onClick={() => void onDownloadEvidence(row)}>
|
||||
Download
|
||||
</Button>
|
||||
</Stack>
|
||||
) : (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
—
|
||||
</Typography>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{row.requestedAt ? formatDateTime(row.requestedAt) : ""}</TableCell>
|
||||
<TableCell>
|
||||
<Stack direction="row" spacing={1}>
|
||||
{Boolean(row.workOrderId) && (
|
||||
<Button size="small" variant="outlined" onClick={() => onViewWorkOrder(row)}>
|
||||
View
|
||||
</Button>
|
||||
)}
|
||||
{decisionable && (
|
||||
<>
|
||||
<Tooltip title={!row.canDecide ? `Requires Tier ${row.requiredTier} role` : ""}>
|
||||
<span>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
color="success"
|
||||
disabled={!row.canDecide}
|
||||
onClick={() => onDecide("approve", row)}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title={!row.canDecide ? `Requires Tier ${row.requiredTier} role` : ""}>
|
||||
<span>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
color="error"
|
||||
disabled={!row.canDecide}
|
||||
onClick={() => onDecide("reject", row)}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title={!row.canDecide ? `Requires Tier ${row.requiredTier} role` : ""}>
|
||||
<span>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
disabled={!row.canDecide}
|
||||
onClick={() => onDecide("request-changes", row)}
|
||||
>
|
||||
Request Changes
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
function UpliftQueueTable({
|
||||
isLoading,
|
||||
rows,
|
||||
onViewWorkOrder,
|
||||
onDownloadEvidence,
|
||||
onDecide,
|
||||
}: {
|
||||
isLoading: boolean;
|
||||
rows: UpliftQueueItem[];
|
||||
onViewWorkOrder: (row: UpliftQueueItem) => void;
|
||||
onDownloadEvidence: (row: UpliftQueueItem) => void;
|
||||
onDecide: (type: UpliftActionType, row: UpliftQueueItem) => void;
|
||||
}) {
|
||||
return (
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell className="font-semibold">Dispatch</TableCell>
|
||||
<TableCell className="font-semibold">PO</TableCell>
|
||||
<TableCell className="font-semibold">Vendor</TableCell>
|
||||
<TableCell className="font-semibold">Current NTE</TableCell>
|
||||
<TableCell className="font-semibold">Requested</TableCell>
|
||||
<TableCell className="font-semibold">Tier</TableCell>
|
||||
<TableCell className="font-semibold">Status</TableCell>
|
||||
<TableCell className="font-semibold">Evidence</TableCell>
|
||||
<TableCell className="font-semibold">Requested At</TableCell>
|
||||
<TableCell className="font-semibold">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={10} align="center" className="py-10">
|
||||
<CircularProgress size={28} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={10} align="center" className="py-10">
|
||||
<Typography sx={{ color: "text.secondary" }}>
|
||||
No uplift requests match these filters.
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
<UpliftQueueRow
|
||||
key={String(row.id)}
|
||||
row={row}
|
||||
onViewWorkOrder={onViewWorkOrder}
|
||||
onDownloadEvidence={onDownloadEvidence}
|
||||
onDecide={onDecide}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function UpliftDecisionDialog({
|
||||
action,
|
||||
note,
|
||||
noteRequired,
|
||||
decisionPending,
|
||||
onNoteChange,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: {
|
||||
action: UpliftDecision | null;
|
||||
note: string;
|
||||
noteRequired: boolean;
|
||||
decisionPending: boolean;
|
||||
onNoteChange: (value: string) => void;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={Boolean(action)} onClose={onClose}>
|
||||
<DialogTitle>
|
||||
{action?.type === "approve"
|
||||
? `Approve uplift to ${formatMoney(action.item.requestedNTE)}?`
|
||||
: action?.type === "reject"
|
||||
? "Reject Uplift"
|
||||
: "Request Changes"}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
{action?.type === "approve" && (
|
||||
<Typography variant="body2" className="mt-1 mb-3">
|
||||
{formatMoney(action.item.currentNTE)} → {formatMoney(action.item.requestedNTE)}
|
||||
</Typography>
|
||||
)}
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={2}
|
||||
className="mt-2"
|
||||
label={noteRequired ? "Note (required)" : "Optional note"}
|
||||
value={note}
|
||||
required={noteRequired}
|
||||
onChange={(e) => onNoteChange(e.target.value)}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color={action?.type === "approve" ? "success" : "primary"}
|
||||
disabled={noteRequired ? !note.trim() || decisionPending : decisionPending}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default function UpliftQueuePage() {
|
||||
const navigate = useNavigate();
|
||||
const [status, setStatus] = useState("Pending");
|
||||
const [tier, setTier] = useState("");
|
||||
const [page, setPage] = useState(0);
|
||||
const [pageSize] = useState(25);
|
||||
|
||||
const [upliftAction, setUpliftAction] = useState<{
|
||||
type: "approve" | "deny";
|
||||
item: UpliftQueueItem;
|
||||
} | null>(null);
|
||||
const [upliftAction, setUpliftAction] = useState<UpliftDecision | null>(null);
|
||||
const [note, setNote] = useState("");
|
||||
|
||||
const listParams = useMemo(
|
||||
|
|
@ -58,39 +349,59 @@ export default function UpliftQueuePage() {
|
|||
|
||||
const { data, isLoading, isFetching, error, refetch } = useUpliftsQueue(listParams);
|
||||
const approveUplift = useApproveUplift();
|
||||
const denyUplift = useDenyUplift();
|
||||
const rejectUplift = useRejectUplift();
|
||||
const requestChangesUplift = useRequestChangesUplift();
|
||||
|
||||
const rows = data?.items ?? [];
|
||||
const totalCount = data?.totalCount ?? 0;
|
||||
const noteRequired = upliftAction?.type === "reject" || upliftAction?.type === "request-changes";
|
||||
const decisionPending =
|
||||
approveUplift.isPending || rejectUplift.isPending || requestChangesUplift.isPending;
|
||||
|
||||
const handleStatusChange = (value: string) => {
|
||||
setStatus(value);
|
||||
setPage(0);
|
||||
};
|
||||
const handleTierChange = (value: string) => {
|
||||
setTier(value);
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
setUpliftAction(null);
|
||||
setNote("");
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!upliftAction) return;
|
||||
if (noteRequired && !note.trim()) return;
|
||||
const id = upliftAction.item.id;
|
||||
const onDone = () => {
|
||||
closeDialog();
|
||||
void refetch();
|
||||
};
|
||||
if (upliftAction.type === "approve") {
|
||||
approveUplift.mutate(
|
||||
{ id: upliftAction.item.id, note: note || undefined },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setUpliftAction(null);
|
||||
setNote("");
|
||||
void refetch();
|
||||
},
|
||||
},
|
||||
);
|
||||
approveUplift.mutate({ id, note: note || undefined }, { onSuccess: onDone });
|
||||
} else if (upliftAction.type === "reject") {
|
||||
rejectUplift.mutate({ id, note: note.trim() }, { onSuccess: onDone });
|
||||
} else {
|
||||
if (!note.trim()) return;
|
||||
denyUplift.mutate(
|
||||
{ id: upliftAction.item.id, note: note.trim() },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setUpliftAction(null);
|
||||
setNote("");
|
||||
void refetch();
|
||||
},
|
||||
},
|
||||
);
|
||||
requestChangesUplift.mutate({ id, note: note.trim() }, { onSuccess: onDone });
|
||||
}
|
||||
};
|
||||
|
||||
const downloadEvidence = async (item: UpliftQueueItem) => {
|
||||
try {
|
||||
await upliftsApi.downloadEvidence(item.id, item.evidenceFileName || "uplift-evidence");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Unable to download evidence.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDecide = (type: UpliftActionType, item: UpliftQueueItem) => {
|
||||
setUpliftAction({ type, item });
|
||||
setNote("");
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="flex flex-col gap-4 p-4">
|
||||
<Stack
|
||||
|
|
@ -101,177 +412,26 @@ export default function UpliftQueuePage() {
|
|||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={2}
|
||||
sx={{
|
||||
alignItems: "baseline",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h5" component="h1">
|
||||
Uplift Queue
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
{totalCount} total
|
||||
</Typography>
|
||||
{isFetching && !isLoading && <CircularProgress size={16} />}
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" spacing={2}>
|
||||
<FormControl size="small" className="min-w-[140px]">
|
||||
<InputLabel>Status</InputLabel>
|
||||
<Select
|
||||
label="Status"
|
||||
value={status}
|
||||
onChange={(e) => {
|
||||
setStatus(e.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
>
|
||||
{STATUS_OPTIONS.map((s) => (
|
||||
<MenuItem key={s || "all"} value={s}>
|
||||
{s || "All"}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl size="small" className="min-w-[120px]">
|
||||
<InputLabel>Tier</InputLabel>
|
||||
<Select
|
||||
label="Tier"
|
||||
value={tier}
|
||||
onChange={(e) => {
|
||||
setTier(e.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
>
|
||||
<MenuItem value="">All</MenuItem>
|
||||
<MenuItem value="1">Tier 1</MenuItem>
|
||||
<MenuItem value="2">Tier 2</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
<UpliftQueueHeader totalCount={totalCount} isFetching={isFetching} isLoading={isLoading} />
|
||||
<UpliftQueueFilters
|
||||
status={status}
|
||||
tier={tier}
|
||||
onStatusChange={handleStatusChange}
|
||||
onTierChange={handleTierChange}
|
||||
/>
|
||||
</Stack>
|
||||
{Boolean(error) && (
|
||||
<Alert severity="error">
|
||||
{error instanceof Error ? error.message : "Failed to load uplift queue"}
|
||||
</Alert>
|
||||
)}
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell className="font-semibold">Dispatch</TableCell>
|
||||
<TableCell className="font-semibold">PO</TableCell>
|
||||
<TableCell className="font-semibold">Vendor</TableCell>
|
||||
<TableCell className="font-semibold">Current NTE</TableCell>
|
||||
<TableCell className="font-semibold">Requested</TableCell>
|
||||
<TableCell className="font-semibold">Delta</TableCell>
|
||||
<TableCell className="font-semibold">Tier</TableCell>
|
||||
<TableCell className="font-semibold">Status</TableCell>
|
||||
<TableCell className="font-semibold">Requested At</TableCell>
|
||||
<TableCell className="font-semibold">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={10} align="center" className="py-10">
|
||||
<CircularProgress size={28} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={10} align="center" className="py-10">
|
||||
<Typography
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
No uplift requests match these filters.
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
<TableRow key={String(row.id)} hover>
|
||||
<TableCell>{row.dispatchNumber}</TableCell>
|
||||
<TableCell>{row.poNumber}</TableCell>
|
||||
<TableCell>{row.vendorCompanyName}</TableCell>
|
||||
<TableCell>{formatMoney(row.currentNTE)}</TableCell>
|
||||
<TableCell>{formatMoney(row.requestedNTE)}</TableCell>
|
||||
<TableCell>{formatMoney(row.delta)}</TableCell>
|
||||
<TableCell>
|
||||
<Chip size="small" label={`Tier ${row.requiredTier}`} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip size="small" label={row.status} />
|
||||
</TableCell>
|
||||
<TableCell>{row.requestedAt ? formatDateTime(row.requestedAt) : ""}</TableCell>
|
||||
<TableCell>
|
||||
<Stack direction="row" spacing={1}>
|
||||
{Boolean(row.workOrderId) && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={() => navigate(`/workorders/${row.workOrderId}`)}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
)}
|
||||
{row.status === "Pending" && (
|
||||
<>
|
||||
<Tooltip
|
||||
title={!row.canDecide ? `Requires Tier ${row.requiredTier} role` : ""}
|
||||
>
|
||||
<span>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
color="success"
|
||||
disabled={!row.canDecide}
|
||||
onClick={() => {
|
||||
setUpliftAction({ type: "approve", item: row });
|
||||
setNote("");
|
||||
}}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
title={!row.canDecide ? `Requires Tier ${row.requiredTier} role` : ""}
|
||||
>
|
||||
<span>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
color="error"
|
||||
disabled={!row.canDecide}
|
||||
onClick={() => {
|
||||
setUpliftAction({ type: "deny", item: row });
|
||||
setNote("");
|
||||
}}
|
||||
>
|
||||
Deny
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<UpliftQueueTable
|
||||
isLoading={isLoading}
|
||||
rows={rows}
|
||||
onViewWorkOrder={(r) => navigate(`/workorders/${r.workOrderId}`)}
|
||||
onDownloadEvidence={(r) => void downloadEvidence(r)}
|
||||
onDecide={handleDecide}
|
||||
/>
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={totalCount}
|
||||
|
|
@ -280,46 +440,15 @@ export default function UpliftQueuePage() {
|
|||
rowsPerPage={pageSize}
|
||||
rowsPerPageOptions={[25]}
|
||||
/>
|
||||
<Dialog open={Boolean(upliftAction)} onClose={() => setUpliftAction(null)}>
|
||||
<DialogTitle>
|
||||
{upliftAction?.type === "approve"
|
||||
? `Approve uplift to ${formatMoney(upliftAction.item.requestedNTE)}?`
|
||||
: "Deny Uplift"}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
{upliftAction?.type === "approve" && (
|
||||
<Typography variant="body2" className="mt-1 mb-3">
|
||||
{formatMoney(upliftAction.item.currentNTE)} →{" "}
|
||||
{formatMoney(upliftAction.item.requestedNTE)} (+
|
||||
{formatMoney(upliftAction.item.delta)})
|
||||
</Typography>
|
||||
)}
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={2}
|
||||
className="mt-2"
|
||||
label={upliftAction?.type === "deny" ? "Denial reason (required)" : "Optional note"}
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setUpliftAction(null)}>Cancel</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color={upliftAction?.type === "approve" ? "success" : "error"}
|
||||
disabled={
|
||||
upliftAction?.type === "deny"
|
||||
? !note.trim() || denyUplift.isPending
|
||||
: approveUplift.isPending
|
||||
}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
<UpliftDecisionDialog
|
||||
action={upliftAction}
|
||||
note={note}
|
||||
noteRequired={noteRequired}
|
||||
decisionPending={decisionPending}
|
||||
onNoteChange={setNote}
|
||||
onClose={closeDialog}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,90 +1,82 @@
|
|||
import { useState, type ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Paper,
|
||||
Stack,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { useApproveUplift, useDenyUplift } from "@/domain/uplifts/use-cases/use-uplift-actions";
|
||||
import { useVendorPoDetail } from "@/domain/vendor-pos/use-cases/use-vendor-po-detail";
|
||||
import { Alert, Box, Button, Chip, CircularProgress, Stack, Typography } from "@mui/material";
|
||||
import { toast } from "react-toastify";
|
||||
import { upliftsApi } from "@/domain/uplifts/api/uplifts-api";
|
||||
import type { UpliftRequest } from "@/domain/uplifts/types/uplift";
|
||||
import { formatDateTime, toDateInputValue } from "@/lib/time-utils";
|
||||
import {
|
||||
useApproveUplift,
|
||||
useRejectUplift,
|
||||
useRequestChangesUplift,
|
||||
} from "@/domain/uplifts/use-cases/use-uplift-actions";
|
||||
import { useVendorPoDetail } from "@/domain/vendor-pos/use-cases/use-vendor-po-detail";
|
||||
import {
|
||||
PoDetailsPaper,
|
||||
SitePaper,
|
||||
UpliftDecisionDialog,
|
||||
UpliftHistoryPaper,
|
||||
VendorSummaryPaper,
|
||||
WorkOrdersPaper,
|
||||
type UpliftActionType,
|
||||
type VendorPoUpliftDecision,
|
||||
} from "@/app/(protected)/vendor-pos/_components/vendor-po-sections";
|
||||
|
||||
function formatMoney(value: number | null | undefined): string {
|
||||
if (value == null) return "—";
|
||||
return `$${Number(value).toFixed(2)}`;
|
||||
}
|
||||
|
||||
function InfoRow({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{children == null || children === "" ? "—" : children}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VendorPoViewPage() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { data: dispatch, isLoading, error, refetch } = useVendorPoDetail(id);
|
||||
|
||||
const approveUplift = useApproveUplift(id);
|
||||
const denyUplift = useDenyUplift(id);
|
||||
const rejectUplift = useRejectUplift(id);
|
||||
const requestChangesUplift = useRequestChangesUplift(id);
|
||||
|
||||
const [upliftAction, setUpliftAction] = useState<{
|
||||
type: "approve" | "deny";
|
||||
uplift: UpliftRequest;
|
||||
} | null>(null);
|
||||
const [upliftAction, setUpliftAction] = useState<VendorPoUpliftDecision | null>(null);
|
||||
const [note, setNote] = useState("");
|
||||
|
||||
const noteRequired = upliftAction?.type === "reject" || upliftAction?.type === "request-changes";
|
||||
const decisionPending =
|
||||
approveUplift.isPending || rejectUplift.isPending || requestChangesUplift.isPending;
|
||||
|
||||
const closeUpliftDialog = () => {
|
||||
setUpliftAction(null);
|
||||
setNote("");
|
||||
};
|
||||
|
||||
const handleUpliftConfirm = () => {
|
||||
if (!upliftAction) return;
|
||||
if (noteRequired && !note.trim()) return;
|
||||
const upliftId = upliftAction.uplift.id;
|
||||
const onDone = () => {
|
||||
closeUpliftDialog();
|
||||
void refetch();
|
||||
};
|
||||
if (upliftAction.type === "approve") {
|
||||
approveUplift.mutate(
|
||||
{ id: upliftAction.uplift.id, note: note || undefined },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setUpliftAction(null);
|
||||
setNote("");
|
||||
void refetch();
|
||||
},
|
||||
},
|
||||
);
|
||||
approveUplift.mutate({ id: upliftId, note: note || undefined }, { onSuccess: onDone });
|
||||
} else if (upliftAction.type === "reject") {
|
||||
rejectUplift.mutate({ id: upliftId, note: note.trim() }, { onSuccess: onDone });
|
||||
} else {
|
||||
if (!note.trim()) return;
|
||||
denyUplift.mutate(
|
||||
{ id: upliftAction.uplift.id, note: note.trim() },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setUpliftAction(null);
|
||||
setNote("");
|
||||
void refetch();
|
||||
},
|
||||
},
|
||||
);
|
||||
requestChangesUplift.mutate({ id: upliftId, note: note.trim() }, { onSuccess: onDone });
|
||||
}
|
||||
};
|
||||
|
||||
const downloadEvidence = async (uplift: UpliftRequest) => {
|
||||
try {
|
||||
await upliftsApi.downloadEvidence(uplift.id, uplift.evidenceFileName || "uplift-evidence");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Unable to download evidence.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDecide = (type: UpliftActionType, uplift: UpliftRequest) => {
|
||||
setUpliftAction({ type, uplift });
|
||||
setNote("");
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box className="flex justify-center p-10">
|
||||
|
|
@ -141,212 +133,29 @@ export default function VendorPoViewPage() {
|
|||
</Stack>
|
||||
|
||||
<Stack spacing={3}>
|
||||
<Paper variant="outlined" className="p-4">
|
||||
<Typography variant="subtitle1" className="mb-3 font-semibold">
|
||||
Vendor
|
||||
</Typography>
|
||||
{vendor ? (
|
||||
<Box className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3">
|
||||
<InfoRow label="Company">{vendor.companyName}</InfoRow>
|
||||
<InfoRow label="Contact">{vendor.contactName}</InfoRow>
|
||||
<InfoRow label="Email">{vendor.email}</InfoRow>
|
||||
<InfoRow label="Phone">{vendor.phone}</InfoRow>
|
||||
<InfoRow label="Address">
|
||||
{[vendor.address, vendor.city, vendor.state, vendor.zip].filter(Boolean).join(", ")}
|
||||
</InfoRow>
|
||||
<InfoRow label="Trades">{vendor.tradeSpecialties}</InfoRow>
|
||||
</Box>
|
||||
) : (
|
||||
<Typography color="text.secondary">No vendor attached.</Typography>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<Paper variant="outlined" className="p-4">
|
||||
<Typography variant="subtitle1" className="mb-3 font-semibold">
|
||||
PO Details
|
||||
</Typography>
|
||||
<Box className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3">
|
||||
<InfoRow label="PO #">{dispatch.poNumber}</InfoRow>
|
||||
<InfoRow label="Dispatch #">{dispatch.dispatchNumber}</InfoRow>
|
||||
<InfoRow label="Status">{dispatch.status}</InfoRow>
|
||||
<InfoRow label="NTE">{formatMoney(dispatch.nteAmount)}</InfoRow>
|
||||
<InfoRow label="Dispatched">{formatDateTime(dispatch.dispatchedAt)}</InfoRow>
|
||||
<InfoRow label="Acknowledged">{formatDateTime(dispatch.acknowledgedAt)}</InfoRow>
|
||||
<InfoRow label="Scheduled">{toDateInputValue(dispatch.scheduledDate) || "—"}</InfoRow>
|
||||
<InfoRow label="Completed">{toDateInputValue(dispatch.completedDate) || "—"}</InfoRow>
|
||||
<InfoRow label="Verified">{formatDateTime(dispatch.verifiedAt)}</InfoRow>
|
||||
<InfoRow label="Email sent">{dispatch.emailSent ? "Yes" : "No"}</InfoRow>
|
||||
</Box>
|
||||
{Boolean(dispatch.description) && (
|
||||
<Box className="mt-3">
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Description
|
||||
</Typography>
|
||||
<Typography variant="body2" className="whitespace-pre-wrap">
|
||||
{dispatch.description}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{dispatch.primaryLocation != null && (
|
||||
<Paper variant="outlined" className="p-4">
|
||||
<Typography variant="subtitle1" className="mb-3 font-semibold">
|
||||
Site
|
||||
</Typography>
|
||||
<Box className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<InfoRow label="Location">{dispatch.primaryLocation.name}</InfoRow>
|
||||
<InfoRow label="Address">
|
||||
{[
|
||||
dispatch.primaryLocation.address,
|
||||
dispatch.primaryLocation.city,
|
||||
dispatch.primaryLocation.state,
|
||||
dispatch.primaryLocation.zipCode,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ")}
|
||||
</InfoRow>
|
||||
</Box>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Paper variant="outlined" className="p-4">
|
||||
<Typography variant="subtitle1" className="mb-3 font-semibold">
|
||||
Linked Work Orders ({dispatch.workOrders.length})
|
||||
</Typography>
|
||||
{dispatch.workOrders.length === 0 ? (
|
||||
<Typography color="text.secondary">No work orders linked.</Typography>
|
||||
) : (
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>WO #</TableCell>
|
||||
<TableCell>Title</TableCell>
|
||||
<TableCell>Trade</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell>Location</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{dispatch.workOrders.map((wo) => (
|
||||
<TableRow key={String(wo.workOrderId)}>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="text"
|
||||
size="small"
|
||||
onClick={() => navigate(`/workorders/${wo.workOrderId}`)}
|
||||
>
|
||||
{wo.internalWONumber || wo.workOrderId}
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell>{wo.title}</TableCell>
|
||||
<TableCell>{wo.trade}</TableCell>
|
||||
<TableCell>{wo.status}</TableCell>
|
||||
<TableCell>{wo.locationName}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<Paper variant="outlined" className="p-4">
|
||||
<Typography variant="subtitle1" className="mb-3 font-semibold">
|
||||
NTE Uplift History ({dispatch.upliftRequests.length})
|
||||
</Typography>
|
||||
{dispatch.upliftRequests.length === 0 ? (
|
||||
<Typography color="text.secondary">No uplift requests on this PO.</Typography>
|
||||
) : (
|
||||
<Stack spacing={2}>
|
||||
{dispatch.upliftRequests.map((u) => (
|
||||
<Box key={String(u.id)} className="rounded border border-border p-3">
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{ justifyContent: "space-between", alignItems: "center" }}
|
||||
>
|
||||
<Typography variant="body2" className="font-semibold">
|
||||
{formatMoney(u.currentNTE)} → {formatMoney(u.requestedNTE)}
|
||||
<Typography
|
||||
component="span"
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
className="ml-2"
|
||||
>
|
||||
(Δ {formatMoney(u.delta)}, Tier {u.requiredTier})
|
||||
</Typography>
|
||||
</Typography>
|
||||
<Chip size="small" label={u.status} />
|
||||
</Stack>
|
||||
{Boolean(u.vendorReason) && (
|
||||
<Typography variant="body2" className="mt-1">
|
||||
<strong>Reason:</strong> {u.vendorReason}
|
||||
</Typography>
|
||||
)}
|
||||
{u.status === "Pending" && (
|
||||
<Stack direction="row" spacing={1} className="mt-2">
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
color="success"
|
||||
onClick={() => {
|
||||
setUpliftAction({ type: "approve", uplift: u });
|
||||
setNote("");
|
||||
}}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
color="error"
|
||||
onClick={() => {
|
||||
setUpliftAction({ type: "deny", uplift: u });
|
||||
setNote("");
|
||||
}}
|
||||
>
|
||||
Deny
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Paper>
|
||||
<VendorSummaryPaper vendor={vendor} />
|
||||
<PoDetailsPaper dispatch={dispatch} />
|
||||
<SitePaper location={dispatch.primaryLocation} />
|
||||
<WorkOrdersPaper
|
||||
workOrders={dispatch.workOrders}
|
||||
onView={(workOrderId) => navigate(`/workorders/${workOrderId}`)}
|
||||
/>
|
||||
<UpliftHistoryPaper
|
||||
upliftRequests={dispatch.upliftRequests}
|
||||
onDownload={(uplift) => void downloadEvidence(uplift)}
|
||||
onDecide={handleDecide}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Dialog open={Boolean(upliftAction)} onClose={() => setUpliftAction(null)}>
|
||||
<DialogTitle>
|
||||
{upliftAction?.type === "approve" ? "Approve Uplift" : "Deny Uplift"}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={2}
|
||||
className="mt-2"
|
||||
label={upliftAction?.type === "deny" ? "Denial reason (required)" : "Optional note"}
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
required={upliftAction?.type === "deny"}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setUpliftAction(null)}>Cancel</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color={upliftAction?.type === "approve" ? "success" : "error"}
|
||||
disabled={
|
||||
upliftAction?.type === "deny"
|
||||
? !note.trim() || denyUplift.isPending
|
||||
: approveUplift.isPending
|
||||
}
|
||||
onClick={handleUpliftConfirm}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
<UpliftDecisionDialog
|
||||
action={upliftAction}
|
||||
note={note}
|
||||
noteRequired={noteRequired}
|
||||
decisionPending={decisionPending}
|
||||
onNoteChange={setNote}
|
||||
onClose={closeUpliftDialog}
|
||||
onConfirm={handleUpliftConfirm}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,341 @@
|
|||
import { type ReactNode } from "react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Paper,
|
||||
Stack,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import type { UpliftRequest } from "@/domain/uplifts/types/uplift";
|
||||
import type {
|
||||
VendorPoDetail,
|
||||
VendorPoLocation,
|
||||
VendorPoVendor,
|
||||
VendorPoWorkOrder,
|
||||
} from "@/domain/vendor-pos/types/vendor-po";
|
||||
import { formatDateTime, toDateInputValue } from "@/lib/time-utils";
|
||||
|
||||
export type UpliftActionType = "approve" | "reject" | "request-changes";
|
||||
|
||||
export type VendorPoUpliftDecision = { type: UpliftActionType; uplift: UpliftRequest };
|
||||
|
||||
function formatMoney(value: number | null | undefined): string {
|
||||
if (value == null) return "—";
|
||||
return `$${Number(value).toFixed(2)}`;
|
||||
}
|
||||
|
||||
function InfoRow({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{children == null || children === "" ? "—" : children}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function VendorSummaryPaper({ vendor }: { vendor: VendorPoVendor | null }) {
|
||||
return (
|
||||
<Paper variant="outlined" className="p-4">
|
||||
<Typography variant="subtitle1" className="mb-3 font-semibold">
|
||||
Vendor
|
||||
</Typography>
|
||||
{vendor ? (
|
||||
<Box className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3">
|
||||
<InfoRow label="Company">{vendor.companyName}</InfoRow>
|
||||
<InfoRow label="Contact">{vendor.contactName}</InfoRow>
|
||||
<InfoRow label="Email">{vendor.email}</InfoRow>
|
||||
<InfoRow label="Phone">{vendor.phone}</InfoRow>
|
||||
<InfoRow label="Address">
|
||||
{[vendor.address, vendor.city, vendor.state, vendor.zip].filter(Boolean).join(", ")}
|
||||
</InfoRow>
|
||||
<InfoRow label="Trades">{vendor.tradeSpecialties}</InfoRow>
|
||||
</Box>
|
||||
) : (
|
||||
<Typography color="text.secondary">No vendor attached.</Typography>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export function PoDetailsPaper({ dispatch }: { dispatch: VendorPoDetail }) {
|
||||
return (
|
||||
<Paper variant="outlined" className="p-4">
|
||||
<Typography variant="subtitle1" className="mb-3 font-semibold">
|
||||
PO Details
|
||||
</Typography>
|
||||
<Box className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3">
|
||||
<InfoRow label="PO #">{dispatch.poNumber}</InfoRow>
|
||||
<InfoRow label="Dispatch #">{dispatch.dispatchNumber}</InfoRow>
|
||||
<InfoRow label="Status">{dispatch.status}</InfoRow>
|
||||
<InfoRow label="NTE">{formatMoney(dispatch.nteAmount)}</InfoRow>
|
||||
<InfoRow label="Dispatched">{formatDateTime(dispatch.dispatchedAt)}</InfoRow>
|
||||
<InfoRow label="Acknowledged">{formatDateTime(dispatch.acknowledgedAt)}</InfoRow>
|
||||
<InfoRow label="Scheduled">{toDateInputValue(dispatch.scheduledDate) || "—"}</InfoRow>
|
||||
<InfoRow label="Completed">{toDateInputValue(dispatch.completedDate) || "—"}</InfoRow>
|
||||
<InfoRow label="Verified">{formatDateTime(dispatch.verifiedAt)}</InfoRow>
|
||||
<InfoRow label="Email sent">{dispatch.emailSent ? "Yes" : "No"}</InfoRow>
|
||||
</Box>
|
||||
{Boolean(dispatch.description) && (
|
||||
<Box className="mt-3">
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Description
|
||||
</Typography>
|
||||
<Typography variant="body2" className="whitespace-pre-wrap">
|
||||
{dispatch.description}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export function SitePaper({ location }: { location: VendorPoLocation | null }) {
|
||||
if (location == null) return null;
|
||||
return (
|
||||
<Paper variant="outlined" className="p-4">
|
||||
<Typography variant="subtitle1" className="mb-3 font-semibold">
|
||||
Site
|
||||
</Typography>
|
||||
<Box className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<InfoRow label="Location">{location.name}</InfoRow>
|
||||
<InfoRow label="Address">
|
||||
{[location.address, location.city, location.state, location.zipCode]
|
||||
.filter(Boolean)
|
||||
.join(", ")}
|
||||
</InfoRow>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkOrdersPaper({
|
||||
workOrders,
|
||||
onView,
|
||||
}: {
|
||||
workOrders: VendorPoWorkOrder[];
|
||||
onView: (workOrderId: string | number) => void;
|
||||
}) {
|
||||
return (
|
||||
<Paper variant="outlined" className="p-4">
|
||||
<Typography variant="subtitle1" className="mb-3 font-semibold">
|
||||
Linked Work Orders ({workOrders.length})
|
||||
</Typography>
|
||||
{workOrders.length === 0 ? (
|
||||
<Typography color="text.secondary">No work orders linked.</Typography>
|
||||
) : (
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>WO #</TableCell>
|
||||
<TableCell>Title</TableCell>
|
||||
<TableCell>Trade</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell>Location</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{workOrders.map((wo) => (
|
||||
<TableRow key={String(wo.workOrderId)}>
|
||||
<TableCell>
|
||||
<Button variant="text" size="small" onClick={() => onView(wo.workOrderId)}>
|
||||
{wo.internalWONumber || wo.workOrderId}
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell>{wo.title}</TableCell>
|
||||
<TableCell>{wo.trade}</TableCell>
|
||||
<TableCell>{wo.status}</TableCell>
|
||||
<TableCell>{wo.locationName}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function UpliftHistoryItem({
|
||||
uplift,
|
||||
onDownload,
|
||||
onDecide,
|
||||
}: {
|
||||
uplift: UpliftRequest;
|
||||
onDownload: (uplift: UpliftRequest) => void;
|
||||
onDecide: (type: UpliftActionType, uplift: UpliftRequest) => void;
|
||||
}) {
|
||||
const decisionable = uplift.status === "Pending";
|
||||
return (
|
||||
<Box className="rounded border border-border p-3">
|
||||
<Stack direction="row" sx={{ justifyContent: "space-between", alignItems: "center" }}>
|
||||
<Typography variant="body2" className="font-semibold">
|
||||
{formatMoney(uplift.currentNTE)} → {formatMoney(uplift.requestedNTE)}
|
||||
<Typography component="span" variant="caption" color="text.secondary" className="ml-2">
|
||||
(Tier {uplift.requiredTier})
|
||||
</Typography>
|
||||
</Typography>
|
||||
<Chip size="small" label={uplift.status} />
|
||||
</Stack>
|
||||
{Boolean(uplift.expiresAt) && (
|
||||
<Typography variant="caption" color="text.secondary" className="mt-1 block">
|
||||
Expires {formatDateTime(uplift.expiresAt)}
|
||||
</Typography>
|
||||
)}
|
||||
{Boolean(uplift.vendorReason) && (
|
||||
<Typography variant="body2" className="mt-1">
|
||||
<strong>Reason:</strong> {uplift.vendorReason}
|
||||
</Typography>
|
||||
)}
|
||||
{uplift.evidenceDocumentId != null && (
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }} className="mt-1">
|
||||
<Typography variant="body2">
|
||||
<strong>Evidence:</strong>{" "}
|
||||
{uplift.evidenceFileName || `Document #${uplift.evidenceDocumentId}`}
|
||||
</Typography>
|
||||
<Button size="small" variant="text" onClick={() => onDownload(uplift)}>
|
||||
Download
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
{Boolean(uplift.decisionNote) && (
|
||||
<Typography variant="body2" className="mt-1">
|
||||
<strong>Decision:</strong> {uplift.decisionNote}
|
||||
</Typography>
|
||||
)}
|
||||
{uplift.notificationStatus === "Error" && Boolean(uplift.notificationError) && (
|
||||
<Typography variant="body2" color="error" className="mt-1">
|
||||
Notification error: {uplift.notificationError}
|
||||
</Typography>
|
||||
)}
|
||||
{decisionable && (
|
||||
<Stack direction="row" spacing={1} className="mt-2">
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
color="success"
|
||||
disabled={!uplift.canDecide}
|
||||
onClick={() => onDecide("approve", uplift)}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
color="error"
|
||||
disabled={!uplift.canDecide}
|
||||
onClick={() => onDecide("reject", uplift)}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
disabled={!uplift.canDecide}
|
||||
onClick={() => onDecide("request-changes", uplift)}
|
||||
>
|
||||
Request Changes
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function UpliftHistoryPaper({
|
||||
upliftRequests,
|
||||
onDownload,
|
||||
onDecide,
|
||||
}: {
|
||||
upliftRequests: UpliftRequest[];
|
||||
onDownload: (uplift: UpliftRequest) => void;
|
||||
onDecide: (type: UpliftActionType, uplift: UpliftRequest) => void;
|
||||
}) {
|
||||
return (
|
||||
<Paper variant="outlined" className="p-4">
|
||||
<Typography variant="subtitle1" className="mb-3 font-semibold">
|
||||
NTE Uplift History ({upliftRequests.length})
|
||||
</Typography>
|
||||
{upliftRequests.length === 0 ? (
|
||||
<Typography color="text.secondary">No uplift requests on this PO.</Typography>
|
||||
) : (
|
||||
<Stack spacing={2}>
|
||||
{upliftRequests.map((uplift) => (
|
||||
<UpliftHistoryItem
|
||||
key={String(uplift.id)}
|
||||
uplift={uplift}
|
||||
onDownload={onDownload}
|
||||
onDecide={onDecide}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export function UpliftDecisionDialog({
|
||||
action,
|
||||
note,
|
||||
noteRequired,
|
||||
decisionPending,
|
||||
onNoteChange,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: {
|
||||
action: VendorPoUpliftDecision | null;
|
||||
note: string;
|
||||
noteRequired: boolean;
|
||||
decisionPending: boolean;
|
||||
onNoteChange: (value: string) => void;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={Boolean(action)} onClose={onClose}>
|
||||
<DialogTitle>
|
||||
{action?.type === "approve"
|
||||
? "Approve Uplift"
|
||||
: action?.type === "reject"
|
||||
? "Reject Uplift"
|
||||
: "Request Changes"}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={2}
|
||||
className="mt-2"
|
||||
label={noteRequired ? "Note (required)" : "Optional note"}
|
||||
value={note}
|
||||
required={noteRequired}
|
||||
onChange={(e) => onNoteChange(e.target.value)}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color={action?.type === "approve" ? "success" : "primary"}
|
||||
disabled={noteRequired ? !note.trim() || decisionPending : decisionPending}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -48,7 +48,9 @@ export function VendorsListToolbar({
|
|||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
{globalCount} vendor entries · {activeCount} active
|
||||
</Typography>
|
||||
{isFetching && !isLoading && <CircularProgress size={16} />}
|
||||
{isFetching && !isLoading && (
|
||||
<CircularProgress size={16} aria-label="Refreshing vendors" role="status" />
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Stack
|
||||
|
|
@ -62,6 +64,7 @@ export function VendorsListToolbar({
|
|||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder="Search name, company, trade..."
|
||||
className="min-w-[260px]"
|
||||
aria-label="Search vendors"
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
|
|
|
|||
|
|
@ -1,110 +1,100 @@
|
|||
import { useNavigate, useParams } from "react-router";
|
||||
import { useParams } from "react-router";
|
||||
import { Box } from "@mui/material";
|
||||
import { QueryErrorPanel } from "@/components/ui/query-error-panel";
|
||||
import { DetailPageSkeleton } from "@/app/(protected)/workorders/_components/loading/detail-page-skeleton";
|
||||
import { DetailPageSkeleton } from "./_components/loading/detail-page-skeleton";
|
||||
import { DispatchCreateModal } from "./_components/dispatch-create-modal";
|
||||
import { DispatchDetailModal } from "./_components/dispatch-detail-modal";
|
||||
import { WorkOrderHeaderSection } from "@/app/(protected)/workorders/_components/view/work-order-header-section";
|
||||
import { WorkOrderDetailsPanel } from "@/app/(protected)/workorders/_components/view/work-order-details-panel";
|
||||
import {
|
||||
WorkOrderDescriptionAccordion,
|
||||
WorkOrderInfoAccordions,
|
||||
} from "@/app/(protected)/workorders/_components/view/work-order-info-accordions";
|
||||
import { WorkOrderDispatchesAccordion } from "@/app/(protected)/workorders/_components/view/work-order-dispatches-accordion";
|
||||
import { WorkOrderActivityPanel } from "@/app/(protected)/workorders/_components/view/work-order-activity-panel";
|
||||
import { WorkOrderVendorEditorDialog } from "@/app/(protected)/workorders/_components/view/work-order-vendor-editor-dialog";
|
||||
import { useWorkOrderViewController } from "@/app/(protected)/workorders/_hooks/use-work-order-view-controller";
|
||||
import { WorkOrderActivityPanel } from "./_components/view/work-order-activity-panel";
|
||||
import { WorkOrderDispatchesAccordion } from "./_components/view/work-order-dispatches-accordion";
|
||||
import { WorkOrderVendorEditorDialog } from "./_components/view/work-order-vendor-editor-dialog";
|
||||
import { WorkOrderViewAccordions } from "./_components/view/work-order-view-accordions";
|
||||
import { WorkOrderViewDetails } from "./_components/view/work-order-view-details";
|
||||
import { WorkOrderViewHeader } from "./_components/view/work-order-view-header";
|
||||
import { useWorkOrderViewPage } from "./_hooks/use-work-order-view-page";
|
||||
|
||||
export default function WorkOrderViewPage() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const workOrderId = id ?? "";
|
||||
const controller = useWorkOrderViewController(workOrderId);
|
||||
const { wo, isLoading, error, refetch, vendorEditor } = controller;
|
||||
const view = useWorkOrderViewPage(workOrderId);
|
||||
|
||||
if (isLoading && !wo) {
|
||||
if (view.isLoading && !view.wo) {
|
||||
return <DetailPageSkeleton variant="view" />;
|
||||
}
|
||||
|
||||
if ((error || !wo) && !isLoading) {
|
||||
if (!view.wo) {
|
||||
return (
|
||||
<Box className="p-4">
|
||||
<QueryErrorPanel
|
||||
message={error instanceof Error ? error.message : "Work order not found."}
|
||||
onRetry={() => void refetch()}
|
||||
message={view.error instanceof Error ? view.error.message : "Work order not found."}
|
||||
onRetry={() => void view.refetch()}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!wo) {
|
||||
return <DetailPageSkeleton variant="view" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className="flex flex-col gap-4 p-4">
|
||||
<WorkOrderHeaderSection
|
||||
wo={wo}
|
||||
statusAnchor={controller.statusAnchor}
|
||||
onStatusAnchorChange={controller.setStatusAnchor}
|
||||
onStatusChange={(status) => controller.statusMutation.mutate(status)}
|
||||
onDispatch={() => controller.setDispatchOpen(true)}
|
||||
onEdit={() => navigate(`/workorders/edit/${id}`)}
|
||||
onBack={() => navigate("/workorders")}
|
||||
<WorkOrderViewHeader
|
||||
wo={view.wo}
|
||||
workOrderId={workOrderId}
|
||||
statusAnchor={view.statusAnchor}
|
||||
onStatusAnchorChange={view.setStatusAnchor}
|
||||
onStatusChange={(status) => view.statusMutation.mutate(status)}
|
||||
onOpenDispatch={() => view.setDispatchOpen(true)}
|
||||
/>
|
||||
<WorkOrderDetailsPanel
|
||||
wo={wo}
|
||||
users={controller.users}
|
||||
assignAnchor={controller.assignAnchor}
|
||||
onAssignAnchorChange={controller.setAssignAnchor}
|
||||
onAssign={(userId, userName) => controller.assignmentMutation.mutate({ userId, userName })}
|
||||
|
||||
<WorkOrderViewDetails
|
||||
wo={view.wo}
|
||||
users={view.users}
|
||||
assignAnchor={view.assignAnchor}
|
||||
onAssignAnchorChange={view.setAssignAnchor}
|
||||
onAssign={(userId, userName) => view.assignmentMutation.mutate({ userId, userName })}
|
||||
onUnassign={() => view.assignmentMutation.mutate({ userId: "", userName: "" })}
|
||||
/>
|
||||
<WorkOrderInfoAccordions wo={wo} />
|
||||
|
||||
<WorkOrderViewAccordions wo={view.wo} />
|
||||
|
||||
<WorkOrderDispatchesAccordion
|
||||
dispatches={wo.dispatches}
|
||||
onSelectDispatch={controller.setDispatchDetailId}
|
||||
onEditVendor={(event, vendorId, vendorName) => {
|
||||
event.stopPropagation();
|
||||
void vendorEditor.openVendorEditor(vendorId, vendorName);
|
||||
}}
|
||||
wo={view.wo}
|
||||
onOpenDispatchDetail={view.setDispatchDetailId}
|
||||
onOpenVendorEditor={view.openVendorEditor}
|
||||
/>
|
||||
<WorkOrderDescriptionAccordion description={wo.description} />
|
||||
|
||||
<WorkOrderActivityPanel
|
||||
activeTab={controller.activeTab}
|
||||
onTabChange={controller.setActiveTab}
|
||||
customerComments={controller.customerComments}
|
||||
internalComments={controller.internalComments}
|
||||
auditLog={controller.auditLog}
|
||||
commentText={controller.commentText}
|
||||
onCommentTextChange={controller.setCommentText}
|
||||
isSubmittingComment={controller.commentMutation.isPending}
|
||||
onSubmitComment={controller.handleCommentSubmit}
|
||||
customerComments={view.customerComments}
|
||||
internalComments={view.internalComments}
|
||||
auditLog={view.auditLog}
|
||||
mentionUsers={view.mentionUsers}
|
||||
isSubmitting={view.commentMutation.isPending}
|
||||
onSubmitComment={view.submitComment}
|
||||
/>
|
||||
{controller.dispatchOpen && (
|
||||
|
||||
{view.dispatchOpen && (
|
||||
<DispatchCreateModal
|
||||
workOrder={wo}
|
||||
onClose={() => controller.setDispatchOpen(false)}
|
||||
onDispatched={() => void refetch()}
|
||||
workOrder={view.wo}
|
||||
onClose={() => view.setDispatchOpen(false)}
|
||||
onDispatched={() => void view.refetch()}
|
||||
/>
|
||||
)}
|
||||
{controller.dispatchDetailId != null && (
|
||||
{view.dispatchDetailId != null && (
|
||||
<DispatchDetailModal
|
||||
dispatchId={controller.dispatchDetailId}
|
||||
dispatchId={view.dispatchDetailId}
|
||||
workOrderId={workOrderId}
|
||||
onClose={() => controller.setDispatchDetailId(null)}
|
||||
onUpdated={() => void refetch()}
|
||||
onClose={() => view.setDispatchDetailId(null)}
|
||||
onUpdated={() => void view.refetch()}
|
||||
/>
|
||||
)}
|
||||
|
||||
<WorkOrderVendorEditorDialog
|
||||
vendorEditor={vendorEditor.vendorEditor}
|
||||
isLoading={vendorEditor.vendorEditorLoading}
|
||||
isSaving={vendorEditor.vendorMutation.isPending}
|
||||
error={vendorEditor.vendorEditorError}
|
||||
onClose={vendorEditor.closeVendorEditor}
|
||||
onChange={(patch) =>
|
||||
vendorEditor.setVendorEditor((current) => (current ? { ...current, ...patch } : current))
|
||||
}
|
||||
onSave={vendorEditor.saveVendorEditor}
|
||||
editor={view.vendorEditor}
|
||||
loading={view.vendorEditorLoading}
|
||||
error={view.vendorEditorError}
|
||||
isSaving={view.vendorMutation.isPending}
|
||||
onClose={() => {
|
||||
if (!view.vendorMutation.isPending) view.setVendorEditor(null);
|
||||
}}
|
||||
onSave={view.saveVendorEditor}
|
||||
onEditorChange={view.setVendorEditor}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,75 @@
|
|||
import { Building2, MapPin, Users, Wrench } from "lucide-react";
|
||||
import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { CompDocDetailRow } from "./comp-doc-detail-row";
|
||||
import { CompDocEditableRow } from "./comp-doc-editable-row";
|
||||
|
||||
type CompDocDetailsSectionProps = {
|
||||
row: WorkOrderTableRow;
|
||||
site: CompDocSiteInfo;
|
||||
editAddress: string;
|
||||
onAddressChange: (value: string) => void;
|
||||
editDateCompleted: string;
|
||||
onDateCompletedChange: (value: string) => void;
|
||||
amazonRep: string;
|
||||
seaRep: string;
|
||||
isGenerated: boolean;
|
||||
};
|
||||
|
||||
export function CompDocDetailsSection({
|
||||
row,
|
||||
site,
|
||||
editAddress,
|
||||
onAddressChange,
|
||||
editDateCompleted,
|
||||
onDateCompletedChange,
|
||||
amazonRep,
|
||||
seaRep,
|
||||
isGenerated,
|
||||
}: CompDocDetailsSectionProps) {
|
||||
return (
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
letterSpacing: "0.1em",
|
||||
textTransform: "uppercase",
|
||||
color: "var(--muted-foreground)",
|
||||
}}
|
||||
>
|
||||
Work Order Details
|
||||
</div>
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<CompDocDetailRow icon={Building2} label="WO #" value={`#${row.woNumber}`} />
|
||||
<CompDocDetailRow
|
||||
icon={MapPin}
|
||||
label="Site Code"
|
||||
value={`${row.site} · Amazon.com Services LLC`}
|
||||
/>
|
||||
<CompDocEditableRow
|
||||
label="Address"
|
||||
value={editAddress}
|
||||
onChange={onAddressChange}
|
||||
placeholder={site.fullAddress !== "—" ? site.fullAddress : "Street address"}
|
||||
disabled={isGenerated}
|
||||
/>
|
||||
<CompDocEditableRow
|
||||
label="Date Work Completed"
|
||||
value={editDateCompleted}
|
||||
onChange={onDateCompletedChange}
|
||||
type="date"
|
||||
disabled={isGenerated}
|
||||
/>
|
||||
<CompDocDetailRow icon={Users} label="Amazon Representative" value={amazonRep || "—"} />
|
||||
<CompDocDetailRow
|
||||
icon={Wrench}
|
||||
label="Seahaven Representative"
|
||||
value={seaRep || "—"}
|
||||
last
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import { Ban, Save, Sparkles } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { COMP_DOC_FOOTER_BTN_SX } from "./comp-doc-footer-btn-sx";
|
||||
|
||||
type CompDocDialogFooterDraftProps = {
|
||||
onMarkNotRequired: () => void;
|
||||
onSaveDraft: () => void;
|
||||
onGenerate: () => void;
|
||||
};
|
||||
|
||||
export function CompDocDialogFooterDraft({
|
||||
onMarkNotRequired,
|
||||
onSaveDraft,
|
||||
onGenerate,
|
||||
}: CompDocDialogFooterDraftProps) {
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="default"
|
||||
className="gap-1.5 whitespace-nowrap text-[12px]"
|
||||
sx={COMP_DOC_FOOTER_BTN_SX}
|
||||
style={{ color: "var(--muted-foreground)" }}
|
||||
onClick={onMarkNotRequired}
|
||||
>
|
||||
<Ban size={14} /> Mark as not required
|
||||
</Button>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="gap-1.5 whitespace-nowrap text-[12px]"
|
||||
sx={COMP_DOC_FOOTER_BTN_SX}
|
||||
onClick={onSaveDraft}
|
||||
>
|
||||
<Save size={14} /> Save draft
|
||||
</Button>
|
||||
<Button
|
||||
size="default"
|
||||
className="gap-1.5 whitespace-nowrap text-[12px]"
|
||||
sx={COMP_DOC_FOOTER_BTN_SX}
|
||||
style={{ background: "var(--color-primary)", color: "#fff" }}
|
||||
onClick={onGenerate}
|
||||
>
|
||||
<Sparkles size={14} /> Generate
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import { CheckCircle2, Download } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { COMP_DOC_FOOTER_BTN_SX } from "./comp-doc-footer-btn-sx";
|
||||
|
||||
type CompDocDialogFooterGeneratedProps = {
|
||||
generatedAt: string;
|
||||
onDownloadPdf: () => void;
|
||||
};
|
||||
|
||||
export function CompDocDialogFooterGenerated({
|
||||
generatedAt,
|
||||
onDownloadPdf,
|
||||
}: CompDocDialogFooterGeneratedProps) {
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 whitespace-nowrap"
|
||||
style={{ fontSize: 12, color: "var(--color-success)" }}
|
||||
>
|
||||
<CheckCircle2 size={14} /> Generated {generatedAt}
|
||||
</span>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
size="default"
|
||||
className="gap-1.5 whitespace-nowrap text-[12px]"
|
||||
sx={COMP_DOC_FOOTER_BTN_SX}
|
||||
style={{ background: "var(--color-primary)", color: "#fff" }}
|
||||
onClick={onDownloadPdf}
|
||||
>
|
||||
<Download size={14} /> Download PDF
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import { Ban, Sparkles, Undo2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { COMP_DOC_FOOTER_BTN_SX } from "./comp-doc-footer-btn-sx";
|
||||
|
||||
type CompDocDialogFooterNotRequiredProps = {
|
||||
onUndo: () => void;
|
||||
};
|
||||
|
||||
export function CompDocDialogFooterNotRequired({ onUndo }: CompDocDialogFooterNotRequiredProps) {
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 whitespace-nowrap"
|
||||
style={{ fontSize: 12, color: "var(--muted-foreground)" }}
|
||||
>
|
||||
<Ban size={14} style={{ color: "var(--warning)" }} /> Marked{" "}
|
||||
<b style={{ fontWeight: 600 }}>not required</b> for this work order
|
||||
</span>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="gap-1.5 whitespace-nowrap text-[12px]"
|
||||
sx={COMP_DOC_FOOTER_BTN_SX}
|
||||
onClick={onUndo}
|
||||
>
|
||||
<Undo2 size={14} /> Undo
|
||||
</Button>
|
||||
<Button
|
||||
size="default"
|
||||
disabled
|
||||
title="Marked not required — undo to generate"
|
||||
className="gap-1.5 whitespace-nowrap text-[12px]"
|
||||
sx={COMP_DOC_FOOTER_BTN_SX}
|
||||
style={{
|
||||
background: "var(--color-primary)",
|
||||
color: "#fff",
|
||||
opacity: 0.5,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
<Sparkles size={14} /> Generate
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,13 +4,13 @@ import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-ta
|
|||
import { serviceTitle } from "@/domain/work-orders/utils/completion-doc-service-title";
|
||||
|
||||
type CompDocDialogHeaderProps = {
|
||||
info: WorkOrderTableRow;
|
||||
row: WorkOrderTableRow;
|
||||
cfg: CompletionDocStateConfig;
|
||||
isUploading: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function CompDocDialogHeader({ info, cfg, isUploading, onClose }: CompDocDialogHeaderProps) {
|
||||
export function CompDocDialogHeader({ row, cfg, isUploading, onClose }: CompDocDialogHeaderProps) {
|
||||
return (
|
||||
<div
|
||||
className="flex shrink-0 items-center justify-between"
|
||||
|
|
@ -45,10 +45,10 @@ export function CompDocDialogHeader({ info, cfg, isUploading, onClose }: CompDoc
|
|||
>
|
||||
WO{" "}
|
||||
<span className="font-mono" style={{ color: "var(--color-primary)", fontWeight: 600 }}>
|
||||
#{info.woNumber}
|
||||
#{row.woNumber}
|
||||
</span>
|
||||
{" · "}
|
||||
{serviceTitle(info)}
|
||||
{serviceTitle(row)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,15 +1,23 @@
|
|||
import type { MouseEvent } from "react";
|
||||
import { useRef, useState, type MouseEvent } from "react";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
|
||||
import { toast } from "react-toastify";
|
||||
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import type { CompDocData, CompDocSaveStatus } from "@/domain/work-orders/types/completion-doc";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||
import { useUploadCompletionDoc } from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
||||
import {
|
||||
formatMediaSizeLimit,
|
||||
isWorkOrderDocumentsReadOnly,
|
||||
isWorkOrderFileTooLarge,
|
||||
} from "@/domain/work-orders/utils/work-order-media-limits";
|
||||
import { useCompDocDialog } from "@/app/(protected)/workorders/_hooks/use-comp-doc-dialog";
|
||||
import { CompDocDialogFooter } from "./comp-doc-dialog-footer";
|
||||
import { CompDocDialogHeader } from "./comp-doc-dialog-header";
|
||||
import { CompDocDialogResourceLinks } from "./comp-doc-dialog-resource-links";
|
||||
import { CompDocPreview } from "./comp-doc-preview";
|
||||
import { CompDocSidebar } from "./comp-doc-sidebar";
|
||||
import { useCompDocDialog } from "./use-comp-doc-dialog";
|
||||
|
||||
type CompDocDialogProps = {
|
||||
row: WorkOrderTableRow;
|
||||
|
|
@ -20,6 +28,59 @@ type CompDocDialogProps = {
|
|||
onDateCompletedChange?: (date: string) => void | Promise<void>;
|
||||
};
|
||||
|
||||
type UseCompDocPdfUploadArgs = {
|
||||
workOrderId: string | number;
|
||||
rowVersion: string;
|
||||
onUploaded: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
function rejectCompDocPdf(file: File, rowVersion: string): string | null {
|
||||
if (isWorkOrderFileTooLarge(file)) {
|
||||
return `File exceeds the ${formatMediaSizeLimit()} limit.`;
|
||||
}
|
||||
if (file.type !== "application/pdf" && !file.name.toLowerCase().endsWith(".pdf")) {
|
||||
return "Please upload a PDF file.";
|
||||
}
|
||||
if (!rowVersion) {
|
||||
return "Missing work order version. Refresh and try again.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function useCompDocPdfUpload({ workOrderId, rowVersion, onUploaded }: UseCompDocPdfUploadArgs) {
|
||||
const uploadCompletionMutation = useUploadCompletionDoc(workOrderId);
|
||||
const pdfInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isFinalizing, setIsFinalizing] = useState(false);
|
||||
|
||||
function handleUploadPdf(files: FileList | null) {
|
||||
const file = files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const rejection = rejectCompDocPdf(file, rowVersion);
|
||||
if (rejection) {
|
||||
toast.error(rejection);
|
||||
return;
|
||||
}
|
||||
|
||||
uploadCompletionMutation.mutate(
|
||||
{ file, workOrderVersion: rowVersion },
|
||||
{
|
||||
onSuccess: () => {
|
||||
// Keep close/actions disabled across the gap between upload settle and docStatus persist.
|
||||
setIsFinalizing(true);
|
||||
void Promise.resolve(onUploaded()).finally(() => setIsFinalizing(false));
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
pdfInputRef,
|
||||
handleUploadPdf,
|
||||
isUploading: uploadCompletionMutation.isPending || isFinalizing,
|
||||
};
|
||||
}
|
||||
|
||||
export function CompDocDialog({
|
||||
row,
|
||||
open,
|
||||
|
|
@ -28,14 +89,33 @@ export function CompDocDialog({
|
|||
onSave,
|
||||
onDateCompletedChange,
|
||||
}: CompDocDialogProps) {
|
||||
const dialog = useCompDocDialog({
|
||||
row,
|
||||
open,
|
||||
data,
|
||||
onSave,
|
||||
onDateCompletedChange,
|
||||
const dialog = useCompDocDialog({ row, open, data, onSave, onDateCompletedChange });
|
||||
const { data: boardDetail } = useWorkOrderBoardDetail(row.id, open);
|
||||
|
||||
const templateUrl = boardDetail?.completion.template?.templateUrl;
|
||||
const signOffAttachment = boardDetail?.completion.signOffAttachment || null;
|
||||
const rowVersion = boardDetail?.info.rowVersion || row.rowVersion;
|
||||
const isDocumentsReadOnly = isWorkOrderDocumentsReadOnly(boardDetail?.info.status ?? row.status);
|
||||
|
||||
// Uploading a signed PDF finalizes through the same Generate flow, so the
|
||||
// completedDate/address prerequisites still persist before docStatus Yes.
|
||||
const upload = useCompDocPdfUpload({
|
||||
workOrderId: row.id,
|
||||
rowVersion,
|
||||
onUploaded: dialog.handleGenerate,
|
||||
});
|
||||
const handleClose = dialog.isUploading ? undefined : () => onOpenChange(false);
|
||||
|
||||
// A signed PDF on file means the document exists even if docState has not caught up.
|
||||
const isGenerated = dialog.isGenerated || Boolean(signOffAttachment);
|
||||
const handleClose = upload.isUploading ? undefined : () => onOpenChange(false);
|
||||
|
||||
function handleDownloadPdf() {
|
||||
if (signOffAttachment) {
|
||||
window.open(signOffAttachment, "_blank", "noopener,noreferrer");
|
||||
return;
|
||||
}
|
||||
dialog.handleDownloadPdf();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
|
|
@ -66,45 +146,45 @@ export function CompDocDialog({
|
|||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<DialogTitle className="sr-only">
|
||||
Completion Document — WO #{dialog.info.woNumber}
|
||||
</DialogTitle>
|
||||
<DialogTitle className="sr-only">Completion Document — WO #{row.woNumber}</DialogTitle>
|
||||
<Text className="sr-only" variant="description">
|
||||
Generate, save or mark not-required the completion document for this work order.
|
||||
</Text>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<CompDocDialogHeader
|
||||
info={dialog.info}
|
||||
row={row}
|
||||
cfg={dialog.cfg}
|
||||
isUploading={dialog.isUploading}
|
||||
isUploading={upload.isUploading}
|
||||
onClose={() => handleClose?.()}
|
||||
/>
|
||||
|
||||
<CompDocDialogResourceLinks
|
||||
templateUrl={dialog.templateUrl}
|
||||
signOffAttachment={dialog.signOffAttachment}
|
||||
templateUrl={templateUrl}
|
||||
signOffAttachment={signOffAttachment}
|
||||
/>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-row">
|
||||
<CompDocPreview
|
||||
row={dialog.info}
|
||||
row={row}
|
||||
site={dialog.site}
|
||||
notes={dialog.notes}
|
||||
generatedAt={dialog.generatedAt}
|
||||
isGenerated={dialog.isGenerated}
|
||||
isGenerated={isGenerated}
|
||||
isNotRequired={dialog.isNotRequired}
|
||||
/>
|
||||
<CompDocSidebar
|
||||
row={dialog.info}
|
||||
row={row}
|
||||
site={dialog.site}
|
||||
docState={dialog.docState}
|
||||
generatedAt={dialog.generatedAt}
|
||||
notes={dialog.notes}
|
||||
editAddress={dialog.editAddress}
|
||||
onAddressChange={dialog.setEditAddress}
|
||||
editDateCompleted={dialog.editDateCompleted}
|
||||
amazonRep={dialog.amazonRep}
|
||||
seaRep={dialog.seaRep}
|
||||
isGenerated={dialog.isGenerated}
|
||||
isGenerated={isGenerated}
|
||||
onNotesChange={dialog.setNotes}
|
||||
onDateCompletedChange={dialog.setEditDateCompleted}
|
||||
/>
|
||||
|
|
@ -112,18 +192,18 @@ export function CompDocDialog({
|
|||
|
||||
<CompDocDialogFooter
|
||||
docState={dialog.docState}
|
||||
isDocumentsReadOnly={dialog.isDocumentsReadOnly}
|
||||
rowVersion={dialog.rowVersion}
|
||||
isUploading={dialog.isUploading}
|
||||
signOffAttachment={dialog.signOffAttachment}
|
||||
isDocumentsReadOnly={isDocumentsReadOnly}
|
||||
rowVersion={rowVersion}
|
||||
isUploading={upload.isUploading}
|
||||
signOffAttachment={signOffAttachment}
|
||||
generatedAt={dialog.generatedAt}
|
||||
pdfInputRef={dialog.pdfInputRef}
|
||||
pdfInputRef={upload.pdfInputRef}
|
||||
onMarkNotRequired={dialog.handleMarkNotRequired}
|
||||
onSaveDraft={dialog.handleSaveDraft}
|
||||
onGenerate={dialog.handleGenerate}
|
||||
onUndo={dialog.handleUndo}
|
||||
onDownloadPdf={dialog.handleDownloadPdf}
|
||||
onUploadPdf={dialog.handleUploadPdf}
|
||||
onDownloadPdf={handleDownloadPdf}
|
||||
onUploadPdf={upload.handleUploadPdf}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
import { CheckCircle2 } from "lucide-react";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
export function showCompDocDraftSavedToast() {
|
||||
toast(
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
style={{
|
||||
background: "var(--foreground)",
|
||||
color: "#fff",
|
||||
padding: "10px 18px",
|
||||
borderRadius: 9999,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
boxShadow: "0 10px 30px -10px rgba(0,0,0,.45)",
|
||||
}}
|
||||
>
|
||||
<CheckCircle2 size={16} /> Draft saved
|
||||
</div>,
|
||||
{
|
||||
position: "bottom-center",
|
||||
autoClose: 3000,
|
||||
hideProgressBar: true,
|
||||
closeButton: false,
|
||||
icon: false,
|
||||
className: "!min-h-0 !bg-transparent !p-0 !shadow-none",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import type { CSSProperties } from "react";
|
||||
|
||||
const COMP_DOC_INPUT_CLS = "w-full rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-1";
|
||||
|
||||
const COMP_DOC_INPUT_STYLE: CSSProperties = {
|
||||
fontSize: 13,
|
||||
border: "1px solid var(--color-border)",
|
||||
color: "var(--color-text-primary)",
|
||||
background: "var(--color-bg-surface)",
|
||||
};
|
||||
|
||||
const COMP_DOC_INPUT_DISABLED_STYLE: CSSProperties = {
|
||||
opacity: 0.6,
|
||||
cursor: "default",
|
||||
pointerEvents: "none",
|
||||
};
|
||||
|
||||
type CompDocEditableRowProps = {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
type?: string;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function CompDocEditableRow({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
type = "text",
|
||||
placeholder = "",
|
||||
disabled = false,
|
||||
}: CompDocEditableRowProps) {
|
||||
return (
|
||||
<div style={{ padding: "8px 0", borderBottom: "1px solid var(--color-border)" }}>
|
||||
<div style={{ fontSize: 11.5, color: "var(--muted-foreground)", marginBottom: 4 }}>
|
||||
{label}
|
||||
</div>
|
||||
<input
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
readOnly={disabled}
|
||||
placeholder={placeholder}
|
||||
className={COMP_DOC_INPUT_CLS}
|
||||
style={{
|
||||
...COMP_DOC_INPUT_STYLE,
|
||||
...(disabled ? COMP_DOC_INPUT_DISABLED_STYLE : {}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
export const COMP_DOC_FOOTER_BTN_SX = {
|
||||
minHeight: 28,
|
||||
height: 28,
|
||||
fontSize: 12,
|
||||
px: 1.25,
|
||||
py: 0.5,
|
||||
gap: 0.75,
|
||||
} as const;
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import { Ban } from "lucide-react";
|
||||
|
||||
export function CompDocNotRequiredStamp() {
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-start justify-center">
|
||||
<div
|
||||
className="inline-flex items-center gap-2"
|
||||
style={{
|
||||
marginTop: "30%",
|
||||
transform: "rotate(-15deg)",
|
||||
border: "3px solid var(--warning)",
|
||||
color: "var(--warning)",
|
||||
borderRadius: 8,
|
||||
padding: "8px 18px",
|
||||
fontWeight: 800,
|
||||
fontSize: 22,
|
||||
letterSpacing: "0.06em",
|
||||
background: "rgba(255,255,255,0.65)",
|
||||
}}
|
||||
>
|
||||
<Ban size={20} /> NOT REQUIRED
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
type CompDocNotesFieldProps = {
|
||||
notes: string;
|
||||
onNotesChange: (value: string) => void;
|
||||
isGenerated: boolean;
|
||||
};
|
||||
|
||||
export function CompDocNotesField({ notes, onNotesChange, isGenerated }: CompDocNotesFieldProps) {
|
||||
return (
|
||||
<div style={{ marginTop: 18 }}>
|
||||
<label
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: "var(--color-text-primary)",
|
||||
}}
|
||||
>
|
||||
PM Comments{" "}
|
||||
<span style={{ fontWeight: 400, color: "var(--muted-foreground)" }}>
|
||||
— optional, included in the document
|
||||
</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => onNotesChange(e.target.value)}
|
||||
readOnly={isGenerated}
|
||||
rows={3}
|
||||
placeholder="Add findings, parts used, access notes, or anything the site POC should see…"
|
||||
className="mt-2 w-full resize-none"
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontFamily: "var(--font-sans)",
|
||||
color: "var(--color-text-primary)",
|
||||
background: "var(--color-bg-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
borderRadius: 8,
|
||||
padding: "10px 12px",
|
||||
lineHeight: 1.5,
|
||||
outline: "none",
|
||||
...(isGenerated
|
||||
? {
|
||||
opacity: 0.6,
|
||||
cursor: "default",
|
||||
pointerEvents: "none" as const,
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import { CheckCircle2, FileText } from "lucide-react";
|
||||
|
||||
type CompDocPreviewBadgeProps = {
|
||||
isGenerated: boolean;
|
||||
};
|
||||
|
||||
export function CompDocPreviewBadge({ isGenerated }: CompDocPreviewBadgeProps) {
|
||||
return (
|
||||
<div className="flex shrink-0 items-center justify-center" style={{ padding: "12px 0" }}>
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5"
|
||||
style={{
|
||||
height: 28,
|
||||
padding: "0 12px",
|
||||
borderRadius: 9999,
|
||||
background: "var(--color-bg-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
color: "var(--muted-foreground)",
|
||||
}}
|
||||
>
|
||||
{isGenerated ? (
|
||||
<CheckCircle2 size={13} style={{ color: "var(--color-success)" }} />
|
||||
) : (
|
||||
<FileText size={13} />
|
||||
)}
|
||||
{isGenerated ? "Generated document" : "Preview — not yet generated"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import { Building2, Mail, MapPin, Phone, Printer } from "lucide-react";
|
||||
import { SEAHAVEN_HEADER } from "@/domain/work-orders/utils/completion-doc-html";
|
||||
import { CompDocCoLine } from "./comp-doc-co-line";
|
||||
|
||||
export function CompDocPreviewHeader() {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="flex flex-wrap items-start justify-between gap-y-3"
|
||||
style={{ padding: "20px 0 12px" }}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<img
|
||||
src="/seahaven-logo.svg"
|
||||
alt="Seahaven Industries"
|
||||
style={{ height: 34, width: "auto", display: "block" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<CompDocCoLine icon={Building2} text={SEAHAVEN_HEADER.name} bold />
|
||||
<CompDocCoLine icon={MapPin} text={SEAHAVEN_HEADER.address} />
|
||||
<CompDocCoLine icon={Phone} text={SEAHAVEN_HEADER.phone} />
|
||||
<CompDocCoLine icon={Printer} text={SEAHAVEN_HEADER.fax} />
|
||||
<CompDocCoLine icon={Mail} text={SEAHAVEN_HEADER.email} />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
height: 3,
|
||||
borderRadius: 2,
|
||||
background: "linear-gradient(90deg,#27AAE1,#1C75BC)",
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
import { Text } from "@/components/ui/text";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { procedureFor, SAFETY_BULLETS } from "@/domain/work-orders/utils/completion-doc-procedures";
|
||||
import { CompDocProcedureList } from "./comp-doc-procedure-list";
|
||||
|
||||
type CompDocPreviewProcedureProps = {
|
||||
row: WorkOrderTableRow;
|
||||
notes: string;
|
||||
};
|
||||
|
||||
export function CompDocPreviewProcedure({ row, notes }: CompDocPreviewProcedureProps) {
|
||||
const proc = procedureFor(row.pm);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ul style={{ listStyle: "disc", paddingLeft: 18, margin: "8px 0 0" }}>
|
||||
{SAFETY_BULLETS.map((b, i) => (
|
||||
<li
|
||||
key={i}
|
||||
style={{
|
||||
fontSize: 12.5,
|
||||
lineHeight: 1.5,
|
||||
margin: "5px 0",
|
||||
color: "#161E38",
|
||||
}}
|
||||
>
|
||||
{b}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Text
|
||||
as="h3"
|
||||
variant="heading"
|
||||
sx={{
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
color: "#161E38",
|
||||
margin: "16px 0 4px",
|
||||
}}
|
||||
>
|
||||
{proc.heading}
|
||||
</Text>
|
||||
<Text
|
||||
variant="body"
|
||||
sx={{
|
||||
fontSize: 12.5,
|
||||
lineHeight: 1.5,
|
||||
color: "#58595B",
|
||||
margin: "0 0 8px",
|
||||
}}
|
||||
>
|
||||
{proc.intro}
|
||||
</Text>
|
||||
<CompDocProcedureList proc={proc} />
|
||||
|
||||
{notes.length > 0 && (
|
||||
<>
|
||||
<Text
|
||||
as="h3"
|
||||
variant="heading"
|
||||
sx={{
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
color: "#161E38",
|
||||
margin: "16px 0 4px",
|
||||
}}
|
||||
>
|
||||
PM Comments
|
||||
</Text>
|
||||
<Text
|
||||
variant="body"
|
||||
sx={{
|
||||
fontSize: 12.5,
|
||||
lineHeight: 1.5,
|
||||
color: "#161E38",
|
||||
margin: 0,
|
||||
whiteSpace: "pre-wrap",
|
||||
}}
|
||||
>
|
||||
{notes}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
import { CheckCircle2 } from "lucide-react";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { serviceTitle } from "@/domain/work-orders/utils/completion-doc-service-title";
|
||||
|
||||
type CompDocPreviewSummaryProps = {
|
||||
row: WorkOrderTableRow;
|
||||
site: CompDocSiteInfo;
|
||||
generatedAt: string;
|
||||
isGenerated: boolean;
|
||||
};
|
||||
|
||||
export function CompDocPreviewSummary({
|
||||
row,
|
||||
site,
|
||||
generatedAt,
|
||||
isGenerated,
|
||||
}: CompDocPreviewSummaryProps) {
|
||||
return (
|
||||
<>
|
||||
{isGenerated && (
|
||||
<div
|
||||
className="inline-flex items-center gap-1.5"
|
||||
style={{
|
||||
marginTop: 16,
|
||||
padding: "4px 10px",
|
||||
borderRadius: 9999,
|
||||
background: "var(--doc-yes-bg)",
|
||||
color: "var(--color-success)",
|
||||
fontSize: 11.5,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
<CheckCircle2 size={13} /> Generated · {generatedAt} · v1
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Text
|
||||
as="h2"
|
||||
variant="heading"
|
||||
sx={{
|
||||
fontFamily: "var(--font-sans)",
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.02em",
|
||||
color: "#161E38",
|
||||
margin: "16px 0 4px",
|
||||
}}
|
||||
>
|
||||
{serviceTitle(row).toUpperCase()}
|
||||
</Text>
|
||||
<Text
|
||||
family="mono"
|
||||
variant="body"
|
||||
sx={{
|
||||
fontSize: 12.5,
|
||||
fontWeight: 600,
|
||||
color: "#1C75BC",
|
||||
margin: "2px 0",
|
||||
}}
|
||||
>
|
||||
WO# {row.woNumber}
|
||||
</Text>
|
||||
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<Text
|
||||
variant="body"
|
||||
sx={{
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: "#161E38",
|
||||
margin: "2px 0",
|
||||
}}
|
||||
>
|
||||
Site ID — {row.site}
|
||||
</Text>
|
||||
<Text variant="body" sx={{ fontSize: 13, color: "#161E38", margin: "2px 0" }}>
|
||||
Amazon.com Services LLC {row.site}
|
||||
</Text>
|
||||
<Text variant="body" sx={{ fontSize: 13, color: "#58595B", margin: "2px 0" }}>
|
||||
{site.street},
|
||||
</Text>
|
||||
<Text variant="body" sx={{ fontSize: 13, color: "#58595B", margin: "2px 0" }}>
|
||||
{site.label}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Text variant="body" sx={{ fontSize: 13, color: "#161E38", margin: "14px 0 6px" }}>
|
||||
<span style={{ fontWeight: 700 }}>Vendor / Technician: </span>
|
||||
{row.tech || "—"}
|
||||
{row.company ? ` · ${row.company}` : ""}
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,8 +1,10 @@
|
|||
import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { CompDocPreviewDocument } from "./comp-doc-preview-document";
|
||||
import { CompDocPreviewNotRequiredStamp } from "./comp-doc-preview-not-required-stamp";
|
||||
import { CompDocPreviewStatusBadge } from "./comp-doc-preview-status-badge";
|
||||
import { CompDocNotRequiredStamp } from "./comp-doc-not-required-stamp";
|
||||
import { CompDocPreviewBadge } from "./comp-doc-preview-badge";
|
||||
import { CompDocPreviewHeader } from "./comp-doc-preview-header";
|
||||
import { CompDocPreviewProcedure } from "./comp-doc-preview-procedure";
|
||||
import { CompDocPreviewSummary } from "./comp-doc-preview-summary";
|
||||
|
||||
type CompDocPreviewProps = {
|
||||
row: WorkOrderTableRow;
|
||||
|
|
@ -30,7 +32,7 @@ export function CompDocPreview({
|
|||
background: "var(--color-bg-muted)",
|
||||
}}
|
||||
>
|
||||
<CompDocPreviewStatusBadge isGenerated={isGenerated} />
|
||||
<CompDocPreviewBadge isGenerated={isGenerated} />
|
||||
|
||||
<div
|
||||
className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto"
|
||||
|
|
@ -48,16 +50,38 @@ export function CompDocPreview({
|
|||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
<CompDocPreviewDocument
|
||||
row={row}
|
||||
site={site}
|
||||
notes={notes}
|
||||
generatedAt={generatedAt}
|
||||
isGenerated={isGenerated}
|
||||
isNotRequired={isNotRequired}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
opacity: isNotRequired ? 0.5 : 1,
|
||||
filter: isNotRequired ? "grayscale(0.4)" : "none",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: 12,
|
||||
background: "linear-gradient(100deg,#27AAE1,#1C75BC)",
|
||||
}}
|
||||
/>
|
||||
<div style={{ padding: "0 28px 28px" }}>
|
||||
<CompDocPreviewHeader />
|
||||
<CompDocPreviewSummary
|
||||
row={row}
|
||||
site={site}
|
||||
generatedAt={generatedAt}
|
||||
isGenerated={isGenerated}
|
||||
/>
|
||||
<CompDocPreviewProcedure row={row} notes={notes} />
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
height: 24,
|
||||
background: "linear-gradient(100deg,#1C75BC,#27AAE1)",
|
||||
clipPath: "polygon(0 45%,100% 0,100% 100%,0 100%)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isNotRequired && <CompDocPreviewNotRequiredStamp />}
|
||||
{isNotRequired && <CompDocNotRequiredStamp />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,26 +7,29 @@ type CompDocProcedureListProps = {
|
|||
export function CompDocProcedureList({ proc }: CompDocProcedureListProps) {
|
||||
return (
|
||||
<ol style={{ listStyle: "decimal", paddingLeft: 22, margin: 0 }}>
|
||||
{proc.steps.map((st, i) => (
|
||||
<li key={i} style={{ margin: "6px 0", fontSize: 13, lineHeight: 1.5 }}>
|
||||
{st.text}
|
||||
{st.sub != null && (
|
||||
<ol
|
||||
style={{
|
||||
listStyle: "lower-alpha",
|
||||
paddingLeft: 22,
|
||||
margin: "4px 0 0",
|
||||
}}
|
||||
>
|
||||
{st.sub.map((x, j) => (
|
||||
<li key={j} style={{ margin: "2px 0" }}>
|
||||
{x}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
{proc.steps.map((st, i) => {
|
||||
const subSteps = st.sub ?? [];
|
||||
return (
|
||||
<li key={i} style={{ margin: "6px 0", fontSize: 13, lineHeight: 1.5 }}>
|
||||
{st.text}
|
||||
{subSteps.length > 0 && (
|
||||
<ol
|
||||
style={{
|
||||
listStyle: "lower-alpha",
|
||||
paddingLeft: 22,
|
||||
margin: "4px 0 0",
|
||||
}}
|
||||
>
|
||||
{subSteps.map((x, j) => (
|
||||
<li key={j} style={{ margin: "2px 0" }}>
|
||||
{x}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,8 @@
|
|||
import { Building2, MapPin, Users, Wrench } from "lucide-react";
|
||||
import type { CompDocSiteInfo, DocState } from "@/domain/work-orders/types/completion-doc";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import {
|
||||
DOC_STATE_CFG,
|
||||
docStatusCardDescription,
|
||||
} from "@/domain/work-orders/utils/completion-doc-state";
|
||||
import { CompDocDetailRow } from "./comp-doc-detail-row";
|
||||
import { CompDocEditRow } from "./comp-doc-edit-row";
|
||||
import { CompDocDetailsSection } from "./comp-doc-details-section";
|
||||
import { CompDocNotesField } from "./comp-doc-notes-field";
|
||||
import { CompDocStatusCard } from "./comp-doc-status-card";
|
||||
|
||||
type CompDocSidebarProps = {
|
||||
row: WorkOrderTableRow;
|
||||
|
|
@ -14,6 +10,8 @@ type CompDocSidebarProps = {
|
|||
docState: DocState;
|
||||
generatedAt: string;
|
||||
notes: string;
|
||||
editAddress: string;
|
||||
onAddressChange: (value: string) => void;
|
||||
editDateCompleted: string;
|
||||
amazonRep: string;
|
||||
seaRep: string;
|
||||
|
|
@ -28,6 +26,8 @@ export function CompDocSidebar({
|
|||
docState,
|
||||
generatedAt,
|
||||
notes,
|
||||
editAddress,
|
||||
onAddressChange,
|
||||
editDateCompleted,
|
||||
amazonRep,
|
||||
seaRep,
|
||||
|
|
@ -35,139 +35,25 @@ export function CompDocSidebar({
|
|||
onNotesChange,
|
||||
onDateCompletedChange,
|
||||
}: CompDocSidebarProps) {
|
||||
const displayState: DocState =
|
||||
isGenerated && docState !== "not-required" ? "generated" : docState;
|
||||
const cfg = DOC_STATE_CFG[displayState];
|
||||
const StateIcon = cfg.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-0 flex-col"
|
||||
style={{ width: "42%", background: "var(--color-bg-surface)" }}
|
||||
>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto" style={{ padding: 20 }}>
|
||||
<div
|
||||
className="flex items-start gap-3"
|
||||
style={{
|
||||
padding: 14,
|
||||
borderRadius: 10,
|
||||
background: cfg.cardBg,
|
||||
border: `1px solid ${cfg.cardBorder}`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex shrink-0 items-center justify-center"
|
||||
style={{
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 8,
|
||||
background: cfg.cardIconBg,
|
||||
border: `1px solid ${cfg.cardBorder}`,
|
||||
}}
|
||||
>
|
||||
<StateIcon size={17} style={{ color: cfg.iconColor }} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13.5,
|
||||
fontWeight: 600,
|
||||
color: "var(--color-text-primary)",
|
||||
}}
|
||||
>
|
||||
{cfg.cardTitle}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12.5,
|
||||
lineHeight: 1.45,
|
||||
color: "var(--muted-foreground)",
|
||||
marginTop: 3,
|
||||
}}
|
||||
>
|
||||
{docStatusCardDescription(displayState, generatedAt)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
letterSpacing: "0.1em",
|
||||
textTransform: "uppercase",
|
||||
color: "var(--muted-foreground)",
|
||||
}}
|
||||
>
|
||||
Work Order Details
|
||||
</div>
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<CompDocDetailRow icon={Building2} label="WO #" value={`#${row.woNumber}`} />
|
||||
<CompDocDetailRow
|
||||
icon={MapPin}
|
||||
label="Site Code"
|
||||
value={`${row.site} · Amazon.com Services LLC`}
|
||||
/>
|
||||
<CompDocDetailRow icon={MapPin} label="Address" value={site.fullAddress} />
|
||||
<CompDocEditRow
|
||||
label="Date Work Completed"
|
||||
value={editDateCompleted}
|
||||
onChange={onDateCompletedChange}
|
||||
type="date"
|
||||
readOnly={isGenerated}
|
||||
/>
|
||||
<CompDocDetailRow icon={Users} label="Amazon Representative" value={amazonRep || "—"} />
|
||||
<CompDocDetailRow
|
||||
icon={Wrench}
|
||||
label="Seahaven Representative"
|
||||
value={seaRep || "—"}
|
||||
last
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 18 }}>
|
||||
<label
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: "var(--color-text-primary)",
|
||||
}}
|
||||
>
|
||||
PM Comments{" "}
|
||||
<span style={{ fontWeight: 400, color: "var(--muted-foreground)" }}>
|
||||
— optional, included in the document
|
||||
</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => onNotesChange(e.target.value)}
|
||||
readOnly={isGenerated}
|
||||
rows={3}
|
||||
placeholder="Add findings, parts used, access notes, or anything the site POC should see…"
|
||||
className="mt-2 w-full resize-none"
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontFamily: "var(--font-sans)",
|
||||
color: "var(--color-text-primary)",
|
||||
background: "var(--color-bg-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
borderRadius: 8,
|
||||
padding: "10px 12px",
|
||||
lineHeight: 1.5,
|
||||
outline: "none",
|
||||
...(isGenerated
|
||||
? {
|
||||
opacity: 0.6,
|
||||
cursor: "default",
|
||||
pointerEvents: "none" as const,
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<CompDocStatusCard docState={docState} generatedAt={generatedAt} />
|
||||
<CompDocDetailsSection
|
||||
row={row}
|
||||
site={site}
|
||||
editAddress={editAddress}
|
||||
onAddressChange={onAddressChange}
|
||||
editDateCompleted={editDateCompleted}
|
||||
onDateCompletedChange={onDateCompletedChange}
|
||||
amazonRep={amazonRep}
|
||||
seaRep={seaRep}
|
||||
isGenerated={isGenerated}
|
||||
/>
|
||||
<CompDocNotesField notes={notes} onNotesChange={onNotesChange} isGenerated={isGenerated} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
import type { DocState } from "@/domain/work-orders/types/completion-doc";
|
||||
import {
|
||||
DOC_STATE_CFG,
|
||||
docStatusCardDescription,
|
||||
} from "@/domain/work-orders/utils/completion-doc-state";
|
||||
|
||||
type CompDocStatusCardProps = {
|
||||
docState: DocState;
|
||||
generatedAt: string;
|
||||
};
|
||||
|
||||
export function CompDocStatusCard({ docState, generatedAt }: CompDocStatusCardProps) {
|
||||
const cfg = DOC_STATE_CFG[docState];
|
||||
const StateIcon = cfg.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-start gap-3"
|
||||
style={{
|
||||
padding: 14,
|
||||
borderRadius: 10,
|
||||
background: cfg.cardBg,
|
||||
border: `1px solid ${cfg.cardBorder}`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex shrink-0 items-center justify-center"
|
||||
style={{
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 8,
|
||||
background: cfg.cardIconBg,
|
||||
border: `1px solid ${cfg.cardBorder}`,
|
||||
}}
|
||||
>
|
||||
<StateIcon size={17} style={{ color: cfg.iconColor }} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13.5,
|
||||
fontWeight: 600,
|
||||
color: "var(--color-text-primary)",
|
||||
}}
|
||||
>
|
||||
{cfg.cardTitle}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12.5,
|
||||
lineHeight: 1.45,
|
||||
color: "var(--muted-foreground)",
|
||||
marginTop: 3,
|
||||
}}
|
||||
>
|
||||
{docStatusCardDescription(docState, generatedAt)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import type { MediaUploadRequest } from "@/app/(protected)/workorders/_components/media/media-uploader";
|
||||
import { useMediaUploadSession } from "@/app/(protected)/workorders/_hooks/use-media-upload-session";
|
||||
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||
|
|
@ -72,7 +73,12 @@ export function useSlideOverMediaState({
|
|||
};
|
||||
|
||||
const categorizeMedia = (mediaId: string, category: UploadableMediaCategory) => {
|
||||
categorizeMediaMutation.mutate({ mediaId, category });
|
||||
const workOrderVersion = detailForRow?.info.rowVersion;
|
||||
if (!workOrderVersion) {
|
||||
toast.error("Missing work order version. Refresh and try again.");
|
||||
return;
|
||||
}
|
||||
categorizeMediaMutation.mutate({ mediaId, category, workOrderVersion });
|
||||
};
|
||||
|
||||
const removeMedia = (id: string) => {
|
||||
|
|
@ -80,7 +86,12 @@ export function useSlideOverMediaState({
|
|||
removeLocal(id);
|
||||
return;
|
||||
}
|
||||
deleteMediaMutation.mutate(id);
|
||||
const workOrderVersion = detailForRow?.info.rowVersion;
|
||||
if (!workOrderVersion) {
|
||||
toast.error("Missing work order version. Refresh and try again.");
|
||||
return;
|
||||
}
|
||||
deleteMediaMutation.mutate({ mediaId: id, workOrderVersion });
|
||||
};
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -147,7 +147,7 @@ export function WorkOrderSlideOverTabs({
|
|||
onUpload={(files) => {
|
||||
void state.uploadExtraFiles(files);
|
||||
}}
|
||||
onRemove={(id) => state.deleteMediaMutation.mutate(id)}
|
||||
onRemove={state.removeMedia}
|
||||
/>
|
||||
</WorkOrderSlideOverTabPanel>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -72,7 +72,10 @@ export function DispatchDetailBody({ dispatch, controller }: DispatchDetailBodyP
|
|||
uplifts={controller.uplifts}
|
||||
isLocked={controller.isLocked}
|
||||
onApprove={(uplift) => controller.setUpliftAction({ type: "approve", uplift })}
|
||||
onDeny={(uplift) => controller.setUpliftAction({ type: "deny", uplift })}
|
||||
onReject={(uplift) => controller.setUpliftAction({ type: "reject", uplift })}
|
||||
onRequestChanges={(uplift) =>
|
||||
controller.setUpliftAction({ type: "request-changes", uplift })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,82 +1,178 @@
|
|||
import { Box, Button, Chip, Stack, Tooltip, Typography } from "@mui/material";
|
||||
import type { UpliftRequest } from "@/domain/uplifts/types/uplift";
|
||||
import { upliftsApi } from "@/domain/uplifts/api/uplifts-api";
|
||||
import { formatDateTime } from "@/lib/time-utils";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
type UpliftAction = "approve" | "reject" | "request-changes";
|
||||
|
||||
type DispatchUpliftsSectionProps = {
|
||||
uplifts: UpliftRequest[];
|
||||
isLocked: boolean;
|
||||
onApprove: (uplift: UpliftRequest) => void;
|
||||
onDeny: (uplift: UpliftRequest) => void;
|
||||
onReject: (uplift: UpliftRequest) => void;
|
||||
onRequestChanges: (uplift: UpliftRequest) => void;
|
||||
};
|
||||
|
||||
const ACTION_LABELS: Record<Exclude<UpliftAction, "approve">, string> = {
|
||||
reject: "Reject",
|
||||
"request-changes": "Request Changes",
|
||||
};
|
||||
|
||||
function actionColor(action: UpliftAction): "success" | "error" | "primary" {
|
||||
if (action === "approve") return "success";
|
||||
if (action === "reject") return "error";
|
||||
return "primary";
|
||||
}
|
||||
|
||||
function actionVariant(action: UpliftAction): "contained" | "outlined" {
|
||||
return action === "request-changes" ? "outlined" : "contained";
|
||||
}
|
||||
|
||||
type UpliftCardProps = Omit<DispatchUpliftsSectionProps, "uplifts"> & {
|
||||
uplift: UpliftRequest;
|
||||
};
|
||||
|
||||
function UpliftCard({ uplift, isLocked, onApprove, onDeny }: UpliftCardProps) {
|
||||
const canDecide = uplift.canDecide && uplift.status === "Pending" && !isLocked;
|
||||
type UpliftCardActionsProps = Pick<
|
||||
UpliftCardProps,
|
||||
"isLocked" | "onApprove" | "onReject" | "onRequestChanges"
|
||||
> & {
|
||||
uplift: UpliftRequest;
|
||||
};
|
||||
|
||||
function downloadUpliftEvidence(uplift: UpliftRequest) {
|
||||
return upliftsApi
|
||||
.downloadEvidence(uplift.id, uplift.evidenceFileName || "uplift-evidence")
|
||||
.catch((err: unknown) => {
|
||||
toast.error(err instanceof Error ? err.message : "Unable to download evidence.");
|
||||
});
|
||||
}
|
||||
|
||||
function UpliftCardHeader({ uplift }: { uplift: UpliftRequest }) {
|
||||
const evidenceDocumentId = uplift.evidenceDocumentId;
|
||||
return (
|
||||
<Box className="rounded border border-border p-3">
|
||||
<>
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: "center", flexWrap: "wrap" }}>
|
||||
<Chip label={uplift.status} size="small" />
|
||||
{uplift.requiredTier === 2 && (
|
||||
<Typography variant="caption">Tier 2 · Manager approval</Typography>
|
||||
)}
|
||||
<Typography variant="body2">
|
||||
${Number(uplift.currentNTE ?? 0).toFixed(2)} → ${Number(uplift.requestedNTE).toFixed(2)}{" "}
|
||||
(+${Number(uplift.delta).toFixed(2)})
|
||||
${Number(uplift.currentNTE ?? 0).toFixed(2)} → ${Number(uplift.requestedNTE).toFixed(2)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
{Boolean(uplift.expiresAt) && (
|
||||
<Typography variant="caption" color="text.secondary" className="mt-1 block">
|
||||
Expires {formatDateTime(uplift.expiresAt)}
|
||||
</Typography>
|
||||
)}
|
||||
{Boolean(uplift.vendorReason) && (
|
||||
<Typography variant="body2" className="mt-1">
|
||||
<strong>Reason:</strong> {uplift.vendorReason}
|
||||
</Typography>
|
||||
)}
|
||||
{evidenceDocumentId != null && (
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }} className="mt-1">
|
||||
<Typography variant="body2">
|
||||
<strong>Evidence:</strong>{" "}
|
||||
{uplift.evidenceFileName || `Document #${evidenceDocumentId}`}
|
||||
</Typography>
|
||||
<Button size="small" variant="text" onClick={() => void downloadUpliftEvidence(uplift)}>
|
||||
Download
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
{uplift.notificationStatus === "Error" && Boolean(uplift.notificationError) && (
|
||||
<Typography variant="body2" color="error" className="mt-1">
|
||||
Notification error: {uplift.notificationError}
|
||||
</Typography>
|
||||
)}
|
||||
{Boolean(uplift.requestedByVendorName || uplift.requestedAt) && (
|
||||
<Typography variant="body2" color="text.secondary" className="mt-1">
|
||||
Requested by {uplift.requestedByVendorName || "vendor"}
|
||||
{uplift.requestedAt ? ` · ${formatDateTime(uplift.requestedAt)}` : ""}
|
||||
</Typography>
|
||||
)}
|
||||
{uplift.status !== "Pending" &&
|
||||
Boolean(uplift.decidedByName || uplift.decidedAt || uplift.decisionNote) && (
|
||||
<Box className="mt-1">
|
||||
{Boolean(uplift.decidedByName || uplift.decidedAt) && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{uplift.status} by {uplift.decidedByName || "—"}
|
||||
{uplift.decidedAt ? ` · ${formatDateTime(uplift.decidedAt)}` : ""}
|
||||
</Typography>
|
||||
)}
|
||||
{Boolean(uplift.decisionNote) && (
|
||||
<Typography variant="body2" className="mt-0.5">
|
||||
<strong>Note:</strong> {uplift.decisionNote}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
{uplift.status === "Pending" && (
|
||||
<Stack direction="row" spacing={1} className="mt-2">
|
||||
{(["approve", "deny"] as const).map((action) => (
|
||||
<Tooltip
|
||||
key={action}
|
||||
title={!canDecide ? `Requires Tier ${uplift.requiredTier} role` : ""}
|
||||
>
|
||||
<span>
|
||||
<Button
|
||||
size="small"
|
||||
color={action === "approve" ? "success" : "error"}
|
||||
variant="contained"
|
||||
disabled={!canDecide}
|
||||
onClick={() => (action === "approve" ? onApprove(uplift) : onDeny(uplift))}
|
||||
>
|
||||
{action === "approve" ? "Approve" : "Deny"}
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function UpliftCardDecisionSummary({ uplift }: { uplift: UpliftRequest }) {
|
||||
if (uplift.status === "Pending") return null;
|
||||
if (!uplift.decidedByName && !uplift.decidedAt && !uplift.decisionNote) return null;
|
||||
return (
|
||||
<Box className="mt-1">
|
||||
{Boolean(uplift.decidedByName || uplift.decidedAt) && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{uplift.status} by {uplift.decidedByName || "—"}
|
||||
{uplift.decidedAt ? ` · ${formatDateTime(uplift.decidedAt)}` : ""}
|
||||
</Typography>
|
||||
)}
|
||||
{Boolean(uplift.decisionNote) && (
|
||||
<Typography variant="body2" className="mt-0.5">
|
||||
<strong>Note:</strong> {uplift.decisionNote}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function UpliftCardActions({
|
||||
uplift,
|
||||
isLocked,
|
||||
onApprove,
|
||||
onReject,
|
||||
onRequestChanges,
|
||||
}: UpliftCardActionsProps) {
|
||||
if (uplift.status !== "Pending") return null;
|
||||
const canDecide = uplift.canDecide && !isLocked;
|
||||
const decisionActions: Exclude<UpliftAction, "approve">[] = ["reject", "request-changes"];
|
||||
return (
|
||||
<Stack direction="row" spacing={1} className="mt-2">
|
||||
<Tooltip title={!canDecide ? `Requires Tier ${uplift.requiredTier} role` : ""}>
|
||||
<span>
|
||||
<Button
|
||||
size="small"
|
||||
color="success"
|
||||
variant="contained"
|
||||
disabled={!canDecide}
|
||||
onClick={() => onApprove(uplift)}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
{decisionActions.map((action) => (
|
||||
<Tooltip key={action} title={!canDecide ? `Requires Tier ${uplift.requiredTier} role` : ""}>
|
||||
<span>
|
||||
<Button
|
||||
size="small"
|
||||
color={actionColor(action)}
|
||||
variant={actionVariant(action)}
|
||||
disabled={!canDecide}
|
||||
onClick={() => (action === "reject" ? onReject(uplift) : onRequestChanges(uplift))}
|
||||
>
|
||||
{ACTION_LABELS[action]}
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function UpliftCard({ uplift, isLocked, onApprove, onReject, onRequestChanges }: UpliftCardProps) {
|
||||
return (
|
||||
<Box className="rounded border border-border p-3">
|
||||
<UpliftCardHeader uplift={uplift} />
|
||||
<UpliftCardDecisionSummary uplift={uplift} />
|
||||
<UpliftCardActions
|
||||
uplift={uplift}
|
||||
isLocked={isLocked}
|
||||
onApprove={onApprove}
|
||||
onReject={onReject}
|
||||
onRequestChanges={onRequestChanges}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -85,7 +181,8 @@ export function DispatchUpliftsSection({
|
|||
uplifts,
|
||||
isLocked,
|
||||
onApprove,
|
||||
onDeny,
|
||||
onReject,
|
||||
onRequestChanges,
|
||||
}: DispatchUpliftsSectionProps) {
|
||||
return (
|
||||
<Box>
|
||||
|
|
@ -102,7 +199,8 @@ export function DispatchUpliftsSection({
|
|||
uplift={uplift}
|
||||
isLocked={isLocked}
|
||||
onApprove={onApprove}
|
||||
onDeny={onDeny}
|
||||
onReject={onReject}
|
||||
onRequestChanges={onRequestChanges}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
|
|
|
|||
|
|
@ -21,6 +21,10 @@ export function DispatchDetailModal({
|
|||
}: DispatchDetailModalProps) {
|
||||
const controller = useDispatchDetailController(dispatchId, workOrderId);
|
||||
const handleClose = guardDialogClose(controller.isMutating, onClose);
|
||||
const isDecidingUplift =
|
||||
controller.approveUpliftMutation.isPending ||
|
||||
controller.rejectUpliftMutation.isPending ||
|
||||
controller.requestChangesUpliftMutation.isPending;
|
||||
|
||||
if (controller.isLoading && !controller.dispatch) {
|
||||
return (
|
||||
|
|
@ -77,17 +81,12 @@ export function DispatchDetailModal({
|
|||
requestedNTE={controller.upliftAction.uplift.requestedNTE}
|
||||
currentNTE={controller.upliftAction.uplift.currentNTE}
|
||||
onClose={() => {
|
||||
if (
|
||||
!controller.approveUpliftMutation.isPending &&
|
||||
!controller.denyUpliftMutation.isPending
|
||||
) {
|
||||
if (!isDecidingUplift) {
|
||||
controller.setUpliftAction(null);
|
||||
}
|
||||
}}
|
||||
onConfirm={controller.handleUpliftConfirm}
|
||||
isPending={
|
||||
controller.approveUpliftMutation.isPending || controller.denyUpliftMutation.isPending
|
||||
}
|
||||
isPending={isDecidingUplift}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import { guardDialogClose } from "@/lib/dialog-guard";
|
|||
|
||||
type UpliftDecisionDialogProps = {
|
||||
open: boolean;
|
||||
type: "approve" | "deny";
|
||||
type: "approve" | "reject" | "request-changes";
|
||||
requestedNTE: number;
|
||||
currentNTE: number | null;
|
||||
onClose: () => void;
|
||||
|
|
@ -20,6 +20,13 @@ type UpliftDecisionDialogProps = {
|
|||
isPending?: boolean;
|
||||
};
|
||||
|
||||
function titleFor(type: UpliftDecisionDialogProps["type"], requestedNTE: number): string {
|
||||
if (type === "approve") {
|
||||
return `Approve uplift to $${requestedNTE.toFixed(2)}?`;
|
||||
}
|
||||
return type === "reject" ? "Reject Uplift" : "Request Changes";
|
||||
}
|
||||
|
||||
export function UpliftDecisionDialog({
|
||||
open,
|
||||
type,
|
||||
|
|
@ -30,6 +37,7 @@ export function UpliftDecisionDialog({
|
|||
isPending = false,
|
||||
}: UpliftDecisionDialogProps) {
|
||||
const [note, setNote] = useState("");
|
||||
const noteRequired = type === "reject" || type === "request-changes";
|
||||
|
||||
const handleClose = () => {
|
||||
if (isPending) return;
|
||||
|
|
@ -38,14 +46,13 @@ export function UpliftDecisionDialog({
|
|||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (noteRequired && !note.trim()) return;
|
||||
onConfirm(note.trim());
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={guardDialogClose(isPending, handleClose)}>
|
||||
<DialogTitle>
|
||||
{type === "approve" ? `Approve uplift to $${requestedNTE.toFixed(2)}?` : "Deny Uplift"}
|
||||
</DialogTitle>
|
||||
<DialogTitle>{titleFor(type, requestedNTE)}</DialogTitle>
|
||||
<DialogContent>
|
||||
{type === "approve" && (
|
||||
<Typography variant="body2" className="mt-1 mb-3">
|
||||
|
|
@ -58,7 +65,8 @@ export function UpliftDecisionDialog({
|
|||
multiline
|
||||
minRows={2}
|
||||
className="mt-2"
|
||||
label={type === "deny" ? "Denial reason (required)" : "Optional note"}
|
||||
label={noteRequired ? "Note (required)" : "Optional note"}
|
||||
required={noteRequired}
|
||||
value={note}
|
||||
onChange={(event) => setNote(event.target.value)}
|
||||
disabled={isPending}
|
||||
|
|
@ -69,8 +77,8 @@ export function UpliftDecisionDialog({
|
|||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant={type === "approve" ? "default" : "destructive"}
|
||||
disabled={type === "deny" ? !note.trim() : false}
|
||||
variant={type === "reject" ? "destructive" : "default"}
|
||||
disabled={noteRequired && !note.trim()}
|
||||
loading={isPending}
|
||||
loadingText="Confirming…"
|
||||
onClick={handleConfirm}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Ban, FileText, Image as ImageIcon } from "lucide-react";
|
||||
import type { CompletionDocStatus } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||
import { hasCompletionDocService } from "@/domain/work-orders/utils/completion-doc-service-gate";
|
||||
|
||||
function isMediaWO(type: WOType | ""): boolean {
|
||||
return type === "Emergency" || type === "Reactive";
|
||||
|
|
@ -9,6 +10,8 @@ function isMediaWO(type: WOType | ""): boolean {
|
|||
type CompDocCellProps = {
|
||||
status: CompletionDocStatus;
|
||||
woType: WOType | "";
|
||||
/** Primary service (`pm`). Required before CompDoc can open. */
|
||||
pm?: string;
|
||||
mediaCount?: number;
|
||||
/** Omit until completion-doc slice mounts CompDocDialog. */
|
||||
onOpen?: () => void;
|
||||
|
|
@ -18,6 +21,7 @@ type CompDocCellProps = {
|
|||
export function CompDocCell({
|
||||
status,
|
||||
woType,
|
||||
pm = "",
|
||||
mediaCount = 0,
|
||||
onOpen,
|
||||
onOpenMedia,
|
||||
|
|
@ -43,6 +47,20 @@ export function CompDocCell({
|
|||
return null;
|
||||
}
|
||||
|
||||
if (!hasCompletionDocService(pm)) {
|
||||
return (
|
||||
<span
|
||||
title="Select a service before opening the completion document"
|
||||
className="flex w-full items-center gap-1.5 whitespace-nowrap text-xs font-semibold"
|
||||
style={{ color: "var(--color-text-muted)", opacity: 0.65 }}
|
||||
aria-disabled="true"
|
||||
>
|
||||
<FileText size={15} className="shrink-0" style={{ color: "var(--color-text-muted)" }} />
|
||||
Select service
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const generated = status === "Yes";
|
||||
const notRequired = status === "NN";
|
||||
const color = generated ? "var(--color-success)" : "var(--color-text-muted)";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,56 @@
|
|||
import type { MouseEvent } from "react";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
|
||||
import { MiniCalendar } from "@/app/(protected)/workorders/_components/wizard/mini-calendar";
|
||||
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
||||
|
||||
type DueDateCalendarDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
value: string;
|
||||
onSave: (iso: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Emergency Due Date picker — dedicated calendar Dialog (no time, no week/range).
|
||||
* Reuses the shared MiniCalendar used by the standard Due Date popover.
|
||||
*/
|
||||
export function DueDateCalendarDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
value,
|
||||
onSave,
|
||||
}: DueDateCalendarDialogProps) {
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => onOpenChange(false)}
|
||||
maxWidth={false}
|
||||
slotProps={{
|
||||
...dialogBlurBackdropSlotProps,
|
||||
paper: {
|
||||
className: "w-full",
|
||||
style: { borderRadius: 12, maxWidth: 320 },
|
||||
onClick: (e: MouseEvent<HTMLDivElement>) => e.stopPropagation(),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogContent className="p-5" onClick={(e) => e.stopPropagation()}>
|
||||
<DialogTitle
|
||||
className="mb-3 p-0"
|
||||
style={{ fontSize: 16, color: "var(--color-text-primary)" }}
|
||||
>
|
||||
Due Date
|
||||
</DialogTitle>
|
||||
<div className="flex justify-center">
|
||||
<MiniCalendar
|
||||
value={value}
|
||||
onChange={(iso) => {
|
||||
onSave(iso);
|
||||
onOpenChange(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
import { useState } from "react";
|
||||
import { Calendar as CalendarIcon, ChevronDown } from "lucide-react";
|
||||
import { DateCell } from "@/app/(protected)/workorders/_components/list/table/cells/date-cell";
|
||||
import { DueDateCalendarDialog } from "@/app/(protected)/workorders/_components/list/table/cells/due-date-calendar-dialog";
|
||||
import { HighlightText } from "@/app/(protected)/workorders/_components/list/table/cells/highlight-text";
|
||||
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||
|
||||
type DueDateCellProps = {
|
||||
type: WOType | "";
|
||||
value: string;
|
||||
q?: string;
|
||||
highlight?: boolean;
|
||||
onSave: (date: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Board / slide-over Due Date editor.
|
||||
* Emergency → dedicated calendar Dialog; other types → standard DateCell popover.
|
||||
*/
|
||||
export function DueDateCell({ type, value, q, highlight, onSave }: DueDateCellProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
if (type !== "Emergency") {
|
||||
return <DateCell value={value} q={q} highlight={highlight} onSave={onSave} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpen(true);
|
||||
}}
|
||||
className="group/cell flex w-full items-center whitespace-nowrap rounded transition-opacity hover:opacity-80"
|
||||
style={
|
||||
highlight
|
||||
? {
|
||||
boxShadow: "0 0 0 2px var(--color-accent)",
|
||||
background: "var(--color-primary-tint)",
|
||||
padding: "2px 8px",
|
||||
margin: "0 -8px",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
aria-haspopup="dialog"
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden">
|
||||
<CalendarIcon
|
||||
size={11}
|
||||
className="shrink-0"
|
||||
style={{ color: highlight ? "var(--primary)" : "var(--color-text-muted)" }}
|
||||
/>
|
||||
{value ? (
|
||||
<span className="truncate text-[13px] text-[var(--color-text-secondary)]">
|
||||
<HighlightText text={value.replace(/^\d{4}-/, "")} q={q} />
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[13px] text-[var(--color-text-muted)]">—</span>
|
||||
)}
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className="ml-2 shrink-0 opacity-0 transition-opacity group-hover/cell:opacity-100"
|
||||
style={{ color: "var(--muted-foreground)" }}
|
||||
/>
|
||||
</button>
|
||||
<DueDateCalendarDialog open={open} onOpenChange={setOpen} value={value} onSave={onSave} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||
|
||||
/**
|
||||
* Board rows often have tech/company but no vendorId — resolve via vendors list.
|
||||
* Prefers a stable vendor id over name+company matching.
|
||||
*/
|
||||
export function resolveTechnicianKey(
|
||||
vendorId: string | number,
|
||||
tech: string,
|
||||
company: string,
|
||||
vendors: VendorDropdownItem[],
|
||||
): string {
|
||||
const idStr = vendorId !== "" && vendorId != null ? String(vendorId) : "";
|
||||
if (idStr && vendors.some((vendor) => String(vendor.id) === idStr)) {
|
||||
return idStr;
|
||||
}
|
||||
if (idStr && vendors.length === 0) {
|
||||
return idStr;
|
||||
}
|
||||
|
||||
const name = tech.trim();
|
||||
if (!name) {
|
||||
return idStr;
|
||||
}
|
||||
|
||||
const byNameAndCompany =
|
||||
vendors.find((vendor) => vendor.contactName === name && vendor.companyName === company) ??
|
||||
vendors.find((vendor) => vendor.contactName === name);
|
||||
|
||||
if (byNameAndCompany) {
|
||||
return String(byNameAndCompany.id);
|
||||
}
|
||||
|
||||
// Orphan tech string used as option key when no vendor row matches (legacy board cells).
|
||||
return idStr || name;
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||
|
||||
type ResolveVendorCompanyAnchorInput = {
|
||||
company: string;
|
||||
resolvedCompany: string;
|
||||
selectedVendor: VendorDropdownItem | undefined;
|
||||
vendors: VendorDropdownItem[];
|
||||
};
|
||||
|
||||
export function resolveVendorCompanyAnchor({
|
||||
company,
|
||||
resolvedCompany,
|
||||
selectedVendor,
|
||||
vendors,
|
||||
}: ResolveVendorCompanyAnchorInput): {
|
||||
companyName: string;
|
||||
companyVendor: VendorDropdownItem | null;
|
||||
} {
|
||||
const companyName = resolvedCompany || company;
|
||||
if (!companyName) {
|
||||
return { companyName: "", companyVendor: null };
|
||||
}
|
||||
|
||||
const companyVendor =
|
||||
selectedVendor ??
|
||||
vendors.find((vendor) => vendor.companyName === companyName) ??
|
||||
({
|
||||
id: "",
|
||||
companyName,
|
||||
contactName: "",
|
||||
tradeSpecialties: "",
|
||||
address: "",
|
||||
distanceMiles: null,
|
||||
} satisfies VendorDropdownItem);
|
||||
|
||||
return { companyName, companyVendor };
|
||||
}
|
||||
|
||||
export function mergeVendorDropdownItems(
|
||||
vendors: VendorDropdownItem[],
|
||||
addedVendors: VendorDropdownItem[],
|
||||
): VendorDropdownItem[] {
|
||||
const byId = new Map<string, VendorDropdownItem>();
|
||||
for (const vendor of vendors) {
|
||||
if (vendor.id === "") continue;
|
||||
byId.set(String(vendor.id), vendor);
|
||||
}
|
||||
for (const vendor of addedVendors) {
|
||||
if (vendor.id === "") continue;
|
||||
byId.set(String(vendor.id), vendor);
|
||||
}
|
||||
return Array.from(byId.values());
|
||||
}
|
||||
|
|
@ -49,7 +49,7 @@ export function StatusCell({
|
|||
|
||||
setAnchorEl(e.currentTarget);
|
||||
}}
|
||||
title="This WO missed its service window. Reschedule to unlock status updates."
|
||||
title="This WO missed its deadline. Update the Due Date to unlock status updates."
|
||||
className="group/cell flex w-full cursor-pointer items-center gap-1.5 whitespace-nowrap leading-none transition-opacity hover:opacity-80"
|
||||
style={{ fontSize: 12.5, fontWeight: 600, color: "var(--status-pastdue-text)" }}
|
||||
>
|
||||
|
|
@ -83,8 +83,7 @@ export function StatusCell({
|
|||
color: "var(--status-pending-text)",
|
||||
}}
|
||||
>
|
||||
This work order is past due. A new date must be scheduled before the status can be
|
||||
updated.
|
||||
This work order is past due. Update the Due Date before the status can be updated.
|
||||
</Text>
|
||||
|
||||
<button
|
||||
|
|
@ -98,7 +97,7 @@ export function StatusCell({
|
|||
}}
|
||||
className="mt-2 block text-[11px] font-medium text-[var(--color-primary)]"
|
||||
>
|
||||
Reschedule →
|
||||
Update Due Date →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,75 @@
|
|||
import { isDraftVendorId } from "@/domain/vendors/utils/draft-vendor";
|
||||
import { vendorToDropdownItem } from "@/domain/vendors/utils/vendor-to-dropdown-item";
|
||||
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
||||
import type { VendorPatch } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types";
|
||||
import type { useCreateVendor } from "@/domain/vendors/use-cases/use-create-vendor";
|
||||
|
||||
type UseVendorDialogSaveArgs = {
|
||||
allVendors: VendorDropdownItem[];
|
||||
pendingCreates: Record<string, VendorFormValues>;
|
||||
selectedKey: string;
|
||||
selectedVendor: VendorDropdownItem | undefined;
|
||||
resolvedCompany: string;
|
||||
anchorCompanyName: string;
|
||||
tech: string;
|
||||
phone: string;
|
||||
createVendor: ReturnType<typeof useCreateVendor>;
|
||||
onSave: (patch: VendorPatch) => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
setSaveError: (error: string | null) => void;
|
||||
};
|
||||
|
||||
export function useVendorDialogSave({
|
||||
allVendors,
|
||||
pendingCreates,
|
||||
selectedKey,
|
||||
selectedVendor,
|
||||
resolvedCompany,
|
||||
anchorCompanyName,
|
||||
tech,
|
||||
phone,
|
||||
createVendor,
|
||||
onSave,
|
||||
onOpenChange,
|
||||
setSaveError,
|
||||
}: UseVendorDialogSaveArgs) {
|
||||
const resolveSaveVendor = () =>
|
||||
allVendors.find((vendor) => String(vendor.id) === selectedKey) ??
|
||||
allVendors.find(
|
||||
(vendor) =>
|
||||
vendor.contactName === tech &&
|
||||
vendor.companyName === (resolvedCompany || anchorCompanyName),
|
||||
) ??
|
||||
selectedVendor;
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaveError(null);
|
||||
let saveVendor = resolveSaveVendor();
|
||||
const pendingPayload = saveVendor ? pendingCreates[String(saveVendor.id)] : undefined;
|
||||
|
||||
if (saveVendor != null && isDraftVendorId(saveVendor.id) && pendingPayload) {
|
||||
try {
|
||||
const created = await createVendor.mutateAsync({
|
||||
...pendingPayload,
|
||||
phone: phone.trim() || pendingPayload.phone,
|
||||
});
|
||||
saveVendor = vendorToDropdownItem(created);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to create technician";
|
||||
setSaveError(message);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
onSave({
|
||||
vendorId: saveVendor ? String(saveVendor.id) : "",
|
||||
company: resolvedCompany || anchorCompanyName,
|
||||
tech: saveVendor?.contactName ?? tech,
|
||||
techPhone: phone,
|
||||
});
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return { handleSave };
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import { HoverChevron } from "./hover-chevron";
|
|||
import { VendorDialog, type VendorPatch } from "./vendor-dialog";
|
||||
|
||||
type VendorCellProps = {
|
||||
vendorId?: string | number;
|
||||
company: string;
|
||||
tech: string;
|
||||
techPhone?: string;
|
||||
|
|
@ -13,7 +14,15 @@ type VendorCellProps = {
|
|||
onSave: (patch: VendorPatch) => void;
|
||||
};
|
||||
|
||||
export function VendorCell({ company, tech, techPhone, vendors, q, onSave }: VendorCellProps) {
|
||||
export function VendorCell({
|
||||
vendorId = "",
|
||||
company,
|
||||
tech,
|
||||
techPhone,
|
||||
vendors,
|
||||
q,
|
||||
onSave,
|
||||
}: VendorCellProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const hasAny = !!(tech || company);
|
||||
|
||||
|
|
@ -46,6 +55,7 @@ export function VendorCell({ company, tech, techPhone, vendors, q, onSave }: Ven
|
|||
<VendorDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
vendorId={vendorId}
|
||||
company={company}
|
||||
tech={tech}
|
||||
techPhone={techPhone}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,134 @@
|
|||
import { useState } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import {
|
||||
DLG_INPUT_CLS,
|
||||
DLG_INPUT_STYLE,
|
||||
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||
import { vendorSchema, type VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
||||
import { buildTechnicianCreatePayload } from "@/domain/vendors/utils/build-technician-create-payload";
|
||||
import { createDraftVendorDropdownItem } from "@/domain/vendors/utils/draft-vendor";
|
||||
import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label";
|
||||
|
||||
type VendorDialogAddTechnicianProps = {
|
||||
companyName: string;
|
||||
companyVendor: VendorDropdownItem;
|
||||
onCreated: (vendor: VendorDropdownItem, phone: string, payload: VendorFormValues) => void;
|
||||
};
|
||||
|
||||
export function VendorDialogAddTechnician({
|
||||
companyName,
|
||||
companyVendor,
|
||||
onCreated,
|
||||
}: VendorDialogAddTechnicianProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [contactName, setContactName] = useState("");
|
||||
const [phone, setPhone] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const resetForm = () => {
|
||||
setContactName("");
|
||||
setPhone("");
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
const payload = buildTechnicianCreatePayload({
|
||||
companyVendor,
|
||||
contactName,
|
||||
phone,
|
||||
});
|
||||
const parsed = vendorSchema.safeParse(payload);
|
||||
if (!parsed.success) {
|
||||
const fieldErrors = parsed.error.flatten().fieldErrors;
|
||||
const message =
|
||||
fieldErrors.contactName?.[0] ??
|
||||
fieldErrors.phone?.[0] ??
|
||||
fieldErrors.companyName?.[0] ??
|
||||
"Enter a valid technician name";
|
||||
setError(message);
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
const draftItem = createDraftVendorDropdownItem(parsed.data);
|
||||
onCreated(draftItem, parsed.data.phone || phone.trim(), parsed.data);
|
||||
resetForm();
|
||||
setExpanded(false);
|
||||
};
|
||||
|
||||
if (!expanded) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(true)}
|
||||
className="flex items-center gap-1.5 text-[12px] font-medium transition-opacity hover:opacity-80"
|
||||
style={{ color: "var(--color-primary)" }}
|
||||
>
|
||||
<Plus size={12} className="shrink-0" aria-hidden />+ Add technician
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="space-y-2 rounded-lg p-3"
|
||||
style={{
|
||||
background: "var(--color-bg-muted)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
<Text as="p" variant="caption" sx={{ fontSize: 12, color: "var(--muted-foreground)" }}>
|
||||
New technician for {companyName}
|
||||
</Text>
|
||||
<div>
|
||||
<FieldLabel>Technician Name</FieldLabel>
|
||||
<input
|
||||
autoFocus
|
||||
value={contactName}
|
||||
onChange={(e) => setContactName(e.target.value)}
|
||||
placeholder="Full name"
|
||||
className={DLG_INPUT_CLS}
|
||||
style={DLG_INPUT_STYLE}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>Technician Phone</FieldLabel>
|
||||
<input
|
||||
type="tel"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
placeholder="(000) 000-0000"
|
||||
className={DLG_INPUT_CLS}
|
||||
style={DLG_INPUT_STYLE}
|
||||
/>
|
||||
</div>
|
||||
{Boolean(error) && (
|
||||
<Text as="p" variant="caption" sx={{ fontSize: 11.5, color: "var(--color-destructive)" }}>
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-2 pt-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="min-h-0 px-2 py-1 text-[12px]"
|
||||
onClick={() => {
|
||||
resetForm();
|
||||
setExpanded(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="min-h-0 px-3 py-1 text-[12px]"
|
||||
disabled={contactName.trim() === ""}
|
||||
onClick={handleAdd}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
import { Wrench } from "lucide-react";
|
||||
import {
|
||||
DLG_INPUT_CLS,
|
||||
DLG_INPUT_STYLE,
|
||||
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
|
||||
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||
import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label";
|
||||
import { VendorDialogAddTechnician } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-add-technician";
|
||||
import { VendorDialogCompanyCard } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-company-card";
|
||||
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
||||
import type { TechnicianOption } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-options";
|
||||
|
||||
type VendorDialogBodyProps = {
|
||||
selectedKey: string;
|
||||
technicianOptions: TechnicianOption[];
|
||||
anchorCompanyName: string;
|
||||
companyVendorForAdd: VendorDropdownItem | undefined;
|
||||
tech: string;
|
||||
phone: string;
|
||||
notes: string;
|
||||
resolvedCompany: string;
|
||||
selectedVendor: VendorDropdownItem | undefined;
|
||||
saveError: string | null;
|
||||
onPick: (key: string) => void;
|
||||
onPhoneChange: (phone: string) => void;
|
||||
onNotesChange: (notes: string) => void;
|
||||
onTechnicianCreated: (
|
||||
created: VendorDropdownItem,
|
||||
createdPhone: string,
|
||||
payload: VendorFormValues,
|
||||
) => void;
|
||||
};
|
||||
|
||||
export function VendorDialogBody({
|
||||
selectedKey,
|
||||
technicianOptions,
|
||||
anchorCompanyName,
|
||||
companyVendorForAdd,
|
||||
tech,
|
||||
phone,
|
||||
notes,
|
||||
resolvedCompany,
|
||||
selectedVendor,
|
||||
saveError,
|
||||
onPick,
|
||||
onPhoneChange,
|
||||
onNotesChange,
|
||||
onTechnicianCreated,
|
||||
}: VendorDialogBodyProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="mt-3 space-y-3">
|
||||
<div>
|
||||
<FieldLabel>Technician</FieldLabel>
|
||||
<WizardFieldSelect
|
||||
value={selectedKey}
|
||||
placeholder="Search technician …"
|
||||
icon={
|
||||
<Wrench size={14} className="shrink-0" style={{ color: "var(--color-text-muted)" }} />
|
||||
}
|
||||
searchPlaceholder="Search technician…"
|
||||
options={technicianOptions.map((option) => ({
|
||||
key: option.key,
|
||||
label: option.label,
|
||||
}))}
|
||||
onPick={onPick}
|
||||
/>
|
||||
{companyVendorForAdd != null && (
|
||||
<div className="mt-2">
|
||||
<VendorDialogAddTechnician
|
||||
companyName={anchorCompanyName}
|
||||
companyVendor={companyVendorForAdd}
|
||||
onCreated={onTechnicianCreated}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{Boolean(tech) && (
|
||||
<div>
|
||||
<FieldLabel>Technician Phone</FieldLabel>
|
||||
<input
|
||||
type="tel"
|
||||
value={phone}
|
||||
onChange={(e) => onPhoneChange(e.target.value)}
|
||||
className={DLG_INPUT_CLS}
|
||||
style={DLG_INPUT_STYLE}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{Boolean(resolvedCompany) && (
|
||||
<div>
|
||||
<FieldLabel>Company</FieldLabel>
|
||||
<VendorDialogCompanyCard company={resolvedCompany} vendor={selectedVendor} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<FieldLabel>Notes</FieldLabel>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => onNotesChange(e.target.value)}
|
||||
placeholder="Contact preferences, availability, etc."
|
||||
rows={3}
|
||||
className={`${DLG_INPUT_CLS} resize-none`}
|
||||
style={{ ...DLG_INPUT_STYLE, fontFamily: "inherit" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{Boolean(saveError) && (
|
||||
<Text as="p" variant="caption" sx={{ fontSize: 11.5, color: "var(--color-destructive)" }}>
|
||||
{saveError}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type VendorDialogFooterProps = {
|
||||
isPending: boolean;
|
||||
onCancel: () => void;
|
||||
onSave: () => void;
|
||||
};
|
||||
|
||||
export function VendorDialogFooter({ isPending, onCancel, onSave }: VendorDialogFooterProps) {
|
||||
return (
|
||||
<div className="mt-4 flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="min-h-0 px-2 py-1 text-[12px]"
|
||||
disabled={isPending}
|
||||
onClick={onCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="min-h-0 px-3 py-1 text-[12px]"
|
||||
disabled={isPending}
|
||||
onClick={() => void onSave()}
|
||||
>
|
||||
{isPending ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export type VendorPatch = {
|
||||
vendorId: string;
|
||||
company: string;
|
||||
tech: string;
|
||||
techPhone: string;
|
||||
};
|
||||
|
|
@ -1,32 +1,30 @@
|
|||
import { useEffect, useMemo, useState, type MouseEvent } from "react";
|
||||
import { Wrench } from "lucide-react";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
|
||||
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
|
||||
import {
|
||||
DLG_INPUT_CLS,
|
||||
DLG_INPUT_STYLE,
|
||||
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
||||
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||
import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label";
|
||||
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
||||
import { useCreateVendor } from "@/domain/vendors/use-cases/use-create-vendor";
|
||||
import {
|
||||
buildTechnicianOptions,
|
||||
phoneForOption,
|
||||
} from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-options";
|
||||
import { VendorDialogCompanyCard } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-company-card";
|
||||
import { VendorDialogBody } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-body";
|
||||
import { VendorDialogFooter } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-footer";
|
||||
import {
|
||||
mergeVendorDropdownItems,
|
||||
resolveVendorCompanyAnchor,
|
||||
} from "@/app/(protected)/workorders/_components/list/table/cells/resolve-vendor-company-anchor";
|
||||
import { resolveTechnicianKey } from "@/app/(protected)/workorders/_components/list/table/cells/resolve-technician-key";
|
||||
import { useVendorDialogSave } from "@/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-save";
|
||||
import type { VendorPatch } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types";
|
||||
|
||||
export type VendorPatch = {
|
||||
vendorId: string;
|
||||
company: string;
|
||||
tech: string;
|
||||
techPhone: string;
|
||||
};
|
||||
export type { VendorPatch } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types";
|
||||
|
||||
type VendorDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
vendorId?: string | number;
|
||||
company: string;
|
||||
tech: string;
|
||||
techPhone?: string;
|
||||
|
|
@ -37,43 +35,94 @@ type VendorDialogProps = {
|
|||
export function VendorDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
vendorId = "",
|
||||
company,
|
||||
tech,
|
||||
techPhone = "",
|
||||
vendors,
|
||||
onSave,
|
||||
}: VendorDialogProps) {
|
||||
const createVendor = useCreateVendor();
|
||||
const [selectedKey, setSelectedKey] = useState(() =>
|
||||
resolveTechnicianKey(vendorId, tech, company, vendors),
|
||||
);
|
||||
const [t, setT] = useState(tech);
|
||||
const [phone, setPhone] = useState(techPhone);
|
||||
const [notes, setNotes] = useState("");
|
||||
const [addedVendors, setAddedVendors] = useState<VendorDropdownItem[]>([]);
|
||||
const [pendingCreates, setPendingCreates] = useState<Record<string, VendorFormValues>>({});
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
const allVendors = useMemo(
|
||||
() => mergeVendorDropdownItems(vendors, addedVendors),
|
||||
[vendors, addedVendors],
|
||||
);
|
||||
|
||||
const technicianOptions = useMemo(
|
||||
() => buildTechnicianOptions(vendors, tech, company),
|
||||
[vendors, tech, company],
|
||||
() => buildTechnicianOptions(allVendors, tech, company),
|
||||
[allVendors, tech, company],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const key = resolveTechnicianKey(vendorId, tech, company, vendors);
|
||||
setSelectedKey(key);
|
||||
setT(tech);
|
||||
setPhone(techPhone || phoneForOption(tech, technicianOptions));
|
||||
setPhone(techPhone || phoneForOption(tech, buildTechnicianOptions(vendors, tech, company)));
|
||||
setNotes("");
|
||||
setAddedVendors([]);
|
||||
setPendingCreates({});
|
||||
setSaveError(null);
|
||||
}
|
||||
}, [open, tech, techPhone, technicianOptions]);
|
||||
// Intentionally omit technicianOptions: rebuilding options after Add must not clear staged drafts.
|
||||
}, [open, tech, techPhone, vendors, company, vendorId]);
|
||||
|
||||
const selected =
|
||||
technicianOptions.find((o) => o.name === t && (!company || o.company === company)) ??
|
||||
technicianOptions.find((o) => o.name === t || o.key === t);
|
||||
const selectedKey = selected?.key ?? "";
|
||||
const resolvedCompany = t ? (selected?.company ?? company) : "";
|
||||
const selectedVendor = selected?.vendor ?? vendors.find((v) => v.companyName === resolvedCompany);
|
||||
const selected = technicianOptions.find((option) => option.key === selectedKey);
|
||||
const resolvedCompany = selectedKey ? (selected?.company ?? company) : company;
|
||||
const selectedVendor =
|
||||
selected?.vendor ?? allVendors.find((vendor) => String(vendor.id) === selectedKey);
|
||||
|
||||
const { companyName: anchorCompanyName, companyVendor } = resolveVendorCompanyAnchor({
|
||||
company,
|
||||
resolvedCompany,
|
||||
selectedVendor,
|
||||
vendors: allVendors,
|
||||
});
|
||||
|
||||
const handlePick = (key: string) => {
|
||||
const option = technicianOptions.find((o) => o.key === key);
|
||||
const name = option?.name ?? key;
|
||||
setT(name);
|
||||
setPhone(phoneForOption(name, technicianOptions));
|
||||
setSelectedKey(key);
|
||||
setT(option?.name ?? key);
|
||||
setPhone(option?.phone || phoneForOption(option?.name ?? key, technicianOptions));
|
||||
};
|
||||
|
||||
const handleTechnicianCreated = (
|
||||
created: VendorDropdownItem,
|
||||
createdPhone: string,
|
||||
payload: VendorFormValues,
|
||||
) => {
|
||||
setAddedVendors((current) => [...current, created]);
|
||||
setPendingCreates((current) => ({ ...current, [String(created.id)]: payload }));
|
||||
setSelectedKey(String(created.id));
|
||||
setT(created.contactName);
|
||||
setPhone(createdPhone);
|
||||
};
|
||||
|
||||
const { handleSave } = useVendorDialogSave({
|
||||
allVendors,
|
||||
pendingCreates,
|
||||
selectedKey,
|
||||
selectedVendor,
|
||||
resolvedCompany,
|
||||
anchorCompanyName,
|
||||
tech: selected?.name ?? t,
|
||||
phone,
|
||||
createVendor,
|
||||
onSave,
|
||||
onOpenChange,
|
||||
setSaveError,
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
|
|
@ -96,84 +145,28 @@ export function VendorDialog({
|
|||
Search and select the technician
|
||||
</Text>
|
||||
|
||||
<div className="mt-3 space-y-3">
|
||||
<div>
|
||||
<FieldLabel>Technician</FieldLabel>
|
||||
<WizardFieldSelect
|
||||
value={selectedKey}
|
||||
placeholder="Search technician …"
|
||||
icon={
|
||||
<Wrench
|
||||
size={14}
|
||||
className="shrink-0"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
/>
|
||||
}
|
||||
searchPlaceholder="Search technician…"
|
||||
options={technicianOptions.map((option) => ({
|
||||
key: option.key,
|
||||
label: option.label,
|
||||
}))}
|
||||
onPick={handlePick}
|
||||
/>
|
||||
</div>
|
||||
<VendorDialogBody
|
||||
selectedKey={selectedKey}
|
||||
technicianOptions={technicianOptions}
|
||||
anchorCompanyName={anchorCompanyName}
|
||||
companyVendorForAdd={companyVendor ?? undefined}
|
||||
tech={selected?.name ?? t}
|
||||
phone={phone}
|
||||
notes={notes}
|
||||
resolvedCompany={resolvedCompany}
|
||||
selectedVendor={selectedVendor}
|
||||
saveError={saveError}
|
||||
onPick={handlePick}
|
||||
onPhoneChange={setPhone}
|
||||
onNotesChange={setNotes}
|
||||
onTechnicianCreated={handleTechnicianCreated}
|
||||
/>
|
||||
|
||||
{Boolean(t) && (
|
||||
<div>
|
||||
<FieldLabel>Technician Phone</FieldLabel>
|
||||
<input
|
||||
type="tel"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
className={DLG_INPUT_CLS}
|
||||
style={DLG_INPUT_STYLE}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{Boolean(t) && (
|
||||
<div>
|
||||
<FieldLabel>Company</FieldLabel>
|
||||
<VendorDialogCompanyCard company={resolvedCompany} vendor={selectedVendor} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<FieldLabel>Notes</FieldLabel>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Contact preferences, availability, etc."
|
||||
rows={3}
|
||||
className={`${DLG_INPUT_CLS} resize-none`}
|
||||
style={{ ...DLG_INPUT_STYLE, fontFamily: "inherit" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="min-h-0 px-2 py-1 text-[12px]"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="min-h-0 px-3 py-1 text-[12px]"
|
||||
onClick={() => {
|
||||
onSave({
|
||||
vendorId: selectedVendor ? String(selectedVendor.id) : "",
|
||||
company: resolvedCompany,
|
||||
tech: t,
|
||||
techPhone: phone,
|
||||
});
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
<VendorDialogFooter
|
||||
isPending={createVendor.isPending}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import type { WorkOrderTableDraft } from "@/domain/work-orders/types/work-order-
|
|||
import { AssignedToCell } from "./cells/assigned-to-cell";
|
||||
import { ApptTimeCell } from "./cells/appt-time-cell";
|
||||
import { DateCell } from "./cells/date-cell";
|
||||
import { DueDateCell } from "./cells/due-date-cell";
|
||||
import { EditableWONumberCell } from "./cells/editable-wo-number-cell";
|
||||
import { PMTypeCell } from "./cells/pm-type-cell";
|
||||
import { SiteCell } from "./cells/site-cell";
|
||||
|
|
@ -106,7 +107,11 @@ export function WoTableInlineRowCells({
|
|||
</td>
|
||||
|
||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||
<DateCell value={draft.dueDate} onSave={(date) => onPatch({ dueDate: date })} />
|
||||
<DueDateCell
|
||||
type={draft.type}
|
||||
value={draft.dueDate}
|
||||
onSave={(date) => onPatch({ dueDate: date })}
|
||||
/>
|
||||
</td>
|
||||
|
||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||
|
|
@ -129,6 +134,7 @@ export function WoTableInlineRowCells({
|
|||
|
||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||
<VendorCell
|
||||
vendorId={draft.vendorId}
|
||||
company={draft.company}
|
||||
tech={draft.tech}
|
||||
techPhone={draft.techPhone}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { LocationOption, WorkOrderUser } from "@/domain/work-orders/types/w
|
|||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { AssignedToCell } from "./cells/assigned-to-cell";
|
||||
import { DateCell } from "./cells/date-cell";
|
||||
import { DueDateCell } from "./cells/due-date-cell";
|
||||
import { EditableWONumberCell } from "./cells/editable-wo-number-cell";
|
||||
import { SiteCell } from "./cells/site-cell";
|
||||
import { TypeCell } from "./cells/type-cell";
|
||||
|
|
@ -16,6 +17,7 @@ type WoTableRowCellsProps = {
|
|||
sitePlace?: { city: string; state: string };
|
||||
search?: string;
|
||||
forceScheduleHighlight?: boolean;
|
||||
forceDueDateHighlight?: boolean;
|
||||
mutationCellStyle?: CSSProperties;
|
||||
handlers: WoTableRowHandlers;
|
||||
onPatchRow: (patch: Partial<WorkOrderTableRow>) => void;
|
||||
|
|
@ -29,6 +31,7 @@ export function WoTableRowCells({
|
|||
sitePlace,
|
||||
search,
|
||||
forceScheduleHighlight,
|
||||
forceDueDateHighlight,
|
||||
mutationCellStyle,
|
||||
handlers,
|
||||
onPatchRow,
|
||||
|
|
@ -104,7 +107,7 @@ export function WoTableRowCells({
|
|||
<DateCell
|
||||
value={row.scheduledOn}
|
||||
q={search}
|
||||
highlight={forceScheduleHighlight || row.isPastDue}
|
||||
highlight={forceScheduleHighlight}
|
||||
allowWeekMode
|
||||
targetWeek={row.targetWeek}
|
||||
onSave={(date) => handlers.onScheduleDate(row.id, date)}
|
||||
|
|
@ -116,7 +119,13 @@ export function WoTableRowCells({
|
|||
style={{ padding: "0 14px", ...mutationCellStyle }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DateCell value={row.dueDate} q={search} onSave={(date) => onPatchRow({ dueDate: date })} />
|
||||
<DueDateCell
|
||||
type={row.type}
|
||||
value={row.dueDate}
|
||||
q={search}
|
||||
highlight={forceDueDateHighlight || row.isPastDue}
|
||||
onSave={(date) => onPatchRow({ dueDate: date })}
|
||||
/>
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import type { CSSProperties } from "react";
|
||||
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { ApptTimeCell } from "./cells/appt-time-cell";
|
||||
|
|
@ -12,7 +13,7 @@ type WoTableRowServiceCellsProps = {
|
|||
vendors: VendorDropdownItem[];
|
||||
pmTypes: string[];
|
||||
search?: string;
|
||||
mutationCellStyle?: React.CSSProperties;
|
||||
mutationCellStyle?: CSSProperties;
|
||||
handlers: WoTableRowHandlers;
|
||||
onPatchRow: (patch: Partial<WorkOrderTableRow>) => void;
|
||||
};
|
||||
|
|
@ -56,6 +57,7 @@ export function WoTableRowServiceCells({
|
|||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<VendorCell
|
||||
vendorId={row.vendorId}
|
||||
company={row.company}
|
||||
tech={row.tech}
|
||||
techPhone={row.techPhone}
|
||||
|
|
@ -94,6 +96,7 @@ export function WoTableRowServiceCells({
|
|||
<CompDocCell
|
||||
status={row.docStatus}
|
||||
woType={row.type}
|
||||
pm={row.pm}
|
||||
mediaCount={row.mediaCount}
|
||||
onOpen={handlers.onOpenCompDoc ? () => handlers.onOpenCompDoc!(row) : undefined}
|
||||
onOpenMedia={() => handlers.onOpenMedia(row)}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ type WoTableRowProps = {
|
|||
pmTypes: string[];
|
||||
search?: string;
|
||||
forceScheduleHighlight?: boolean;
|
||||
forceDueDateHighlight?: boolean;
|
||||
isSelected: boolean;
|
||||
isFlashing: boolean;
|
||||
dragViz: DragVizState;
|
||||
|
|
@ -61,6 +62,7 @@ export function WoTableRow({
|
|||
pmTypes,
|
||||
search,
|
||||
forceScheduleHighlight,
|
||||
forceDueDateHighlight,
|
||||
isSelected,
|
||||
isFlashing,
|
||||
dragViz,
|
||||
|
|
@ -154,6 +156,7 @@ export function WoTableRow({
|
|||
sitePlace={sitePlace}
|
||||
search={search}
|
||||
forceScheduleHighlight={forceScheduleHighlight}
|
||||
forceDueDateHighlight={forceDueDateHighlight}
|
||||
mutationCellStyle={mutationCellStyle}
|
||||
handlers={handlers}
|
||||
onPatchRow={patchRow}
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ export function WoTable({
|
|||
onOpenAdvancedFilters,
|
||||
onPatch,
|
||||
}: WoTableProps) {
|
||||
const [scheduleHighlightId, setScheduleHighlightId] = useState<string | number | null>(null);
|
||||
const [dueDateHighlightId, setDueDateHighlightId] = useState<string | number | null>(null);
|
||||
const { data: locationSites = [] } = useLocationSites();
|
||||
const sitePlaceLookups = useMemo(() => buildSitePlaceLookups(locationSites), [locationSites]);
|
||||
const createMutation = useCreateWorkOrderFromWizard(() => {
|
||||
|
|
@ -69,7 +69,7 @@ export function WoTable({
|
|||
onOpenCompDoc,
|
||||
onOpenMedia,
|
||||
onPatch,
|
||||
onScheduleHighlight: setScheduleHighlightId,
|
||||
onScheduleHighlight: setDueDateHighlightId,
|
||||
});
|
||||
|
||||
const renderRow = (row: WorkOrderTableRow, forceScheduleHighlight = false) => (
|
||||
|
|
@ -83,7 +83,8 @@ export function WoTable({
|
|||
vendors={vendors}
|
||||
pmTypes={pmTypes}
|
||||
search={tableData.search}
|
||||
forceScheduleHighlight={forceScheduleHighlight || scheduleHighlightId === row.id}
|
||||
forceScheduleHighlight={forceScheduleHighlight}
|
||||
forceDueDateHighlight={dueDateHighlightId === row.id}
|
||||
isSelected={tableData.selectedId === row.id}
|
||||
isFlashing={tableData.flashId === row.id}
|
||||
dragViz={tableData.dragViz}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
import { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog";
|
||||
import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal";
|
||||
import { WorkOrdersAdvancedSearchSheet } from "@/app/(protected)/workorders/_components/list/advanced-search/work-orders-advanced-search-sheet";
|
||||
import { NewWoWizard } from "@/app/(protected)/workorders/_components/wizard/new-wo-wizard";
|
||||
import type { CompDocData, CompDocSaveStatus } from "@/domain/work-orders/types/completion-doc";
|
||||
import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters";
|
||||
import type { LocationOption } from "@/domain/work-orders/types/work-order-common";
|
||||
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order-list";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
|
||||
type WorkOrdersListOverlaysProps = {
|
||||
wizardOpen: boolean;
|
||||
onWizardOpenChange: (open: boolean) => void;
|
||||
users: WorkOrderUser[];
|
||||
sites: LocationOption[];
|
||||
pmTypes: string[];
|
||||
vendorTechs: string[];
|
||||
advOpen: boolean;
|
||||
onAdvOpenChange: (open: boolean) => void;
|
||||
advInitial: WorkOrderAdvancedFilters;
|
||||
onApplyAdvanced: (next: WorkOrderAdvancedFilters) => void;
|
||||
onClearAdvanced: () => void;
|
||||
activeDocRow: WorkOrderTableRow | null;
|
||||
onDocOpenChange: (open: boolean) => void;
|
||||
completionDocs: Record<string, CompDocData>;
|
||||
onSaveCompDoc: (data: CompDocData, status: CompDocSaveStatus) => void | Promise<void>;
|
||||
onDateCompletedChange: (date: string) => void | Promise<void>;
|
||||
activeMediaRow: WorkOrderTableRow | null;
|
||||
onMediaOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export function WorkOrdersListOverlays({
|
||||
wizardOpen,
|
||||
onWizardOpenChange,
|
||||
users,
|
||||
sites,
|
||||
pmTypes,
|
||||
vendorTechs,
|
||||
advOpen,
|
||||
onAdvOpenChange,
|
||||
advInitial,
|
||||
onApplyAdvanced,
|
||||
onClearAdvanced,
|
||||
activeDocRow,
|
||||
onDocOpenChange,
|
||||
completionDocs,
|
||||
onSaveCompDoc,
|
||||
onDateCompletedChange,
|
||||
activeMediaRow,
|
||||
onMediaOpenChange,
|
||||
}: WorkOrdersListOverlaysProps) {
|
||||
return (
|
||||
<>
|
||||
<WorkOrdersAdvancedSearchSheet
|
||||
open={advOpen}
|
||||
onOpenChange={onAdvOpenChange}
|
||||
initial={advInitial}
|
||||
onApply={onApplyAdvanced}
|
||||
onClearAll={onClearAdvanced}
|
||||
sites={sites}
|
||||
users={users}
|
||||
pmTypes={pmTypes}
|
||||
vendorTechs={vendorTechs}
|
||||
/>
|
||||
|
||||
<NewWoWizard open={wizardOpen} onOpenChange={onWizardOpenChange} users={users} />
|
||||
|
||||
{activeDocRow != null && (
|
||||
<CompDocDialog
|
||||
row={activeDocRow}
|
||||
open={!!activeDocRow}
|
||||
onOpenChange={onDocOpenChange}
|
||||
data={completionDocs[String(activeDocRow.id)]}
|
||||
// docStatus + completedDate persist via board PATCH (not legacy EditWorkorder).
|
||||
// Notes/generatedAt stay client-local (no content endpoint on this branch).
|
||||
onSave={onSaveCompDoc}
|
||||
onDateCompletedChange={onDateCompletedChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeMediaRow != null && (
|
||||
<MediaUploadModal
|
||||
row={activeMediaRow}
|
||||
open={!!activeMediaRow}
|
||||
onOpenChange={onMediaOpenChange}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { type MouseEvent } from "react";
|
||||
import { Dialog, DialogContent, DialogTitle, Skeleton, Stack } from "@mui/material";
|
||||
import { Image as ImageIcon } from "lucide-react";
|
||||
import { toast } from "react-toastify";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
||||
import { LoadingRegion } from "@/components/ui/loading-region";
|
||||
|
|
@ -23,6 +24,14 @@ type MediaUploadModalProps = {
|
|||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
function resolveWorkOrderVersion(
|
||||
detailRowVersion: string | null | undefined,
|
||||
rowVersion: string | null | undefined,
|
||||
): string | null {
|
||||
const version = detailRowVersion?.trim() || rowVersion?.trim() || "";
|
||||
return version || null;
|
||||
}
|
||||
|
||||
export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalProps) {
|
||||
const { data: detail, isLoading } = useWorkOrderBoardDetail(row.id, open);
|
||||
const uploadMutation = useUploadWorkOrderMedia(row.id);
|
||||
|
|
@ -36,6 +45,15 @@ export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalPr
|
|||
uploadMutation.isPending || deleteMutation.isPending || categorizeMutation.isPending;
|
||||
const handleClose = guardDialogClose(pending, () => onOpenChange(false));
|
||||
|
||||
const requireVersion = (): string | null => {
|
||||
const version = resolveWorkOrderVersion(detail?.info.rowVersion, row.rowVersion);
|
||||
if (!version) {
|
||||
toast.error("Missing work order version. Refresh and try again.");
|
||||
return null;
|
||||
}
|
||||
return version;
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
|
|
@ -79,9 +97,15 @@ export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalPr
|
|||
removeLocal(id);
|
||||
return;
|
||||
}
|
||||
deleteMutation.mutate(id);
|
||||
const workOrderVersion = requireVersion();
|
||||
if (!workOrderVersion) return;
|
||||
deleteMutation.mutate({ mediaId: id, workOrderVersion });
|
||||
}}
|
||||
onCategorize={(id, category) => {
|
||||
const workOrderVersion = requireVersion();
|
||||
if (!workOrderVersion) return;
|
||||
categorizeMutation.mutate({ mediaId: id, category, workOrderVersion });
|
||||
}}
|
||||
onCategorize={(id, category) => categorizeMutation.mutate({ mediaId: id, category })}
|
||||
onRetry={retryUpload}
|
||||
onCancel={cancelUpload}
|
||||
readOnly={isReadOnly}
|
||||
|
|
|
|||
|
|
@ -1,42 +1,56 @@
|
|||
import { Box, Button, Paper, Tab, Tabs, TextField, Typography } from "@mui/material";
|
||||
import type { WorkOrderAuditEntry, WorkOrderComment } from "@/domain/work-orders/types/work-order";
|
||||
import {
|
||||
AuditTimeline,
|
||||
CommentTimeline,
|
||||
} from "@/app/(protected)/workorders/_components/view/work-order-timelines";
|
||||
import type { WorkOrderActivityTab } from "@/app/(protected)/workorders/_hooks/use-work-order-view-controller";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Box, Button, Paper, Tab, Tabs } from "@mui/material";
|
||||
import { CommentMentionComposer } from "@/components/common/comment-mention-composer";
|
||||
import { CommentTimeline } from "@/components/common/comment-timeline";
|
||||
import { mapWorkOrderCommentsToTimeline } from "@/components/common/comment-timeline-mappers";
|
||||
import type {
|
||||
WorkOrderAuditEntry,
|
||||
WorkOrderComment,
|
||||
} from "@/domain/work-orders/types/work-order-detail";
|
||||
import { WorkOrderAuditTimeline } from "@/app/(protected)/workorders/_components/view/work-order-audit-timeline";
|
||||
import { Text } from "@/components/ui/text";
|
||||
|
||||
interface WorkOrderActivityPanelProps {
|
||||
activeTab: WorkOrderActivityTab;
|
||||
onTabChange: (tab: WorkOrderActivityTab) => void;
|
||||
export type WorkOrderActivityTab = "customer" | "internal" | "audit";
|
||||
|
||||
type MentionUser = { id: string | number; name: string };
|
||||
|
||||
type WorkOrderActivityPanelProps = {
|
||||
customerComments: WorkOrderComment[];
|
||||
internalComments: WorkOrderComment[];
|
||||
auditLog: WorkOrderAuditEntry[];
|
||||
commentText: string;
|
||||
onCommentTextChange: (text: string) => void;
|
||||
isSubmittingComment: boolean;
|
||||
onSubmitComment: (event: React.FormEvent) => void;
|
||||
}
|
||||
mentionUsers: MentionUser[];
|
||||
isSubmitting: boolean;
|
||||
onSubmitComment: (text: string) => void | Promise<void>;
|
||||
};
|
||||
|
||||
export function WorkOrderActivityPanel({
|
||||
activeTab,
|
||||
onTabChange,
|
||||
customerComments,
|
||||
internalComments,
|
||||
auditLog,
|
||||
commentText,
|
||||
onCommentTextChange,
|
||||
isSubmittingComment,
|
||||
mentionUsers,
|
||||
isSubmitting,
|
||||
onSubmitComment,
|
||||
}: WorkOrderActivityPanelProps) {
|
||||
const [activeTab, setActiveTab] = useState<WorkOrderActivityTab>("customer");
|
||||
const [commentText, setCommentText] = useState("");
|
||||
|
||||
const customerTimelineItems = useMemo(
|
||||
() => mapWorkOrderCommentsToTimeline(customerComments),
|
||||
[customerComments],
|
||||
);
|
||||
const internalTimelineItems = useMemo(
|
||||
() => mapWorkOrderCommentsToTimeline(internalComments),
|
||||
[internalComments],
|
||||
);
|
||||
|
||||
return (
|
||||
<Paper variant="outlined" className="p-4">
|
||||
<Typography variant="subtitle1" className="mb-3 font-semibold">
|
||||
<Text as="p" variant="heading" className="mb-3 font-semibold">
|
||||
Activity
|
||||
</Typography>
|
||||
</Text>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={(_event, value: WorkOrderActivityTab) => onTabChange(value)}
|
||||
onChange={(_event, value: WorkOrderActivityTab) => setActiveTab(value)}
|
||||
className="mb-3"
|
||||
>
|
||||
<Tab value="customer" label={`Customer (${customerComments.length})`} />
|
||||
|
|
@ -44,27 +58,47 @@ export function WorkOrderActivityPanel({
|
|||
<Tab value="audit" label={`Audit Log (${auditLog.length})`} />
|
||||
</Tabs>
|
||||
|
||||
{activeTab === "customer" && <CommentTimeline comments={customerComments} />}
|
||||
{activeTab === "internal" && <CommentTimeline comments={internalComments} />}
|
||||
{activeTab === "audit" && <AuditTimeline entries={auditLog} />}
|
||||
{activeTab === "customer" && (
|
||||
<CommentTimeline items={customerTimelineItems} mentionUsers={mentionUsers} />
|
||||
)}
|
||||
{activeTab === "internal" && (
|
||||
<CommentTimeline items={internalTimelineItems} mentionUsers={mentionUsers} />
|
||||
)}
|
||||
{activeTab === "audit" && <WorkOrderAuditTimeline entries={auditLog} />}
|
||||
|
||||
{activeTab === "internal" && (
|
||||
<Box component="form" onSubmit={onSubmitComment} className="mt-4 flex flex-col gap-2">
|
||||
<TextField
|
||||
multiline
|
||||
rows={2}
|
||||
<Box
|
||||
component="form"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
const trimmed = commentText.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
void (async () => {
|
||||
try {
|
||||
await onSubmitComment(trimmed);
|
||||
setCommentText("");
|
||||
} catch {
|
||||
// Mutation toast handles failure; keep the draft for retry.
|
||||
}
|
||||
})();
|
||||
}}
|
||||
className="mt-4 flex flex-col gap-2"
|
||||
>
|
||||
<CommentMentionComposer
|
||||
value={commentText}
|
||||
onChange={(event) => onCommentTextChange(event.target.value)}
|
||||
placeholder="Type a comment..."
|
||||
disabled={isSubmittingComment}
|
||||
onChange={setCommentText}
|
||||
candidates={mentionUsers}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
className="self-end"
|
||||
disabled={isSubmittingComment || !commentText.trim()}
|
||||
disabled={isSubmitting || !commentText.trim()}
|
||||
>
|
||||
{isSubmittingComment ? "Sending..." : "Send"}
|
||||
{isSubmitting ? "Sending..." : "Send"}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
import { useMemo } from "react";
|
||||
import { Box, Stack, Typography } from "@mui/material";
|
||||
import type { WorkOrderAuditEntry } from "@/domain/work-orders/types/work-order";
|
||||
import { formatDateTime } from "@/lib/time-utils";
|
||||
|
||||
export function WorkOrderAuditTimeline({ entries }: { entries: WorkOrderAuditEntry[] }) {
|
||||
const sorted = useMemo(
|
||||
() =>
|
||||
[...entries].sort(
|
||||
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
|
||||
),
|
||||
[entries],
|
||||
);
|
||||
|
||||
if (!sorted.length) {
|
||||
return <Typography sx={{ color: "text.secondary" }}>No changes recorded.</Typography>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack spacing={2} className="max-h-72 overflow-y-auto">
|
||||
{sorted.map((entry) => (
|
||||
<Box key={String(entry.id)} className="rounded border border-border p-3">
|
||||
<Stack direction="row" className="mb-1" sx={{ justifyContent: "space-between" }}>
|
||||
<Typography variant="caption" className="font-semibold">
|
||||
{entry.userName || "System"}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
{entry.createdAt ? formatDateTime(entry.createdAt) : ""}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography variant="body2">
|
||||
<strong>{entry.fieldName}</strong> {entry.oldValue} → {entry.newValue}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import { Box, Typography } from "@mui/material";
|
||||
|
||||
export function WorkOrderDetailField({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", display: "block" }}>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="body2">{value}</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -12,47 +12,31 @@ import {
|
|||
Typography,
|
||||
} from "@mui/material";
|
||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||
import type { WorkOrderDispatchSummary } from "@/domain/work-orders/types/work-order";
|
||||
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail";
|
||||
import { formatDateTime, timeSince, waitTimeColor } from "@/lib/time-utils";
|
||||
|
||||
interface WorkOrderDispatchesAccordionProps {
|
||||
dispatches: WorkOrderDispatchSummary[];
|
||||
onSelectDispatch: (dispatchId: string | number) => void;
|
||||
onEditVendor: (event: React.MouseEvent, vendorId: string | number, vendorName: string) => void;
|
||||
}
|
||||
|
||||
function DispatchWaitCell({ dispatch }: { dispatch: WorkOrderDispatchSummary }) {
|
||||
if (dispatch.status !== "Sent") {
|
||||
return <>—</>;
|
||||
}
|
||||
|
||||
const waitColor = waitTimeColor(dispatch.dispatchedAt);
|
||||
const colorClass =
|
||||
waitColor === "green"
|
||||
? "text-green-600"
|
||||
: waitColor === "yellow"
|
||||
? "text-yellow-600"
|
||||
: "text-red-600";
|
||||
|
||||
return (
|
||||
<Typography variant="caption" className={colorClass}>
|
||||
{timeSince(dispatch.dispatchedAt)}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
type WorkOrderDispatchesAccordionProps = {
|
||||
wo: WorkOrderDetail;
|
||||
onOpenDispatchDetail: (dispatchId: string | number) => void;
|
||||
onOpenVendorEditor: (
|
||||
event: React.MouseEvent,
|
||||
vendorId: string | number,
|
||||
vendorName: string,
|
||||
) => void;
|
||||
};
|
||||
|
||||
export function WorkOrderDispatchesAccordion({
|
||||
dispatches,
|
||||
onSelectDispatch,
|
||||
onEditVendor,
|
||||
wo,
|
||||
onOpenDispatchDetail,
|
||||
onOpenVendorEditor,
|
||||
}: WorkOrderDispatchesAccordionProps) {
|
||||
return (
|
||||
<Accordion defaultExpanded={Boolean(dispatches.length)}>
|
||||
<Accordion defaultExpanded={Boolean(wo.dispatches.length)}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography sx={{ fontWeight: 600 }}>Dispatches ({dispatches.length})</Typography>
|
||||
<Typography sx={{ fontWeight: 600 }}>Dispatches ({wo.dispatches.length})</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
{dispatches.length ? (
|
||||
{wo.dispatches.length ? (
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
|
|
@ -67,49 +51,61 @@ export function WorkOrderDispatchesAccordion({
|
|||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{dispatches.map((dispatch) => (
|
||||
<TableRow
|
||||
key={String(dispatch.id)}
|
||||
hover
|
||||
className="cursor-pointer"
|
||||
onClick={() => onSelectDispatch(dispatch.id)}
|
||||
>
|
||||
<TableCell>{dispatch.dispatchNumber || dispatch.poNumber}</TableCell>
|
||||
<TableCell>{dispatch.vendorName}</TableCell>
|
||||
<TableCell>
|
||||
{dispatch.nteAmount != null ? `$${dispatch.nteAmount.toFixed(2)}` : "—"}
|
||||
</TableCell>
|
||||
<TableCell>{dispatch.status}</TableCell>
|
||||
<TableCell>
|
||||
{dispatch.dispatchedAt ? formatDateTime(dispatch.dispatchedAt) : "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DispatchWaitCell dispatch={dispatch} />
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Button
|
||||
size="small"
|
||||
onClick={(event) =>
|
||||
onEditVendor(event, dispatch.vendorId, dispatch.vendorName)
|
||||
}
|
||||
disabled={dispatch.vendorId === ""}
|
||||
>
|
||||
Edit vendor
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{wo.dispatches.map((dispatch) => {
|
||||
const waitColor = waitTimeColor(dispatch.dispatchedAt);
|
||||
return (
|
||||
<TableRow
|
||||
key={String(dispatch.id)}
|
||||
hover
|
||||
className="cursor-pointer"
|
||||
onClick={() => onOpenDispatchDetail(dispatch.id)}
|
||||
>
|
||||
<TableCell>{dispatch.dispatchNumber || dispatch.poNumber}</TableCell>
|
||||
<TableCell>{dispatch.vendorName}</TableCell>
|
||||
<TableCell>
|
||||
{dispatch.nteAmount != null ? `$${dispatch.nteAmount.toFixed(2)}` : "—"}
|
||||
</TableCell>
|
||||
<TableCell>{dispatch.status}</TableCell>
|
||||
<TableCell>
|
||||
{dispatch.dispatchedAt ? formatDateTime(dispatch.dispatchedAt) : "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{dispatch.status === "Sent" ? (
|
||||
<Typography
|
||||
variant="caption"
|
||||
className={
|
||||
waitColor === "green"
|
||||
? "text-green-600"
|
||||
: waitColor === "yellow"
|
||||
? "text-yellow-600"
|
||||
: "text-red-600"
|
||||
}
|
||||
>
|
||||
{timeSince(dispatch.dispatchedAt)}
|
||||
</Typography>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Button
|
||||
size="small"
|
||||
onClick={(event) =>
|
||||
void onOpenVendorEditor(event, dispatch.vendorId, dispatch.vendorName)
|
||||
}
|
||||
disabled={dispatch.vendorId === ""}
|
||||
>
|
||||
Edit vendor
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
) : (
|
||||
<Typography
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
No dispatches yet.
|
||||
</Typography>
|
||||
<Typography sx={{ color: "text.secondary" }}>No dispatches yet.</Typography>
|
||||
)}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
|
|
|||
|
|
@ -11,41 +11,40 @@ import {
|
|||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
formatPhoneMask,
|
||||
isVendorEditorValid,
|
||||
type VendorEditorState,
|
||||
} from "@/app/(protected)/workorders/_hooks/use-vendor-editor";
|
||||
import type { VendorEditorState } from "./work-order-vendor-editor-types";
|
||||
import { formatPhoneMask, isVendorEditorValid } from "./work-order-vendor-editor-utils";
|
||||
|
||||
interface WorkOrderVendorEditorDialogProps {
|
||||
vendorEditor: VendorEditorState | null;
|
||||
isLoading: boolean;
|
||||
isSaving: boolean;
|
||||
type WorkOrderVendorEditorDialogProps = {
|
||||
editor: VendorEditorState | null;
|
||||
loading: boolean;
|
||||
error: string;
|
||||
isSaving: boolean;
|
||||
onClose: () => void;
|
||||
onChange: (patch: Partial<VendorEditorState>) => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
onEditorChange: (
|
||||
updater: (current: VendorEditorState | null) => VendorEditorState | null,
|
||||
) => void;
|
||||
};
|
||||
|
||||
export function WorkOrderVendorEditorDialog({
|
||||
vendorEditor,
|
||||
isLoading,
|
||||
isSaving,
|
||||
editor,
|
||||
loading,
|
||||
error,
|
||||
isSaving,
|
||||
onClose,
|
||||
onChange,
|
||||
onSave,
|
||||
onEditorChange,
|
||||
}: WorkOrderVendorEditorDialogProps) {
|
||||
return (
|
||||
<Dialog open={vendorEditor !== null} onClose={onClose} fullWidth maxWidth="sm">
|
||||
<Dialog open={editor !== null} onClose={onClose} fullWidth maxWidth="sm">
|
||||
<DialogTitle>Edit assigned vendor</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} className="pt-2">
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{vendorEditor?.vendorName}
|
||||
{editor?.vendorName}
|
||||
</Typography>
|
||||
{Boolean(error) && <Alert severity="error">{error}</Alert>}
|
||||
{isLoading ? (
|
||||
{loading ? (
|
||||
<Box className="flex justify-center p-6">
|
||||
<CircularProgress size={28} />
|
||||
</Box>
|
||||
|
|
@ -53,31 +52,45 @@ export function WorkOrderVendorEditorDialog({
|
|||
<>
|
||||
<TextField
|
||||
label="Technician name"
|
||||
value={vendorEditor?.contactName ?? ""}
|
||||
onChange={(event) => onChange({ contactName: event.target.value })}
|
||||
value={editor?.contactName ?? ""}
|
||||
onChange={(event) =>
|
||||
onEditorChange((current) =>
|
||||
current ? { ...current, contactName: event.target.value } : current,
|
||||
)
|
||||
}
|
||||
slotProps={{ htmlInput: { maxLength: 200 } }}
|
||||
/>
|
||||
<TextField
|
||||
label="Phone"
|
||||
value={vendorEditor?.phone ?? ""}
|
||||
onChange={(event) => onChange({ phone: formatPhoneMask(event.target.value) })}
|
||||
error={Boolean(
|
||||
vendorEditor?.phone && !/^\(\d{3}\) \d{3}-\d{4}$/.test(vendorEditor.phone),
|
||||
)}
|
||||
value={editor?.phone ?? ""}
|
||||
onChange={(event) =>
|
||||
onEditorChange((current) =>
|
||||
current ? { ...current, phone: formatPhoneMask(event.target.value) } : current,
|
||||
)
|
||||
}
|
||||
error={Boolean(editor?.phone && !/^\(\d{3}\) \d{3}-\d{4}$/.test(editor.phone))}
|
||||
helperText="Use (XXX) XXX-XXXX"
|
||||
/>
|
||||
<TextField
|
||||
label="Email"
|
||||
type="email"
|
||||
value={vendorEditor?.email ?? ""}
|
||||
onChange={(event) => onChange({ email: event.target.value })}
|
||||
value={editor?.email ?? ""}
|
||||
onChange={(event) =>
|
||||
onEditorChange((current) =>
|
||||
current ? { ...current, email: event.target.value } : current,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
label="Notes"
|
||||
multiline
|
||||
minRows={3}
|
||||
value={vendorEditor?.notes ?? ""}
|
||||
onChange={(event) => onChange({ notes: event.target.value })}
|
||||
value={editor?.notes ?? ""}
|
||||
onChange={(event) =>
|
||||
onEditorChange((current) =>
|
||||
current ? { ...current, notes: event.target.value } : current,
|
||||
)
|
||||
}
|
||||
slotProps={{ htmlInput: { maxLength: 2000 } }}
|
||||
/>
|
||||
</>
|
||||
|
|
@ -91,7 +104,7 @@ export function WorkOrderVendorEditorDialog({
|
|||
<Button
|
||||
variant="contained"
|
||||
onClick={onSave}
|
||||
disabled={!vendorEditor || isLoading || isSaving || !isVendorEditorValid(vendorEditor)}
|
||||
disabled={!editor || loading || isSaving || !isVendorEditorValid(editor)}
|
||||
>
|
||||
{isSaving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
export interface VendorEditorState {
|
||||
vendorId: string | number;
|
||||
vendorName: string;
|
||||
contactName: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
notes: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import type { VendorEditorState } from "./work-order-vendor-editor-types";
|
||||
|
||||
export function formatPhoneMask(input: string): string {
|
||||
const digits = input.replace(/\D/g, "").slice(0, 10);
|
||||
if (digits.length <= 3) return digits;
|
||||
if (digits.length <= 6) return `(${digits.slice(0, 3)}) ${digits.slice(3)}`;
|
||||
return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
|
||||
}
|
||||
|
||||
export function isVendorEditorValid(editor: VendorEditorState): boolean {
|
||||
const phoneValid = editor.phone === "" || /^\(\d{3}\) \d{3}-\d{4}$/.test(editor.phone);
|
||||
const emailValid = editor.email === "" || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(editor.email);
|
||||
return (
|
||||
phoneValid && emailValid && editor.notes.length <= 2000 && editor.contactName.length <= 200
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
import { Accordion, AccordionDetails, AccordionSummary, Box, Typography } from "@mui/material";
|
||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail";
|
||||
import { WorkOrderDetailField } from "@/app/(protected)/workorders/_components/view/work-order-detail-field";
|
||||
import { formatDateTime } from "@/lib/time-utils";
|
||||
|
||||
export function WorkOrderViewAccordions({ wo }: { wo: WorkOrderDetail }) {
|
||||
return (
|
||||
<>
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography sx={{ fontWeight: 600 }}>Classification</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<WorkOrderDetailField label="Problem" value={wo.problem || "—"} />
|
||||
<WorkOrderDetailField label="Trade" value={wo.trade || "—"} />
|
||||
<WorkOrderDetailField label="Sub-Trade" value={wo.subTrade || "—"} />
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography sx={{ fontWeight: 600 }}>Schedule</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<WorkOrderDetailField
|
||||
label="Created"
|
||||
value={wo.createdDateFull ? formatDateTime(wo.createdDateFull) : "—"}
|
||||
/>
|
||||
<WorkOrderDetailField label="Due Date" value={wo.dueDate || "—"} />
|
||||
<WorkOrderDetailField label="Scheduled" value={wo.scheduledDate || "—"} />
|
||||
<WorkOrderDetailField label="Completed" value={wo.completedDate || "—"} />
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography sx={{ fontWeight: 600 }}>Financial</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<WorkOrderDetailField
|
||||
label="Vendor NTE"
|
||||
value={wo.vendorNTE ? `$${parseFloat(wo.vendorNTE).toFixed(2)}` : "—"}
|
||||
/>
|
||||
<WorkOrderDetailField label="Customer PO" value={wo.po || "—"} />
|
||||
<WorkOrderDetailField label="Customer NTE" value="Not connected" />
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography sx={{ fontWeight: 600 }}>Description</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Typography>{wo.description || "No description provided."}</Typography>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
import { Box, Button, Menu, MenuItem, Paper, Typography } from "@mui/material";
|
||||
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
||||
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail";
|
||||
import { WorkOrderDetailField } from "@/app/(protected)/workorders/_components/view/work-order-detail-field";
|
||||
import { Text } from "@/components/ui/text";
|
||||
|
||||
type WorkOrderViewDetailsProps = {
|
||||
wo: WorkOrderDetail;
|
||||
users: WorkOrderUser[];
|
||||
assignAnchor: HTMLElement | null;
|
||||
onAssignAnchorChange: (anchor: HTMLElement | null) => void;
|
||||
onAssign: (userId: string | number, userName: string) => void;
|
||||
onUnassign: () => void;
|
||||
};
|
||||
|
||||
export function WorkOrderViewDetails({
|
||||
wo,
|
||||
users,
|
||||
assignAnchor,
|
||||
onAssignAnchorChange,
|
||||
onAssign,
|
||||
onUnassign,
|
||||
}: WorkOrderViewDetailsProps) {
|
||||
return (
|
||||
<Paper variant="outlined" className="p-4">
|
||||
<Text as="p" variant="heading" className="mb-3 font-semibold">
|
||||
Details
|
||||
</Text>
|
||||
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<WorkOrderDetailField label="Internal WO" value={wo.internalWONumber} />
|
||||
<WorkOrderDetailField label="Customer WO" value={wo.workOrderNumber || "—"} />
|
||||
<WorkOrderDetailField label="Priority" value={wo.priority || "—"} />
|
||||
<WorkOrderDetailField label="Location" value={wo.location || "—"} />
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", display: "block" }}>
|
||||
Assigned To
|
||||
</Typography>
|
||||
<Button size="small" onClick={(event) => onAssignAnchorChange(event.currentTarget)}>
|
||||
{wo.assignedTo || "Unassigned"}
|
||||
</Button>
|
||||
<Menu
|
||||
anchorEl={assignAnchor}
|
||||
open={Boolean(assignAnchor)}
|
||||
onClose={() => onAssignAnchorChange(null)}
|
||||
>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
onAssignAnchorChange(null);
|
||||
onUnassign();
|
||||
}}
|
||||
>
|
||||
Unassigned
|
||||
</MenuItem>
|
||||
{users.map((user) => (
|
||||
<MenuItem
|
||||
key={String(user.id)}
|
||||
selected={wo.assignedTo === user.name}
|
||||
onClick={() => {
|
||||
onAssignAnchorChange(null);
|
||||
onAssign(user.id, user.name);
|
||||
}}
|
||||
>
|
||||
{user.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</Box>
|
||||
<WorkOrderDetailField label="Source" value={wo.source || "—"} />
|
||||
<WorkOrderDetailField label="TT" value={wo.tt || "—"} />
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
import { Link as RouterLink, useNavigate } from "react-router";
|
||||
import { Breadcrumbs, Button, Chip, Link, Menu, MenuItem, Stack, Typography } from "@mui/material";
|
||||
import { WORK_ORDER_STATUS_OPTIONS } from "@/domain/work-orders/types/work-order";
|
||||
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail";
|
||||
|
||||
const STATUS_COLORS: Record<
|
||||
string,
|
||||
"default" | "primary" | "secondary" | "error" | "info" | "success" | "warning"
|
||||
> = {
|
||||
Open: "primary",
|
||||
"In Progress": "info",
|
||||
"On Hold": "warning",
|
||||
Done: "success",
|
||||
Cancelled: "default",
|
||||
};
|
||||
|
||||
type WorkOrderViewHeaderProps = {
|
||||
wo: WorkOrderDetail;
|
||||
workOrderId: string;
|
||||
statusAnchor: HTMLElement | null;
|
||||
onStatusAnchorChange: (anchor: HTMLElement | null) => void;
|
||||
onStatusChange: (status: string) => void;
|
||||
onOpenDispatch: () => void;
|
||||
};
|
||||
|
||||
export function WorkOrderViewHeader({
|
||||
wo,
|
||||
workOrderId,
|
||||
statusAnchor,
|
||||
onStatusAnchorChange,
|
||||
onStatusChange,
|
||||
onOpenDispatch,
|
||||
}: WorkOrderViewHeaderProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Breadcrumbs>
|
||||
<Link component={RouterLink} to="/workorders" underline="hover">
|
||||
Work Orders
|
||||
</Link>
|
||||
<Typography sx={{ color: "text.primary" }}>{wo.internalWONumber}</Typography>
|
||||
</Breadcrumbs>
|
||||
<Stack
|
||||
direction={{ xs: "column", md: "row" }}
|
||||
spacing={2}
|
||||
sx={{ justifyContent: "space-between", alignItems: { xs: "stretch", md: "flex-start" } }}
|
||||
>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="h5" component="h1">
|
||||
{wo.title}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={wo.status}
|
||||
color={STATUS_COLORS[wo.status] ?? "default"}
|
||||
onClick={(event) => onStatusAnchorChange(event.currentTarget)}
|
||||
className="w-fit cursor-pointer"
|
||||
/>
|
||||
<Menu
|
||||
anchorEl={statusAnchor}
|
||||
open={Boolean(statusAnchor)}
|
||||
onClose={() => onStatusAnchorChange(null)}
|
||||
>
|
||||
{WORK_ORDER_STATUS_OPTIONS.map((status) => (
|
||||
<MenuItem
|
||||
key={status}
|
||||
selected={status === wo.status}
|
||||
onClick={() => {
|
||||
onStatusAnchorChange(null);
|
||||
if (status !== wo.status) {
|
||||
onStatusChange(status);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{status}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" spacing={1} sx={{ flexWrap: "wrap" }}>
|
||||
<Button variant="contained" color="success" onClick={onOpenDispatch}>
|
||||
Dispatch
|
||||
</Button>
|
||||
<Button variant="contained" onClick={() => navigate(`/workorders/edit/${workOrderId}`)}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button variant="outlined" onClick={() => navigate("/workorders")}>
|
||||
Back to List
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -52,13 +52,21 @@ export function useWizardDateFieldState({
|
|||
const [rangeStart, setRangeStart] = useState(value || "");
|
||||
const [rangeEnd, setRangeEnd] = useState(scheduleEnd || "");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
// Keep draft props as source of truth while open so parent clears
|
||||
// (e.g. Clear schedule) cannot leave stale rangeStart/rangeEnd.
|
||||
setRangeStart(value || "");
|
||||
setRangeEnd(scheduleEnd || "");
|
||||
}, [open, value, scheduleEnd]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setRangeStart(value || "");
|
||||
setRangeEnd(scheduleEnd || "");
|
||||
setCellMode(initialMode);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- reseat mode only when the popover opens
|
||||
}, [open]);
|
||||
|
||||
function handleRangeClick(iso: string) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
import { useState } from "react";
|
||||
import { CalendarIcon, ChevronDown } from "lucide-react";
|
||||
import { DueDateCalendarDialog } from "@/app/(protected)/workorders/_components/list/table/cells/due-date-calendar-dialog";
|
||||
import { WizardDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-date-field";
|
||||
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||
import { fmtDate } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||
|
||||
type WizardDueDateFieldProps = {
|
||||
type: WOType | "";
|
||||
value: string;
|
||||
placeholder: string;
|
||||
onChange: (iso: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Wizard Due Date editor.
|
||||
* Emergency → dedicated calendar Dialog; other types → WizardDateField popover.
|
||||
*/
|
||||
export function WizardDueDateField({
|
||||
type,
|
||||
value,
|
||||
placeholder,
|
||||
onChange,
|
||||
}: WizardDueDateFieldProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
if (type !== "Emergency") {
|
||||
return <WizardDateField value={value} placeholder={placeholder} onChange={onChange} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="flex w-full items-center gap-2 transition-colors"
|
||||
style={{
|
||||
height: 36,
|
||||
border: "1px solid var(--color-border)",
|
||||
borderRadius: 6,
|
||||
background: "var(--color-bg-surface)",
|
||||
padding: "0 10px",
|
||||
outline: "none",
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.boxShadow = "0 0 0 2px var(--color-primary)";
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.boxShadow = "none";
|
||||
}}
|
||||
aria-haspopup="dialog"
|
||||
>
|
||||
<CalendarIcon size={16} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
|
||||
<span
|
||||
className="flex-1 truncate text-left"
|
||||
style={{
|
||||
fontFamily: "var(--font-sans)",
|
||||
fontSize: 13,
|
||||
color: value ? "var(--foreground)" : "var(--muted-foreground)",
|
||||
}}
|
||||
>
|
||||
{value ? fmtDate(value) : placeholder}
|
||||
</span>
|
||||
<ChevronDown size={13} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
|
||||
</button>
|
||||
<DueDateCalendarDialog open={open} onOpenChange={setOpen} value={value} onSave={onChange} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -28,18 +28,14 @@ export function WizardStepLocationServiceSelect({
|
|||
onUpdate,
|
||||
onPatch,
|
||||
}: WizardStepLocationServiceSelectProps) {
|
||||
const svcSel = [draft.pm, ...draft.extraServices].filter((p, i, a) => !!p && a.indexOf(p) === i);
|
||||
|
||||
const clearVendorIfPrimaryChanged = (nextPm: string): Partial<WorkOrderWizardDraft> =>
|
||||
nextPm !== draft.pm ? { vendorId: "", vendorName: "", techPhone: "" } : {};
|
||||
|
||||
const toggleSvc = (p: string) => {
|
||||
const next = svcSel.includes(p) ? svcSel.filter((x) => x !== p) : [...svcSel, p];
|
||||
const nextPm = next[0] ?? "";
|
||||
const selectSvc = (p: string) => {
|
||||
onPatch({
|
||||
pm: nextPm,
|
||||
extraServices: next.slice(1),
|
||||
...clearVendorIfPrimaryChanged(nextPm),
|
||||
pm: p,
|
||||
extraServices: [],
|
||||
...clearVendorIfPrimaryChanged(p),
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -63,13 +59,7 @@ export function WizardStepLocationServiceSelect({
|
|||
),
|
||||
};
|
||||
})}
|
||||
onPick={(p) =>
|
||||
onPatch({
|
||||
pm: p,
|
||||
extraServices: draft.extraServices.filter((x) => x !== p),
|
||||
...clearVendorIfPrimaryChanged(p),
|
||||
})
|
||||
}
|
||||
onPick={selectSvc}
|
||||
/>
|
||||
{!svcOpen ? (
|
||||
<button
|
||||
|
|
@ -95,8 +85,8 @@ export function WizardStepLocationServiceSelect({
|
|||
>
|
||||
<WizardServiceNotesFields
|
||||
services={services}
|
||||
sel={svcSel}
|
||||
onToggle={toggleSvc}
|
||||
sel={draft.pm ? [draft.pm] : []}
|
||||
onToggle={selectSvc}
|
||||
note={draft.pmNote}
|
||||
setNote={(v) => onUpdate("pmNote", v)}
|
||||
search={svcSearch}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order
|
|||
import { WO_TYPE_META, WO_TYPES, type WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||
import { getDispatcherAvatarColor } from "@/domain/work-orders/utils/dispatcher-avatar-color";
|
||||
import { WizardDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-date-field";
|
||||
import { WizardDueDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-due-date-field";
|
||||
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
|
||||
import {
|
||||
DLG_INPUT_CLS,
|
||||
|
|
@ -239,7 +240,8 @@ export function WizardStepTypeSchedule({
|
|||
>
|
||||
Due Date
|
||||
</WizLabelRow>
|
||||
<WizardDateField
|
||||
<WizardDueDateField
|
||||
type={draft.type}
|
||||
value={draft.dueDate}
|
||||
placeholder="No due date"
|
||||
onChange={(iso) => onUpdate("dueDate", iso)}
|
||||
|
|
|
|||
|
|
@ -2,10 +2,7 @@ import { Clock, ExternalLink, Mail, User, Wrench } from "lucide-react";
|
|||
import { Text } from "@/components/ui/text";
|
||||
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||
import {
|
||||
ALL_WIZARD_STATUSES,
|
||||
TIME_SLOTS,
|
||||
WIZARD_STATUS_DOTS,
|
||||
type WizardWOStatus,
|
||||
type WorkOrderWizardDraft,
|
||||
} from "@/domain/work-orders/types/work-order-wizard";
|
||||
import { calcDuration, fmt12 } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||
|
|
@ -19,7 +16,6 @@ import {
|
|||
DLG_INPUT_STYLE,
|
||||
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
|
||||
import {
|
||||
ColorDot,
|
||||
Detail,
|
||||
WizLabel,
|
||||
WizLabelRow,
|
||||
|
|
@ -117,8 +113,6 @@ export function WizardStepVendorTime({
|
|||
onPatch,
|
||||
}: WizardStepVendorTimeProps) {
|
||||
const selectedVendor = vendors.find((v) => String(v.id) === draft.vendorId);
|
||||
const statusValue = (draft.status || "Incomplete") as WizardWOStatus;
|
||||
const statusDotColor = WIZARD_STATUS_DOTS[statusValue] ?? WIZARD_STATUS_DOTS.Incomplete;
|
||||
const selectedDistance = formatVendorDistance(selectedVendor?.distanceMiles);
|
||||
|
||||
return (
|
||||
|
|
@ -234,22 +228,6 @@ export function WizardStepVendorTime({
|
|||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<WizLabel>Status</WizLabel>
|
||||
<WizardFieldSelect
|
||||
value={statusValue}
|
||||
placeholder="Select status…"
|
||||
search={false}
|
||||
icon={<ColorDot color={statusDotColor} />}
|
||||
options={ALL_WIZARD_STATUSES.map((s) => ({
|
||||
key: s,
|
||||
label: s,
|
||||
left: <ColorDot color={WIZARD_STATUS_DOTS[s]} />,
|
||||
}))}
|
||||
onPick={(s) => onUpdate("status", s as WizardWOStatus)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,170 @@
|
|||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "react-toastify";
|
||||
import { showCompDocDraftSavedToast } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-draft-toast";
|
||||
import { locationsApi } from "@/domain/locations/api/locations-api";
|
||||
import type { Location } from "@/domain/locations/types/location";
|
||||
import type {
|
||||
CompDocData,
|
||||
CompDocSaveStatus,
|
||||
DocFields,
|
||||
DocState,
|
||||
} from "@/domain/work-orders/types/completion-doc";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { buildDocHtml, printDoc } from "@/domain/work-orders/utils/completion-doc-html";
|
||||
import { fmtDocNow } from "@/domain/work-orders/utils/completion-doc-state";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
|
||||
type CompDocSite = { street: string; fullAddress: string };
|
||||
|
||||
export function buildCompDocAddressLine(site: CompDocSite): string {
|
||||
if (site.street.trim()) return site.street.trim();
|
||||
return site.fullAddress !== "—" ? site.fullAddress : "";
|
||||
}
|
||||
|
||||
function locationUpdatePayload(location: Location, address: string): Location {
|
||||
return {
|
||||
...location,
|
||||
address,
|
||||
};
|
||||
}
|
||||
|
||||
type CreateCompDocDialogActionsParams = {
|
||||
row: WorkOrderTableRow;
|
||||
notes: string;
|
||||
generatedAt: string;
|
||||
editDateCompleted: string;
|
||||
editAddress: string;
|
||||
docState: DocState;
|
||||
docFields: DocFields;
|
||||
site: CompDocSite;
|
||||
locId: string;
|
||||
isMockLocation: boolean;
|
||||
locationDetail: Location | undefined;
|
||||
queryClient: QueryClient;
|
||||
onSave: (data: CompDocData, status: CompDocSaveStatus) => void | Promise<void>;
|
||||
onDateCompletedChange?: (date: string) => void | Promise<void>;
|
||||
setDocState: (state: DocState) => void;
|
||||
setGeneratedAt: (value: string) => void;
|
||||
};
|
||||
|
||||
export function createCompDocDialogActions({
|
||||
row,
|
||||
notes,
|
||||
generatedAt,
|
||||
editDateCompleted,
|
||||
editAddress,
|
||||
docState,
|
||||
docFields,
|
||||
site,
|
||||
locId,
|
||||
isMockLocation,
|
||||
locationDetail,
|
||||
queryClient,
|
||||
onSave,
|
||||
onDateCompletedChange,
|
||||
setDocState,
|
||||
setGeneratedAt,
|
||||
}: CreateCompDocDialogActionsParams) {
|
||||
async function persist(extra: Partial<CompDocData>, status: CompDocSaveStatus): Promise<void> {
|
||||
await onSave({ notes, generatedAt, ...extra }, status);
|
||||
}
|
||||
|
||||
async function syncCompletedDateIfChanged(): Promise<void> {
|
||||
if (editDateCompleted !== (row.completedDate || "")) {
|
||||
await onDateCompletedChange?.(editDateCompleted);
|
||||
}
|
||||
}
|
||||
|
||||
async function syncAddressIfChanged(): Promise<void> {
|
||||
const next = editAddress.trim();
|
||||
const current = buildCompDocAddressLine(site);
|
||||
if (next === current) return;
|
||||
if (isMockLocation || !locId) return;
|
||||
if (!locationDetail) {
|
||||
throw new Error("Location details are still loading. Try saving again.");
|
||||
}
|
||||
|
||||
await locationsApi.update(locId, locationUpdatePayload(locationDetail, next));
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.locations.detail(locId) });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.locations.all });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
|
||||
}
|
||||
|
||||
function refreshAuthoritativeState() {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
|
||||
if (locId && !isMockLocation) {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.locations.detail(locId) });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.locations.all });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMarkNotRequired() {
|
||||
const previous = docState;
|
||||
setDocState("not-required");
|
||||
try {
|
||||
await persist({}, "NN");
|
||||
} catch (error) {
|
||||
setDocState(previous);
|
||||
refreshAuthoritativeState();
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to mark document as not required",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUndo() {
|
||||
const previous = docState;
|
||||
setDocState("draft");
|
||||
try {
|
||||
await persist({}, "No");
|
||||
} catch (error) {
|
||||
setDocState(previous);
|
||||
refreshAuthoritativeState();
|
||||
toast.error(error instanceof Error ? error.message : "Failed to undo not-required");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveDraft() {
|
||||
try {
|
||||
await syncCompletedDateIfChanged();
|
||||
await syncAddressIfChanged();
|
||||
await persist({}, "No");
|
||||
showCompDocDraftSavedToast();
|
||||
} catch (error) {
|
||||
refreshAuthoritativeState();
|
||||
toast.error(error instanceof Error ? error.message : "Failed to save draft");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGenerate() {
|
||||
if (!editDateCompleted.trim()) {
|
||||
toast.error("Date Work Completed is required to generate the document.");
|
||||
return;
|
||||
}
|
||||
const ts = fmtDocNow();
|
||||
try {
|
||||
// Prerequisites first; only mark Generated after completedDate/address persist.
|
||||
await syncCompletedDateIfChanged();
|
||||
await syncAddressIfChanged();
|
||||
await persist({ generatedAt: ts }, "Yes");
|
||||
setGeneratedAt(ts);
|
||||
setDocState("generated");
|
||||
} catch (error) {
|
||||
// Keep Draft UI; refresh so any partially persisted date/address is authoritative.
|
||||
refreshAuthoritativeState();
|
||||
toast.error(error instanceof Error ? error.message : "Failed to generate document");
|
||||
}
|
||||
}
|
||||
|
||||
function handleDownloadPdf() {
|
||||
printDoc(buildDocHtml(row, { notes, generatedAt }, docFields));
|
||||
}
|
||||
|
||||
return {
|
||||
handleMarkNotRequired,
|
||||
handleUndo,
|
||||
handleSaveDraft,
|
||||
handleGenerate,
|
||||
handleDownloadPdf,
|
||||
};
|
||||
}
|
||||
157
src/app/(protected)/workorders/_hooks/use-comp-doc-dialog.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
buildCompDocAddressLine,
|
||||
createCompDocDialogActions,
|
||||
} from "@/app/(protected)/workorders/_hooks/create-comp-doc-dialog-actions";
|
||||
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
|
||||
import type {
|
||||
CompDocData,
|
||||
CompDocSaveStatus,
|
||||
DocFields,
|
||||
DocState,
|
||||
} from "@/domain/work-orders/types/completion-doc";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { getMockWoWizardLocationDetail } from "@/domain/work-orders/mocks/wo-wizard-mock-data";
|
||||
import { resolveCompDocSite } from "@/domain/work-orders/utils/completion-doc-site";
|
||||
import { DOC_STATE_CFG, docStateOf } from "@/domain/work-orders/utils/completion-doc-state";
|
||||
|
||||
type UseCompDocDialogParams = {
|
||||
row: WorkOrderTableRow;
|
||||
open: boolean;
|
||||
data?: CompDocData;
|
||||
onSave: (data: CompDocData, status: CompDocSaveStatus) => void | Promise<void>;
|
||||
onDateCompletedChange?: (date: string) => void | Promise<void>;
|
||||
};
|
||||
|
||||
export function useCompDocDialog({
|
||||
row,
|
||||
open,
|
||||
data,
|
||||
onSave,
|
||||
onDateCompletedChange,
|
||||
}: UseCompDocDialogParams) {
|
||||
const queryClient = useQueryClient();
|
||||
const locId = String(row.locationId || "");
|
||||
const isMockLocation = locId.startsWith("mock-");
|
||||
const { data: apiLocationDetail } = useLocationDetail(
|
||||
isMockLocation || !locId ? undefined : locId,
|
||||
);
|
||||
const locationDetail = isMockLocation ? getMockWoWizardLocationDetail(locId) : apiLocationDetail;
|
||||
const site = useMemo(() => resolveCompDocSite(row, locationDetail), [row, locationDetail]);
|
||||
|
||||
const [notes, setNotes] = useState("");
|
||||
const [docState, setDocState] = useState<DocState>("draft");
|
||||
const [generatedAt, setGeneratedAt] = useState("");
|
||||
const [editDateCompleted, setEditDateCompleted] = useState("");
|
||||
const [editAddress, setEditAddress] = useState("");
|
||||
const seedKeyRef = useRef<string | null>(null);
|
||||
const addressSeededRef = useRef(false);
|
||||
const addressTouchedRef = useRef(false);
|
||||
|
||||
// Seed once per open/WO — row/detail refetches must not wipe in-progress edits.
|
||||
// Address waits for authoritative locationDetail (when a real location id exists)
|
||||
// and still accepts that first response without overwriting later user edits.
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
seedKeyRef.current = null;
|
||||
addressSeededRef.current = false;
|
||||
addressTouchedRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const key = String(row.id);
|
||||
if (seedKeyRef.current !== key) {
|
||||
seedKeyRef.current = key;
|
||||
addressSeededRef.current = false;
|
||||
addressTouchedRef.current = false;
|
||||
setNotes(data?.notes ?? "");
|
||||
setDocState(docStateOf(row.docStatus));
|
||||
setGeneratedAt(data?.generatedAt ?? "");
|
||||
setEditDateCompleted(row.completedDate || "");
|
||||
}
|
||||
|
||||
if (addressTouchedRef.current || addressSeededRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const awaitingLocation = Boolean(locId) && !isMockLocation && !locationDetail;
|
||||
if (awaitingLocation) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEditAddress(buildCompDocAddressLine(resolveCompDocSite(row, locationDetail)));
|
||||
addressSeededRef.current = true;
|
||||
}, [open, row, data?.notes, data?.generatedAt, locationDetail, locId, isMockLocation]);
|
||||
|
||||
const handleAddressChange = (value: string) => {
|
||||
addressTouchedRef.current = true;
|
||||
setEditAddress(value);
|
||||
};
|
||||
|
||||
const amazonRep = row.pocName || "";
|
||||
const seaRep = row.tech || "";
|
||||
const cfg = DOC_STATE_CFG[docState];
|
||||
const isNotRequired = docState === "not-required";
|
||||
const isGenerated = docState === "generated";
|
||||
|
||||
const effectiveStreet = editAddress.trim() || site.street;
|
||||
const docFields: DocFields = {
|
||||
street: effectiveStreet,
|
||||
city: site.city,
|
||||
state: site.state,
|
||||
zip: site.zip,
|
||||
dateCompleted: editDateCompleted,
|
||||
amazonRep,
|
||||
seaRep,
|
||||
};
|
||||
|
||||
const previewSite = useMemo(
|
||||
() => ({
|
||||
...site,
|
||||
street: effectiveStreet,
|
||||
fullAddress:
|
||||
[effectiveStreet, site.label, site.zip].filter(Boolean).join(", ") ||
|
||||
effectiveStreet ||
|
||||
"—",
|
||||
}),
|
||||
[site, effectiveStreet],
|
||||
);
|
||||
|
||||
const actions = createCompDocDialogActions({
|
||||
row,
|
||||
notes,
|
||||
generatedAt,
|
||||
editDateCompleted,
|
||||
editAddress,
|
||||
docState,
|
||||
docFields,
|
||||
site,
|
||||
locId,
|
||||
isMockLocation,
|
||||
locationDetail,
|
||||
queryClient,
|
||||
onSave,
|
||||
onDateCompletedChange,
|
||||
setDocState,
|
||||
setGeneratedAt,
|
||||
});
|
||||
|
||||
return {
|
||||
site: previewSite,
|
||||
notes,
|
||||
setNotes,
|
||||
docState,
|
||||
generatedAt,
|
||||
editAddress,
|
||||
setEditAddress: handleAddressChange,
|
||||
editDateCompleted,
|
||||
setEditDateCompleted,
|
||||
amazonRep,
|
||||
seaRep,
|
||||
cfg,
|
||||
isNotRequired,
|
||||
isGenerated,
|
||||
...actions,
|
||||
};
|
||||
}
|
||||
|
|
@ -172,7 +172,8 @@ export function useDispatchDetailController(
|
|||
addSignoffMutation,
|
||||
verifyMutation,
|
||||
approveUpliftMutation: uplift.approveUpliftMutation,
|
||||
denyUpliftMutation: uplift.denyUpliftMutation,
|
||||
rejectUpliftMutation: uplift.rejectUpliftMutation,
|
||||
requestChangesUpliftMutation: uplift.requestChangesUpliftMutation,
|
||||
addCommentMutation,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,23 +1,25 @@
|
|||
import { useState } from "react";
|
||||
import {
|
||||
useApproveUplift,
|
||||
useDenyUplift,
|
||||
useRejectUplift,
|
||||
useRequestChangesUplift,
|
||||
useUpliftsForDispatch,
|
||||
} from "@/domain/uplifts/use-cases/use-uplift-actions";
|
||||
import type { UpliftRequest } from "@/domain/uplifts/types/uplift";
|
||||
|
||||
type UpliftAction = { type: "approve" | "deny"; uplift: UpliftRequest };
|
||||
type UpliftAction = { type: "approve" | "reject" | "request-changes"; uplift: UpliftRequest };
|
||||
|
||||
/**
|
||||
* Owns the uplift approve/deny decision workflow for a dispatch: the pending
|
||||
* uplifts list, which uplift (if any) the user is currently deciding on, and
|
||||
* the mutations backing that decision.
|
||||
* Owns the uplift decision workflow for a dispatch: the pending uplifts list,
|
||||
* which uplift (if any) the user is currently deciding on, and the mutations
|
||||
* backing that decision.
|
||||
*/
|
||||
export function useDispatchUpliftController(dispatchId: string | number) {
|
||||
const { data: uplifts = [], isLoading: isLoadingUplifts } = useUpliftsForDispatch(dispatchId);
|
||||
const [upliftAction, setUpliftAction] = useState<UpliftAction | null>(null);
|
||||
const approveUpliftMutation = useApproveUplift(dispatchId);
|
||||
const denyUpliftMutation = useDenyUplift(dispatchId);
|
||||
const rejectUpliftMutation = useRejectUplift(dispatchId);
|
||||
const requestChangesUpliftMutation = useRequestChangesUplift(dispatchId);
|
||||
|
||||
const handleUpliftConfirm = (note: string) => {
|
||||
if (!upliftAction) {
|
||||
|
|
@ -32,7 +34,15 @@ export function useDispatchUpliftController(dispatchId: string | number) {
|
|||
return;
|
||||
}
|
||||
|
||||
denyUpliftMutation.mutate(
|
||||
if (upliftAction.type === "reject") {
|
||||
rejectUpliftMutation.mutate(
|
||||
{ id: upliftAction.uplift.id, note },
|
||||
{ onSuccess: () => setUpliftAction(null) },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
requestChangesUpliftMutation.mutate(
|
||||
{ id: upliftAction.uplift.id, note },
|
||||
{ onSuccess: () => setUpliftAction(null) },
|
||||
);
|
||||
|
|
@ -44,8 +54,12 @@ export function useDispatchUpliftController(dispatchId: string | number) {
|
|||
upliftAction,
|
||||
setUpliftAction,
|
||||
approveUpliftMutation,
|
||||
denyUpliftMutation,
|
||||
rejectUpliftMutation,
|
||||
requestChangesUpliftMutation,
|
||||
handleUpliftConfirm,
|
||||
isMutating: approveUpliftMutation.isPending || denyUpliftMutation.isPending,
|
||||
isMutating:
|
||||
approveUpliftMutation.isPending ||
|
||||
rejectUpliftMutation.isPending ||
|
||||
requestChangesUpliftMutation.isPending,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,12 @@ type SessionUploadRequest = {
|
|||
signal: AbortSignal;
|
||||
};
|
||||
|
||||
function revokeBlobUrl(url: string | undefined) {
|
||||
if (url?.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
function toLocalUpload(request: SessionUploadRequest): LocalUpload {
|
||||
const classified = classifyUpload(request.file);
|
||||
return {
|
||||
|
|
@ -39,10 +45,33 @@ function isSameAttempt(item: LocalUpload, request: SessionUploadRequest) {
|
|||
return item.id === request.localId && item.signal === request.signal;
|
||||
}
|
||||
|
||||
function revokeBlobUrl(url: string) {
|
||||
if (url.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(url);
|
||||
function upsertUploads(previous: LocalUpload[], entries: LocalUpload[]): LocalUpload[] {
|
||||
const next = [...previous];
|
||||
for (const entry of entries) {
|
||||
const index = next.findIndex((item) => item.id === entry.id);
|
||||
if (index >= 0) {
|
||||
revokeBlobUrl(next[index]?.url);
|
||||
next[index] = entry;
|
||||
} else {
|
||||
next.push(entry);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function dropUploads(
|
||||
previous: LocalUpload[],
|
||||
matches: (item: LocalUpload) => boolean,
|
||||
): LocalUpload[] {
|
||||
const kept: LocalUpload[] = [];
|
||||
for (const item of previous) {
|
||||
if (matches(item)) {
|
||||
revokeBlobUrl(item.url);
|
||||
continue;
|
||||
}
|
||||
kept.push(item);
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
|
||||
export function useMediaUploadSession(
|
||||
|
|
@ -66,12 +95,7 @@ export function useMediaUploadSession(
|
|||
controller.abort();
|
||||
}
|
||||
controllersRef.current.clear();
|
||||
setLocalUploads((prev) => {
|
||||
for (const item of prev) {
|
||||
revokeBlobUrl(item.url);
|
||||
}
|
||||
return [];
|
||||
});
|
||||
setLocalUploads((prev) => dropUploads(prev, () => true));
|
||||
}, [workOrderId]);
|
||||
|
||||
const serverUploads = useMemo(
|
||||
|
|
@ -98,6 +122,27 @@ export function useMediaUploadSession(
|
|||
return controller;
|
||||
}
|
||||
|
||||
function settleAttempt(request: SessionUploadRequest) {
|
||||
clearController(request.localId);
|
||||
setLocalUploads((prev) => dropUploads(prev, (item) => isSameAttempt(item, request)));
|
||||
}
|
||||
|
||||
function failAttempt(request: SessionUploadRequest, error: Error) {
|
||||
// Keep the controller entry while the attempt is retryable; retry replaces it.
|
||||
setLocalUploads((prev) =>
|
||||
prev.map((item) =>
|
||||
isSameAttempt(item, request)
|
||||
? {
|
||||
...item,
|
||||
uploadStatus: "failed",
|
||||
errorMessage: error.message || "Upload failed",
|
||||
progress: undefined,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function startUploads(requests: MediaUploadRequest[]) {
|
||||
const sessionRequests: SessionUploadRequest[] = requests.map((request) => ({
|
||||
localId: request.localId,
|
||||
|
|
@ -105,23 +150,8 @@ export function useMediaUploadSession(
|
|||
signal: replaceController(request.localId).signal,
|
||||
}));
|
||||
|
||||
setLocalUploads((prev) => {
|
||||
const next = [...prev];
|
||||
for (const request of sessionRequests) {
|
||||
const entry = toLocalUpload(request);
|
||||
const index = next.findIndex((item) => item.id === request.localId);
|
||||
if (index >= 0) {
|
||||
const previous = next[index];
|
||||
if (previous) {
|
||||
revokeBlobUrl(previous.url);
|
||||
}
|
||||
next[index] = entry;
|
||||
} else {
|
||||
next.push(entry);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
const entries = sessionRequests.map(toLocalUpload);
|
||||
setLocalUploads((prev) => upsertUploads(prev, entries));
|
||||
|
||||
for (const request of sessionRequests) {
|
||||
void uploadMutateAsync({
|
||||
|
|
@ -135,29 +165,13 @@ export function useMediaUploadSession(
|
|||
);
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
clearController(request.localId);
|
||||
setLocalUploads((prev) => prev.filter((item) => !isSameAttempt(item, request)));
|
||||
})
|
||||
.then(() => settleAttempt(request))
|
||||
.catch((error: Error) => {
|
||||
if (error.name === "AbortError") {
|
||||
clearController(request.localId);
|
||||
setLocalUploads((prev) => prev.filter((item) => !isSameAttempt(item, request)));
|
||||
settleAttempt(request);
|
||||
return;
|
||||
}
|
||||
// Keep controller entry only while retryable; replaceController on retry.
|
||||
setLocalUploads((prev) =>
|
||||
prev.map((item) =>
|
||||
isSameAttempt(item, request)
|
||||
? {
|
||||
...item,
|
||||
uploadStatus: "failed",
|
||||
errorMessage: error.message || "Upload failed",
|
||||
progress: undefined,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
);
|
||||
failAttempt(request, error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -174,21 +188,19 @@ export function useMediaUploadSession(
|
|||
]);
|
||||
}
|
||||
|
||||
function cancelUpload(localId: string) {
|
||||
/** Abort any in-flight attempt for the tile and drop it from the session. */
|
||||
function dropLocal(localId: string) {
|
||||
controllersRef.current.get(localId)?.abort();
|
||||
clearController(localId);
|
||||
setLocalUploads((prev) => {
|
||||
const target = prev.find((item) => item.id === localId);
|
||||
if (target) {
|
||||
revokeBlobUrl(target.url);
|
||||
}
|
||||
return prev.filter((item) => item.id !== localId);
|
||||
});
|
||||
setLocalUploads((prev) => dropUploads(prev, (item) => item.id === localId));
|
||||
}
|
||||
|
||||
function cancelUpload(localId: string) {
|
||||
dropLocal(localId);
|
||||
}
|
||||
|
||||
function removeLocal(id: string) {
|
||||
clearController(id);
|
||||
setLocalUploads((prev) => prev.filter((item) => item.id !== id));
|
||||
dropLocal(id);
|
||||
}
|
||||
|
||||
function isLocal(id: string) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,149 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import type { VendorEditorState } from "@/app/(protected)/workorders/_components/view/work-order-vendor-editor-types";
|
||||
import { isVendorEditorValid } from "@/app/(protected)/workorders/_components/view/work-order-vendor-editor-utils";
|
||||
import { useWorkOrderDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||
import {
|
||||
useAddWorkOrderComment,
|
||||
useChangeWorkOrderAssignment,
|
||||
useChangeWorkOrderStatus,
|
||||
useUpdateWorkOrderVendor,
|
||||
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
||||
import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
|
||||
import { vendorsApi } from "@/domain/vendors/api/vendors-api";
|
||||
import { extractMentionIds } from "@/domain/work-orders/utils/comment-mentions";
|
||||
|
||||
export function useWorkOrderViewPage(workOrderId: string) {
|
||||
const { data: wo, isLoading, error, refetch } = useWorkOrderDetail(workOrderId);
|
||||
const { data: users = [] } = useWorkOrderUsers();
|
||||
const statusMutation = useChangeWorkOrderStatus(workOrderId);
|
||||
const assignmentMutation = useChangeWorkOrderAssignment(workOrderId);
|
||||
const commentMutation = useAddWorkOrderComment(workOrderId);
|
||||
const vendorMutation = useUpdateWorkOrderVendor(workOrderId);
|
||||
|
||||
const [dispatchOpen, setDispatchOpen] = useState(false);
|
||||
const [dispatchDetailId, setDispatchDetailId] = useState<string | number | null>(null);
|
||||
const [statusAnchor, setStatusAnchor] = useState<HTMLElement | null>(null);
|
||||
const [assignAnchor, setAssignAnchor] = useState<HTMLElement | null>(null);
|
||||
const [vendorEditor, setVendorEditor] = useState<VendorEditorState | null>(null);
|
||||
const [vendorEditorLoading, setVendorEditorLoading] = useState(false);
|
||||
const [vendorEditorError, setVendorEditorError] = useState("");
|
||||
|
||||
const customerComments = useMemo(
|
||||
() => (wo?.comments ?? []).filter((comment) => comment.commentType === "customer"),
|
||||
[wo?.comments],
|
||||
);
|
||||
const internalComments = useMemo(
|
||||
() => (wo?.comments ?? []).filter((comment) => comment.commentType === "internal"),
|
||||
[wo?.comments],
|
||||
);
|
||||
const auditLog = wo?.auditLog ?? [];
|
||||
const mentionUsers = useMemo(
|
||||
() => users.map((user) => ({ id: user.id, name: user.name })),
|
||||
[users],
|
||||
);
|
||||
|
||||
const submitComment = async (text: string) => {
|
||||
const mentions = extractMentionIds(text);
|
||||
await commentMutation.mutateAsync({
|
||||
text,
|
||||
mentions: mentions.length > 0 ? mentions : undefined,
|
||||
commentType: "internal",
|
||||
});
|
||||
};
|
||||
|
||||
const openVendorEditor = async (
|
||||
event: React.MouseEvent,
|
||||
vendorId: string | number,
|
||||
vendorName: string,
|
||||
) => {
|
||||
event.stopPropagation();
|
||||
setVendorEditorError("");
|
||||
setVendorEditorLoading(true);
|
||||
setVendorEditor({
|
||||
vendorId,
|
||||
vendorName,
|
||||
contactName: "",
|
||||
phone: "",
|
||||
email: "",
|
||||
notes: "",
|
||||
});
|
||||
|
||||
try {
|
||||
const vendor = await vendorsApi.getById(vendorId);
|
||||
setVendorEditor({
|
||||
vendorId,
|
||||
vendorName: vendor.companyName || vendorName,
|
||||
contactName: vendor.contactName,
|
||||
phone: vendor.phone,
|
||||
email: vendor.email,
|
||||
notes: vendor.notes,
|
||||
});
|
||||
} catch (vendorError) {
|
||||
setVendorEditorError(
|
||||
vendorError instanceof Error ? vendorError.message : "Unable to load vendor details",
|
||||
);
|
||||
} finally {
|
||||
setVendorEditorLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveVendorEditor = () => {
|
||||
if (!vendorEditor || !isVendorEditorValid(vendorEditor)) return;
|
||||
const numericWorkOrderId = Number(workOrderId);
|
||||
if (!Number.isInteger(numericWorkOrderId) || numericWorkOrderId <= 0) {
|
||||
setVendorEditorError("A valid work order is required");
|
||||
return;
|
||||
}
|
||||
|
||||
vendorMutation.mutate(
|
||||
{
|
||||
vendorId: vendorEditor.vendorId,
|
||||
payload: {
|
||||
workOrderId: numericWorkOrderId,
|
||||
contactName: vendorEditor.contactName.trim(),
|
||||
phone: vendorEditor.phone,
|
||||
email: vendorEditor.email.trim(),
|
||||
notes: vendorEditor.notes.trim(),
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setVendorEditor(null);
|
||||
void refetch();
|
||||
},
|
||||
onError: (vendorError) => setVendorEditorError(vendorError.message),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
wo,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
users,
|
||||
customerComments,
|
||||
internalComments,
|
||||
auditLog,
|
||||
mentionUsers,
|
||||
submitComment,
|
||||
dispatchOpen,
|
||||
setDispatchOpen,
|
||||
dispatchDetailId,
|
||||
setDispatchDetailId,
|
||||
statusAnchor,
|
||||
setStatusAnchor,
|
||||
assignAnchor,
|
||||
setAssignAnchor,
|
||||
vendorEditor,
|
||||
setVendorEditor,
|
||||
vendorEditorLoading,
|
||||
vendorEditorError,
|
||||
openVendorEditor,
|
||||
saveVendorEditor,
|
||||
statusMutation,
|
||||
assignmentMutation,
|
||||
commentMutation,
|
||||
vendorMutation,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,17 +1,16 @@
|
|||
import { Box } from "@mui/material";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { QueryErrorPanel } from "@/components/ui/query-error-panel";
|
||||
import { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog";
|
||||
import { WorkOrderSlideOver } from "@/app/(protected)/workorders/_components/detail/work-order-slide-over";
|
||||
import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal";
|
||||
import { WorkOrdersAdvancedSearchSheet } from "@/app/(protected)/workorders/_components/list/advanced-search/work-orders-advanced-search-sheet";
|
||||
import { WorkOrdersAdvancedFiltersBanner } from "@/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner";
|
||||
import { WorkOrdersFilterBar } from "@/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar";
|
||||
import { WorkOrdersListHeader } from "@/app/(protected)/workorders/_components/list/work-orders-list-header";
|
||||
import { WorkOrderCancelDialog } from "@/app/(protected)/workorders/_components/list/work-order-cancel-dialog";
|
||||
import { WorkOrdersListHeader } from "@/app/(protected)/workorders/_components/list/work-orders-list-header";
|
||||
import { WorkOrdersListOverlays } from "@/app/(protected)/workorders/_components/list/work-orders-list-overlays";
|
||||
import { WoTable } from "@/app/(protected)/workorders/_components/list/table/wo-table";
|
||||
import { NewWoWizard } from "@/app/(protected)/workorders/_components/wizard/new-wo-wizard";
|
||||
import { useWorkOrdersListPage } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-page";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { hasCompletionDocService } from "@/domain/work-orders/utils/completion-doc-service-gate";
|
||||
import { useAuthContext } from "@/providers/auth-context";
|
||||
|
||||
export default function WorkOrdersListPage() {
|
||||
|
|
@ -19,6 +18,11 @@ export default function WorkOrdersListPage() {
|
|||
const page = useWorkOrdersListPage();
|
||||
const { filters, tableData, activeDocRow } = page;
|
||||
|
||||
const openCompletionDoc = (row: WorkOrderTableRow) => {
|
||||
if (!hasCompletionDocService(row.pm)) return;
|
||||
page.setDocRow(row);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="flex h-full flex-col overflow-hidden p-2.5">
|
||||
<PageHeader
|
||||
|
|
@ -84,25 +88,42 @@ export default function WorkOrdersListPage() {
|
|||
weekMonday={filters.weekMonday}
|
||||
onWeekChange={filters.setWeekMonday}
|
||||
onOpenSlideOver={page.handleOpenSlideOver}
|
||||
onOpenCompDoc={page.setDocRow}
|
||||
onOpenCompDoc={openCompletionDoc}
|
||||
onOpenMedia={page.setMediaRow}
|
||||
onClearSearch={() => filters.handleSearchChange("")}
|
||||
onOpenAdvancedFilters={() => filters.setAdvOpen(true)}
|
||||
onPatch={page.patchField}
|
||||
/>
|
||||
|
||||
<NewWoWizard open={page.wizardOpen} onOpenChange={page.setWizardOpen} users={page.users} />
|
||||
|
||||
<WorkOrdersAdvancedSearchSheet
|
||||
open={filters.advOpen}
|
||||
onOpenChange={filters.setAdvOpen}
|
||||
initial={filters.advInitial}
|
||||
onApply={filters.applyAdvancedFilters}
|
||||
onClearAll={filters.clearAdvancedFilters}
|
||||
sites={page.locations}
|
||||
<WorkOrdersListOverlays
|
||||
wizardOpen={page.wizardOpen}
|
||||
onWizardOpenChange={page.setWizardOpen}
|
||||
users={page.users}
|
||||
sites={page.locations}
|
||||
pmTypes={page.pmTypes}
|
||||
vendorTechs={page.vendorTechs}
|
||||
advOpen={filters.advOpen}
|
||||
onAdvOpenChange={filters.setAdvOpen}
|
||||
advInitial={filters.advInitial}
|
||||
onApplyAdvanced={filters.applyAdvancedFilters}
|
||||
onClearAdvanced={filters.clearAdvancedFilters}
|
||||
activeDocRow={activeDocRow}
|
||||
onDocOpenChange={(open) => {
|
||||
if (!open) page.setDocRow(null);
|
||||
}}
|
||||
completionDocs={page.completionDocs}
|
||||
onSaveCompDoc={async (data, status) => {
|
||||
if (!activeDocRow) return;
|
||||
await page.saveCompletionDoc(activeDocRow, data, status);
|
||||
}}
|
||||
onDateCompletedChange={async (date) => {
|
||||
if (!activeDocRow) return;
|
||||
await page.patchFieldAsync(activeDocRow.id, { completedDate: date });
|
||||
}}
|
||||
activeMediaRow={page.activeMediaRow}
|
||||
onMediaOpenChange={(open) => {
|
||||
if (!open) page.setMediaRow(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<WorkOrderSlideOver
|
||||
|
|
@ -113,8 +134,8 @@ export default function WorkOrdersListPage() {
|
|||
vendors={page.vendors}
|
||||
pmTypes={page.pmTypes}
|
||||
allOrders={tableData.rows}
|
||||
onSave={(id, patch, options) => page.patchField(id, patch, options)}
|
||||
onOpenCompletionDoc={page.setDocRow}
|
||||
onSave={page.patchField}
|
||||
onOpenCompletionDoc={openCompletionDoc}
|
||||
onCancelWO={page.setConfirmCancel}
|
||||
onDuplicateFound={(existing) => page.handleOpenSlideOver(existing, false)}
|
||||
saving={page.isPatchPending}
|
||||
|
|
@ -128,31 +149,6 @@ export default function WorkOrdersListPage() {
|
|||
onKeepWO={() => page.setConfirmCancel(null)}
|
||||
onConfirm={page.handleConfirmCancel}
|
||||
/>
|
||||
|
||||
{page.activeMediaRow != null && (
|
||||
<MediaUploadModal
|
||||
row={page.activeMediaRow}
|
||||
open={!!page.activeMediaRow}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) page.setMediaRow(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeDocRow != null && (
|
||||
<CompDocDialog
|
||||
row={activeDocRow}
|
||||
open={!!activeDocRow}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) page.setDocRow(null);
|
||||
}}
|
||||
data={page.completionDocs[String(activeDocRow.id)]}
|
||||
onSave={(data, status) => page.saveCompletionDoc(activeDocRow, data, status)}
|
||||
onDateCompletedChange={async (date) => {
|
||||
await page.patchFieldAsync(activeDocRow.id, { completedDate: date });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,36 +8,18 @@ import {
|
|||
formatVendorPortalDateTime,
|
||||
vendorPortalStatusClass,
|
||||
} from "@/domain/vendor-portal/lib/status-helpers";
|
||||
import type { VendorPortalDispatchSummary } from "@/domain/vendor-portal/types/vendor-portal";
|
||||
import {
|
||||
VENDOR_PORTAL_DISPATCH_FILTERS,
|
||||
applyVendorPortalDispatchFilter,
|
||||
getVendorPortalDispatchFilter,
|
||||
type VendorPortalDispatchFilterKey,
|
||||
} from "@/domain/vendor-portal/lib/dispatch-filters";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
|
||||
const FILTERS = [
|
||||
{
|
||||
key: "open",
|
||||
label: "Open",
|
||||
match: (dispatch: VendorPortalDispatchSummary) =>
|
||||
dispatch.status !== "Completed" &&
|
||||
dispatch.status !== "Verified" &&
|
||||
dispatch.status !== "Cancelled",
|
||||
},
|
||||
{
|
||||
key: "completed",
|
||||
label: "Completed",
|
||||
match: (dispatch: VendorPortalDispatchSummary) =>
|
||||
dispatch.status === "Completed" || dispatch.status === "Verified",
|
||||
},
|
||||
{
|
||||
key: "cancelled",
|
||||
label: "Cancelled",
|
||||
match: (dispatch: VendorPortalDispatchSummary) => dispatch.status === "Cancelled",
|
||||
},
|
||||
{ key: "all", label: "All", match: () => true },
|
||||
] as const;
|
||||
|
||||
export default function VendorPortalDashboardPage() {
|
||||
const { token } = useVendorPortal();
|
||||
const navigate = useNavigate();
|
||||
const [filter, setFilter] = useState<(typeof FILTERS)[number]["key"]>("open");
|
||||
const [filter, setFilter] = useState<VendorPortalDispatchFilterKey>("open");
|
||||
|
||||
const {
|
||||
data: dispatches = [],
|
||||
|
|
@ -49,8 +31,11 @@ export default function VendorPortalDashboardPage() {
|
|||
enabled: Boolean(token),
|
||||
});
|
||||
|
||||
const activeFilter = FILTERS.find((item) => item.key === filter) ?? FILTERS[0];
|
||||
const filtered = useMemo(() => dispatches.filter(activeFilter.match), [dispatches, activeFilter]);
|
||||
const activeFilter = getVendorPortalDispatchFilter(filter);
|
||||
const filtered = useMemo(
|
||||
() => applyVendorPortalDispatchFilter(dispatches, activeFilter),
|
||||
[dispatches, activeFilter],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -60,7 +45,7 @@ export default function VendorPortalDashboardPage() {
|
|||
Active dispatches and work orders assigned to your company.
|
||||
</div>
|
||||
<div className="vp-actions" style={{ marginTop: 0 }}>
|
||||
{FILTERS.map((item) => (
|
||||
{VENDOR_PORTAL_DISPATCH_FILTERS.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -4,8 +4,10 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|||
import { Text } from "@/components/ui/text";
|
||||
import { DispatchDetailSections } from "@/app/v/[token]/dispatch/_components/dispatch-detail-sections";
|
||||
import { DispatchSummaryCard } from "@/app/v/[token]/dispatch/_components/dispatch-summary-card";
|
||||
import { RefuseDispatchDialog } from "@/app/v/[token]/dispatch/_components/refuse-dispatch-dialog";
|
||||
import { useVendorPortal } from "@/app/v/_components/vendor-portal-context";
|
||||
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
|
||||
import { isVendorPortalLockedStatus } from "@/domain/vendor-portal/lib/status-helpers";
|
||||
import type { VendorPortalDispatchDetail } from "@/domain/vendor-portal/types/vendor-portal";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
|
||||
|
|
@ -16,6 +18,7 @@ export default function VendorPortalDispatchPage() {
|
|||
const queryClient = useQueryClient();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [actionError, setActionError] = useState("");
|
||||
const [refuseOpen, setRefuseOpen] = useState(false);
|
||||
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: queryKeys.vendorPortal.dispatch(token, dispatchId),
|
||||
|
|
@ -28,6 +31,11 @@ export default function VendorPortalDispatchPage() {
|
|||
await queryClient.invalidateQueries({ queryKey: queryKeys.vendorPortal.dispatches(token) });
|
||||
}, [refetch, queryClient, token]);
|
||||
|
||||
const pollEvidenceDocument = useCallback(
|
||||
(documentId: number) => vendorPortalApi.getDocumentStatus(token, dispatchId, documentId),
|
||||
[token, dispatchId],
|
||||
);
|
||||
|
||||
const runAction = async (action: () => Promise<void>, confirmMsg?: string) => {
|
||||
if (confirmMsg && !window.confirm(confirmMsg)) return;
|
||||
setActionError("");
|
||||
|
|
@ -74,7 +82,7 @@ export default function VendorPortalDispatchPage() {
|
|||
|
||||
if (!data) return null;
|
||||
|
||||
const locked = data.status === "Verified" || data.status === "Cancelled";
|
||||
const locked = isVendorPortalLockedStatus(data.status);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -84,6 +92,7 @@ export default function VendorPortalDispatchPage() {
|
|||
locked={locked}
|
||||
busy={busy}
|
||||
onAccept={() => void runAction(() => vendorPortalApi.accept(token, dispatchId))}
|
||||
onRefuse={() => setRefuseOpen(true)}
|
||||
onStart={() =>
|
||||
void runAction(() => vendorPortalApi.changeStatus(token, dispatchId, "In Progress"))
|
||||
}
|
||||
|
|
@ -105,8 +114,16 @@ export default function VendorPortalDispatchPage() {
|
|||
data={data}
|
||||
locked={locked}
|
||||
refresh={refresh}
|
||||
pollEvidenceDocument={pollEvidenceDocument}
|
||||
updateLocal={updateLocal}
|
||||
/>
|
||||
|
||||
<RefuseDispatchDialog
|
||||
open={refuseOpen}
|
||||
dispatchId={dispatchId}
|
||||
onClose={() => setRefuseOpen(false)}
|
||||
onRefused={() => setActionError("")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,10 @@ import { CommentsSection } from "@/app/v/[token]/dispatch/_components/comments-s
|
|||
import { SignoffSection } from "@/app/v/[token]/dispatch/_components/signoff-section";
|
||||
import { UpliftRequestsSection } from "@/app/v/[token]/dispatch/_components/uplift-requests-section";
|
||||
import { VendorPortalDocuments } from "@/app/v/_components/vendor-portal-documents";
|
||||
import type { VendorPortalDispatchDetail } from "@/domain/vendor-portal/types/vendor-portal";
|
||||
import type {
|
||||
VendorPortalDispatchDetail,
|
||||
VendorPortalDocumentStatus,
|
||||
} from "@/domain/vendor-portal/types/vendor-portal";
|
||||
|
||||
type DispatchDetailSectionsProps = {
|
||||
token: string;
|
||||
|
|
@ -12,6 +15,7 @@ type DispatchDetailSectionsProps = {
|
|||
data: VendorPortalDispatchDetail;
|
||||
locked: boolean;
|
||||
refresh: () => Promise<void>;
|
||||
pollEvidenceDocument: (documentId: number) => Promise<VendorPortalDocumentStatus | undefined>;
|
||||
updateLocal: (
|
||||
updater: (current: VendorPortalDispatchDetail) => VendorPortalDispatchDetail,
|
||||
) => void;
|
||||
|
|
@ -23,6 +27,7 @@ export function DispatchDetailSections({
|
|||
data,
|
||||
locked,
|
||||
refresh,
|
||||
pollEvidenceDocument,
|
||||
updateLocal,
|
||||
}: DispatchDetailSectionsProps) {
|
||||
return (
|
||||
|
|
@ -65,6 +70,7 @@ export function DispatchDetailSections({
|
|||
upliftRequests={data.upliftRequests ?? []}
|
||||
locked={locked}
|
||||
onChanged={refresh}
|
||||
pollDocument={pollEvidenceDocument}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -76,6 +82,7 @@ export function DispatchDetailSections({
|
|||
existing={data.signoffs ?? []}
|
||||
signoffType="customer"
|
||||
nameLabel="Customer name"
|
||||
submissionLocked={locked}
|
||||
onAdded={() => void refresh()}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -88,6 +95,7 @@ export function DispatchDetailSections({
|
|||
existing={data.signoffs ?? []}
|
||||
signoffType="vendor"
|
||||
nameLabel="Your name"
|
||||
submissionLocked={locked}
|
||||
onAdded={() => void refresh()}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,166 @@
|
|||
import { Text } from "@/components/ui/text";
|
||||
import { formatVendorPortalDateTime } from "@/domain/vendor-portal/lib/status-helpers";
|
||||
import type { VendorPortalDispatchDetail } from "@/domain/vendor-portal/types/vendor-portal";
|
||||
|
||||
type DispatchSnapshotDetailsProps = {
|
||||
data: VendorPortalDispatchDetail;
|
||||
locked: boolean;
|
||||
};
|
||||
|
||||
function resolveCanonicalService(workOrder: VendorPortalDispatchDetail["workOrder"]): string {
|
||||
if (workOrder?.service) {
|
||||
return workOrder.service;
|
||||
}
|
||||
|
||||
return [workOrder?.trade, workOrder?.subTrade].filter(Boolean).join(" · ");
|
||||
}
|
||||
|
||||
function DispatchCommercialDetails({ data }: Pick<DispatchSnapshotDetailsProps, "data">) {
|
||||
const service = resolveCanonicalService(data.workOrder);
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
{data.workOrder != null && Boolean(data.workOrder.internalWONumber) && (
|
||||
<div className="vp-row">
|
||||
<strong>WO #</strong>
|
||||
<span>{data.workOrder.internalWONumber}</span>
|
||||
</div>
|
||||
)}
|
||||
{Boolean(service) && (
|
||||
<div className="vp-row">
|
||||
<strong>Service</strong>
|
||||
<span>{service}</span>
|
||||
</div>
|
||||
)}
|
||||
{Boolean(data.poNumber) && (
|
||||
<div className="vp-row">
|
||||
<strong>PO #</strong>
|
||||
<span>{data.poNumber}</span>
|
||||
</div>
|
||||
)}
|
||||
{data.nteAmount != null && (
|
||||
<div className="vp-row">
|
||||
<strong>NTE</strong>
|
||||
<span>${Number(data.nteAmount).toFixed(2)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="vp-row">
|
||||
<strong>Invoice</strong>
|
||||
<span>
|
||||
{data.invoiceStatus || "Not Submitted"}
|
||||
{data.invoiceNumber ? ` · ${data.invoiceNumber}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="vp-row">
|
||||
<strong>Payment</strong>
|
||||
<span>{data.paymentStatus || "Unavailable"}</span>
|
||||
</div>
|
||||
{Boolean(data.scheduledDate) && (
|
||||
<div className="vp-row">
|
||||
<strong>Scheduled</strong>
|
||||
<span>{formatVendorPortalDateTime(data.scheduledDate)}</span>
|
||||
</div>
|
||||
)}
|
||||
{Boolean(data.statusUpdatedAt) && (
|
||||
<div className="vp-row">
|
||||
<strong>Status updated</strong>
|
||||
<span>{formatVendorPortalDateTime(data.statusUpdatedAt)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DispatchLocationDetails({ data }: Pick<DispatchSnapshotDetailsProps, "data">) {
|
||||
if (data.location == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const address = [
|
||||
data.location.address,
|
||||
data.location.city,
|
||||
data.location.state,
|
||||
data.location.zipCode,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text variant="heading" sx={{ marginTop: 2 }}>
|
||||
Location
|
||||
</Text>
|
||||
{Boolean(data.workOrder?.siteCode) && (
|
||||
<div className="vp-row">
|
||||
<strong>Site code</strong>
|
||||
<span>{data.workOrder?.siteCode}</span>
|
||||
</div>
|
||||
)}
|
||||
<div>{data.location.name}</div>
|
||||
<div className="vp-muted">{address}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DispatcherContactDetails({ data }: Pick<DispatchSnapshotDetailsProps, "data">) {
|
||||
const contact = data.dispatcherContact;
|
||||
if (!contact || (!contact.name && !contact.email && !contact.phone)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text variant="heading" sx={{ marginTop: 2 }}>
|
||||
Dispatcher
|
||||
</Text>
|
||||
{Boolean(contact.name) && (
|
||||
<div className="vp-row">
|
||||
<strong>Name</strong>
|
||||
<span>{contact.name}</span>
|
||||
</div>
|
||||
)}
|
||||
{Boolean(contact.email) && (
|
||||
<div className="vp-row">
|
||||
<strong>Email</strong>
|
||||
<span>
|
||||
<a href={`mailto:${contact.email}`}>{contact.email}</a>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{Boolean(contact.phone) && (
|
||||
<div className="vp-row">
|
||||
<strong>Phone</strong>
|
||||
<span>
|
||||
<a href={`tel:${contact.phone}`}>{contact.phone}</a>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function DispatchSnapshotDetails({ data, locked }: DispatchSnapshotDetailsProps) {
|
||||
const description = data.description || data.workOrder?.description;
|
||||
|
||||
return (
|
||||
<>
|
||||
<DispatchCommercialDetails data={data} />
|
||||
<DispatchLocationDetails data={data} />
|
||||
<DispatcherContactDetails data={data} />
|
||||
{locked && (
|
||||
<Text variant="feedback" className="vp-muted" sx={{ marginTop: 2 }}>
|
||||
This dispatch is {data.status.toLowerCase()} and locked to further changes. Comments
|
||||
remain open.
|
||||
</Text>
|
||||
)}
|
||||
{Boolean(description) && (
|
||||
<>
|
||||
<Text variant="heading" sx={{ marginTop: 2 }}>
|
||||
Description
|
||||
</Text>
|
||||
<div>{description}</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ type DispatchStatusActionsProps = {
|
|||
locked: boolean;
|
||||
busy: boolean;
|
||||
onAccept: () => void;
|
||||
onRefuse: () => void;
|
||||
onStart: () => void;
|
||||
onComplete: () => void;
|
||||
onRequestCancel: () => void;
|
||||
|
|
@ -13,6 +14,7 @@ export function DispatchStatusActions({
|
|||
locked,
|
||||
busy,
|
||||
onAccept,
|
||||
onRefuse,
|
||||
onStart,
|
||||
onComplete,
|
||||
onRequestCancel,
|
||||
|
|
@ -20,9 +22,19 @@ export function DispatchStatusActions({
|
|||
return (
|
||||
<div className="vp-actions">
|
||||
{status === "Sent" && (
|
||||
<button type="button" className="vp-btn vp-btn-success" disabled={busy} onClick={onAccept}>
|
||||
Accept Dispatch
|
||||
</button>
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="vp-btn vp-btn-success"
|
||||
disabled={busy}
|
||||
onClick={onAccept}
|
||||
>
|
||||
Accept Dispatch
|
||||
</button>
|
||||
<button type="button" className="vp-btn vp-btn-warn" disabled={busy} onClick={onRefuse}>
|
||||
Refuse Dispatch
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{status === "Acknowledged" && (
|
||||
<button type="button" className="vp-btn vp-btn-primary" disabled={busy} onClick={onStart}>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
import { Link } from "react-router";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import {
|
||||
formatVendorPortalDateTime,
|
||||
vendorPortalStatusClass,
|
||||
} from "@/domain/vendor-portal/lib/status-helpers";
|
||||
import { vendorPortalStatusClass } from "@/domain/vendor-portal/lib/status-helpers";
|
||||
import type { VendorPortalDispatchDetail } from "@/domain/vendor-portal/types/vendor-portal";
|
||||
import { DispatchSnapshotDetails } from "@/app/v/[token]/dispatch/_components/dispatch-snapshot-details";
|
||||
import { DispatchStatusActions } from "@/app/v/[token]/dispatch/_components/dispatch-status-actions";
|
||||
|
||||
type DispatchSummaryCardProps = {
|
||||
|
|
@ -13,6 +11,7 @@ type DispatchSummaryCardProps = {
|
|||
locked: boolean;
|
||||
busy: boolean;
|
||||
onAccept: () => void;
|
||||
onRefuse: () => void;
|
||||
onStart: () => void;
|
||||
onComplete: () => void;
|
||||
onRequestCancel: () => void;
|
||||
|
|
@ -24,6 +23,7 @@ export function DispatchSummaryCard({
|
|||
locked,
|
||||
busy,
|
||||
onAccept,
|
||||
onRefuse,
|
||||
onStart,
|
||||
onComplete,
|
||||
onRequestCancel,
|
||||
|
|
@ -43,77 +43,14 @@ export function DispatchSummaryCard({
|
|||
<span className={vendorPortalStatusClass(data.status)}>{data.status}</span>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 12 }}>
|
||||
{data.workOrder != null && Boolean(data.workOrder.internalWONumber) && (
|
||||
<div className="vp-row">
|
||||
<strong>WO #</strong>
|
||||
<span>{data.workOrder.internalWONumber}</span>
|
||||
</div>
|
||||
)}
|
||||
{Boolean(data.poNumber) && (
|
||||
<div className="vp-row">
|
||||
<strong>PO #</strong>
|
||||
<span>{data.poNumber}</span>
|
||||
</div>
|
||||
)}
|
||||
{data.nteAmount != null && (
|
||||
<div className="vp-row">
|
||||
<strong>NTE</strong>
|
||||
<span>${Number(data.nteAmount).toFixed(2)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="vp-row">
|
||||
<strong>Invoice</strong>
|
||||
<span>
|
||||
{data.invoiceStatus || "Not Submitted"}
|
||||
{data.invoiceNumber ? ` · ${data.invoiceNumber}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="vp-row">
|
||||
<strong>Payment</strong>
|
||||
<span>{data.paymentStatus || "Unavailable"}</span>
|
||||
</div>
|
||||
{Boolean(data.scheduledDate) && (
|
||||
<div className="vp-row">
|
||||
<strong>Scheduled</strong>
|
||||
<span>{formatVendorPortalDateTime(data.scheduledDate)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{data.location != null && (
|
||||
<>
|
||||
<Text variant="heading" sx={{ marginTop: 2 }}>
|
||||
Location
|
||||
</Text>
|
||||
<div>{data.location.name}</div>
|
||||
<div className="vp-muted">
|
||||
{[
|
||||
data.location.address,
|
||||
data.location.city,
|
||||
data.location.state,
|
||||
data.location.zipCode,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ")}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{Boolean(data.description || data.workOrder?.description) && (
|
||||
<>
|
||||
<Text variant="heading" sx={{ marginTop: 2 }}>
|
||||
Description
|
||||
</Text>
|
||||
<div>{data.description || data.workOrder?.description}</div>
|
||||
</>
|
||||
)}
|
||||
<DispatchSnapshotDetails data={data} locked={locked} />
|
||||
|
||||
<DispatchStatusActions
|
||||
status={data.status}
|
||||
locked={locked}
|
||||
busy={busy}
|
||||
onAccept={onAccept}
|
||||
onRefuse={onRefuse}
|
||||
onStart={onStart}
|
||||
onComplete={onComplete}
|
||||
onRequestCancel={onRequestCancel}
|
||||
|
|
|
|||