chore(work-orders): merge origin/dev into duplicate WO number dialog branch
Reconcile merge conflicts keeping SH-189 duplicate dialog wiring and dev overlays. Extend WorkOrdersListOverlays with wizard duplicate props. Remove obsolete WorkOrdersListMediaOverlay.
22
.github/workflows/ci.yaml
vendored
|
|
@ -41,3 +41,25 @@ jobs:
|
||||||
cache: npm
|
cache: npm
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
- run: npm run verify
|
- run: npm run verify
|
||||||
|
|
||||||
|
vendor-visual-regression:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container: mcr.microsoft.com/playwright:v1.61.1-noble
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "24"
|
||||||
|
cache: npm
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm run test:e2e:visual
|
||||||
|
- name: Upload visual diff artifacts
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: vendor-visual-regression-diffs
|
||||||
|
path: |
|
||||||
|
test-results/visual
|
||||||
|
playwright-report-visual
|
||||||
|
if-no-files-found: ignore
|
||||||
|
retention-days: 14
|
||||||
|
|
|
||||||
1
.gitignore
vendored
|
|
@ -10,6 +10,7 @@
|
||||||
/coverage
|
/coverage
|
||||||
/test-results
|
/test-results
|
||||||
/playwright-report
|
/playwright-report
|
||||||
|
/playwright-report-visual
|
||||||
/blob-report
|
/blob-report
|
||||||
/playwright/.cache
|
/playwright/.cache
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ dist
|
||||||
build
|
build
|
||||||
node_modules
|
node_modules
|
||||||
coverage
|
coverage
|
||||||
|
playwright-report-visual
|
||||||
package-lock.json
|
package-lock.json
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
|
@ -10,4 +11,4 @@ src/pages/**
|
||||||
src/**/*.js
|
src/**/*.js
|
||||||
src/**/*.jsx
|
src/**/*.jsx
|
||||||
src/**/*.css
|
src/**/*.css
|
||||||
!src/styles/globals.css
|
!src/styles/globals.css
|
||||||
|
|
|
||||||
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." },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
147
e2e/vendors/vendors.spec.ts
vendored
|
|
@ -306,11 +306,18 @@ test.describe("Vendor directory prototype parity", () => {
|
||||||
test("covers status tabs, global counts, search, facets, table fields, and filtered footer", async ({
|
test("covers status tabs, global counts, search, facets, table fields, and filtered footer", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
await page.setViewportSize({ width: 1440, height: 1000 });
|
||||||
const state = await mockVendorApi(page);
|
const state = await mockVendorApi(page);
|
||||||
await page.goto("/vendors");
|
await page.goto("/vendors");
|
||||||
|
|
||||||
await expect(page.getByRole("heading", { name: "Vendors / Technicians" })).toBeVisible();
|
const heading = page.getByRole("heading", { name: "Vendors / Technicians" });
|
||||||
await expect(page.getByText("3 vendor entries · 2 active")).toBeVisible();
|
await expect(heading).toBeVisible();
|
||||||
|
await expect(heading).toHaveCSS("font-family", /Montserrat/);
|
||||||
|
await expect(heading).toHaveCSS("font-size", "30px");
|
||||||
|
await expect(heading).toHaveCSS("font-weight", "800");
|
||||||
|
await expect(heading).toHaveCSS("line-height", "33px");
|
||||||
|
await expect(heading).toHaveCSS("letter-spacing", "-0.6px");
|
||||||
|
await expect(page.getByText("3 technicians · 2 active")).toBeVisible();
|
||||||
await expect(page.getByRole("tab", { name: "Active (2)" })).toHaveAttribute(
|
await expect(page.getByRole("tab", { name: "Active (2)" })).toHaveAttribute(
|
||||||
"aria-selected",
|
"aria-selected",
|
||||||
"true",
|
"true",
|
||||||
|
|
@ -323,52 +330,58 @@ test.describe("Vendor directory prototype parity", () => {
|
||||||
"Phone",
|
"Phone",
|
||||||
"Email",
|
"Email",
|
||||||
"Total Jobs",
|
"Total Jobs",
|
||||||
"Status",
|
|
||||||
]) {
|
]) {
|
||||||
await expect(page.getByRole("columnheader", { name: heading })).toBeVisible();
|
await expect(page.getByRole("columnheader", { name: heading })).toBeVisible();
|
||||||
}
|
}
|
||||||
|
await expect(page.getByRole("columnheader", { name: "Status" })).toBeVisible();
|
||||||
|
const activeTabBox = await page.getByRole("tab", { name: "Active (2)" }).boundingBox();
|
||||||
|
expect(activeTabBox).toMatchObject({ x: 256, y: 159.5, height: 36 });
|
||||||
|
const tableContainerBox = await page.locator("table").locator("..").boundingBox();
|
||||||
|
expect(tableContainerBox).toMatchObject({ x: 260, y: 212.5, width: 1164 });
|
||||||
|
expect(tableContainerBox?.height).toBeCloseTo(738, 0);
|
||||||
|
expect((await page.locator("table").boundingBox())?.width).toBe(1550);
|
||||||
|
expect((await page.getByRole("columnheader", { name: "Company" }).boundingBox())?.height).toBe(
|
||||||
|
47,
|
||||||
|
);
|
||||||
|
expect((await page.locator("tbody tr").first().boundingBox())?.height).toBe(52);
|
||||||
await expect(page.getByText("Gateway Plumbing")).toBeVisible();
|
await expect(page.getByText("Gateway Plumbing")).toBeVisible();
|
||||||
await expect(page.getByText("+1")).toBeVisible();
|
await expect(page.getByText("+1")).toBeVisible();
|
||||||
await expect(page.getByText("2 active vendor entries · 2 active · 1 inactive")).toBeVisible();
|
await expect(page.getByText("2 active technicians")).toBeVisible();
|
||||||
|
await expect(page.getByText("2 active · 1 inactive")).toBeVisible();
|
||||||
await captureVisual(page, "/tmp/seahaven-vendor-implemented-list.png");
|
await captureVisual(page, "/tmp/seahaven-vendor-implemented-list.png");
|
||||||
|
|
||||||
await page.getByPlaceholder("Search name, company, trade...").fill("metro");
|
await page.getByPlaceholder("Search name, company, trade…").fill("metro");
|
||||||
await expect(page.getByText("Metro HVAC")).toBeVisible();
|
await expect(page.getByText("Metro HVAC")).toBeVisible();
|
||||||
await expect(page.getByText(/1 active vendor entry · filtered/)).toBeVisible();
|
await expect(page.getByText(/1 active technician · filtered/)).toBeVisible();
|
||||||
expect(state.listUrls.some((url) => new URL(url).searchParams.get("search") === "metro")).toBe(
|
expect(state.listUrls.some((url) => new URL(url).searchParams.get("search") === "metro")).toBe(
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
|
||||||
await page.getByPlaceholder("Search name, company, trade...").fill("clayton");
|
await page.getByPlaceholder("Search name, company, trade…").fill("clayton");
|
||||||
await expect(page.getByText("Metro HVAC")).toBeVisible();
|
await expect(page.getByText("Metro HVAC")).toBeVisible();
|
||||||
|
|
||||||
await page.getByPlaceholder("Search name, company, trade...").clear();
|
await page.getByPlaceholder("Search name, company, trade…").clear();
|
||||||
await page.getByRole("button", { name: "Open vendor filters" }).click();
|
await page.getByRole("button", { name: "Open vendor filters" }).click();
|
||||||
await expect(page.getByRole("heading", { name: "Filters" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Filters" })).toBeVisible();
|
||||||
await captureVisual(page, "/tmp/seahaven-vendor-implemented-filters.png");
|
await captureVisual(page, "/tmp/seahaven-vendor-implemented-filters.png");
|
||||||
await page.getByRole("button", { name: /Company/ }).click();
|
await page.getByRole("button", { name: /Company/ }).click();
|
||||||
await page.getByPlaceholder("All companies").click();
|
await page.getByPlaceholder("All companies").click();
|
||||||
await page.getByRole("option", { name: /Gateway Plumbing/ }).click();
|
await page.getByRole("option", { name: /Gateway Plumbing/ }).click();
|
||||||
await page.getByRole("button", { name: "Apply" }).click();
|
await page.getByRole("button", { name: "Apply filters" }).click();
|
||||||
await expect(page.getByText("Gateway Plumbing").first()).toBeVisible();
|
await expect(page.getByText("Gateway Plumbing").first()).toBeVisible();
|
||||||
await expect(page.getByText(/1 active vendor entry · filtered/)).toBeVisible();
|
await expect(page.getByText("1 technician · filtered by")).toBeVisible();
|
||||||
|
await expect(page.getByText(/1 active technician · filtered/)).toBeVisible();
|
||||||
expect(
|
expect(
|
||||||
state.listUrls.some(
|
state.listUrls.some(
|
||||||
(url) => new URL(url).searchParams.get("companies[0]") === "Gateway Plumbing",
|
(url) => new URL(url).searchParams.get("companies[0]") === "Gateway Plumbing",
|
||||||
),
|
),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Deactivate vendor" }).first().click();
|
|
||||||
await expect(page.getByRole("dialog", { name: "Deactivate Vendor" })).toContainText(
|
|
||||||
"history will be preserved",
|
|
||||||
);
|
|
||||||
await page.getByRole("button", { name: "Deactivate", exact: true }).click();
|
|
||||||
await expect.poll(() => state.deletedId).toBe("1");
|
|
||||||
|
|
||||||
await page.getByRole("tab", { name: "Inactive (1)" }).click();
|
await page.getByRole("tab", { name: "Inactive (1)" }).click();
|
||||||
await expect(page.getByText("No vendors match the current filters.")).toBeVisible();
|
await expect(page.getByText("No inactive technicians found")).toBeVisible();
|
||||||
await page.getByRole("button", { name: "Clear all" }).click();
|
await page.getByRole("button", { name: "Clear all" }).click();
|
||||||
await expect(page.getByText("Drain Cleaning Bros")).toBeVisible();
|
await expect(page.getByText("Drain Cleaning Bros")).toBeVisible();
|
||||||
|
await expect(page.getByRole("columnheader", { name: "Status" })).toBeVisible();
|
||||||
await expect(page.getByRole("button", { name: "Deactivate vendor" })).toHaveCount(0);
|
await expect(page.getByRole("button", { name: "Deactivate vendor" })).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -378,33 +391,35 @@ test.describe("Vendor directory prototype parity", () => {
|
||||||
const state = await mockVendorApi(page);
|
const state = await mockVendorApi(page);
|
||||||
await page.goto("/vendors");
|
await page.goto("/vendors");
|
||||||
|
|
||||||
await page.getByRole("button", { name: "+ Add Vendor" }).click();
|
await page.getByRole("button", { name: "Add Vendor", exact: true }).click();
|
||||||
await expect(
|
await expect(page.getByText("Register a vendor company and its technicians.")).toBeVisible();
|
||||||
page.getByText("Register a company and add zero, one, or multiple technicians."),
|
|
||||||
).toBeVisible();
|
|
||||||
await captureVisual(page, "/tmp/seahaven-vendor-implemented-add.png");
|
await captureVisual(page, "/tmp/seahaven-vendor-implemented-add.png");
|
||||||
await expect(page.getByRole("button", { name: "Add Vendor Company" }).last()).toBeDisabled();
|
await expect(
|
||||||
|
page.getByRole("button", { name: "Add Vendor", exact: true }).last(),
|
||||||
|
).toBeEnabled();
|
||||||
|
|
||||||
await page.getByRole("combobox", { name: "Company" }).click();
|
await page.getByRole("combobox", { name: "Company" }).click();
|
||||||
await page.getByRole("option", { name: "Gateway Plumbing" }).click();
|
await page.getByRole("option", { name: "Gateway Plumbing" }).click();
|
||||||
await expect(page.getByLabel("Company Phone")).toHaveValue("314-555-0100");
|
await expect(page.getByLabel("Company Phone (optional)")).toHaveValue("314-555-0100");
|
||||||
await expect(page.getByRole("textbox", { name: "Email", exact: true })).toHaveValue(
|
await expect(page.getByRole("textbox", { name: "Email (optional)", exact: true })).toHaveValue(
|
||||||
"dispatch@gateway.test",
|
"dispatch@gateway.test",
|
||||||
);
|
);
|
||||||
await expect(page.getByLabel("Address")).toHaveValue("1 Market St");
|
await expect(page.getByLabel("Address (optional)")).toHaveValue("1 Market St");
|
||||||
await expect(page.getByLabel("Google Maps URL")).toHaveValue("https://maps.google.com/gateway");
|
await expect(page.getByLabel("Google Maps URL (optional)")).toHaveValue(
|
||||||
|
"https://maps.google.com/gateway",
|
||||||
|
);
|
||||||
await expect(page.getByLabel("Preferred Contact")).toHaveCount(0);
|
await expect(page.getByLabel("Preferred Contact")).toHaveCount(0);
|
||||||
await page.getByRole("button", { name: "Add technician" }).click();
|
await page.getByRole("button", { name: "Add technician" }).click();
|
||||||
await page.getByLabel("Technician Name").last().fill("New Technician");
|
await page.getByLabel("Technician name (optional)").last().fill("New Technician");
|
||||||
const tradeInput = page.getByRole("combobox", { name: "Add Trade" }).last();
|
const tradeInput = page.getByRole("combobox", { name: "Add Trade" }).last();
|
||||||
await tradeInput.fill("HVAC");
|
await tradeInput.fill("HVAC");
|
||||||
await tradeInput.press("Enter");
|
await tradeInput.press("Enter");
|
||||||
await tradeInput.fill("Plumbing");
|
await tradeInput.fill("Plumbing");
|
||||||
await tradeInput.press("Enter");
|
await tradeInput.press("Enter");
|
||||||
await page.getByRole("button", { name: "Move Plumbing up" }).click();
|
await page.getByRole("button", { name: "Move Plumbing up" }).click();
|
||||||
await page.getByLabel("Notes").fill("Created in browser E2E");
|
await page.getByLabel("Notes (optional)").fill("Created in browser E2E");
|
||||||
await page.getByRole("button", { name: "Add Vendor Company" }).last().click();
|
await page.getByRole("button", { name: "Add Vendor", exact: true }).last().click();
|
||||||
await expect(page.getByRole("dialog", { name: "Add Vendor Company" })).toHaveCount(0);
|
await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toHaveCount(0);
|
||||||
expect(state.updatedBody).toMatchObject({
|
expect(state.updatedBody).toMatchObject({
|
||||||
name: "Gateway Plumbing",
|
name: "Gateway Plumbing",
|
||||||
companyPhone: "(314) 555-0100",
|
companyPhone: "(314) 555-0100",
|
||||||
|
|
@ -429,7 +444,7 @@ test.describe("Vendor directory prototype parity", () => {
|
||||||
|
|
||||||
await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click();
|
await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click();
|
||||||
const detailDrawer = page.locator(".MuiDrawer-paper").last();
|
const detailDrawer = page.locator(".MuiDrawer-paper").last();
|
||||||
await expect(detailDrawer.getByRole("heading", { name: "Gateway Plumbing" })).toBeVisible();
|
await expect(detailDrawer.getByRole("heading", { name: "Adam Whyte" })).toBeVisible();
|
||||||
await expect(detailDrawer.getByText("Adam Whyte")).toBeVisible();
|
await expect(detailDrawer.getByText("Adam Whyte")).toBeVisible();
|
||||||
await expect(detailDrawer.getByText("72")).toBeVisible();
|
await expect(detailDrawer.getByText("72")).toBeVisible();
|
||||||
await expect(
|
await expect(
|
||||||
|
|
@ -438,9 +453,9 @@ test.describe("Vendor directory prototype parity", () => {
|
||||||
await captureVisual(page, "/tmp/seahaven-vendor-implemented-detail.png");
|
await captureVisual(page, "/tmp/seahaven-vendor-implemented-detail.png");
|
||||||
|
|
||||||
await detailDrawer.getByRole("button", { name: "Edit" }).click();
|
await detailDrawer.getByRole("button", { name: "Edit" }).click();
|
||||||
await expect(page).toHaveURL(/\/vendors\/edit\/1$/);
|
await expect(page).toHaveURL(/\/vendors$/);
|
||||||
await page.getByLabel("Notes").fill("Updated in browser E2E");
|
await detailDrawer.getByLabel("Notes (optional)").fill("Updated in browser E2E");
|
||||||
await page.getByRole("button", { name: "Save" }).click();
|
await detailDrawer.getByRole("button", { name: "Save changes" }).click();
|
||||||
await expect.poll(() => state.updatedBody?.notes).toBe("Updated in browser E2E");
|
await expect.poll(() => state.updatedBody?.notes).toBe("Updated in browser E2E");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -448,18 +463,18 @@ test.describe("Vendor directory prototype parity", () => {
|
||||||
const state = await mockVendorApi(page, { legacyGateway: true });
|
const state = await mockVendorApi(page, { legacyGateway: true });
|
||||||
await page.goto("/vendors");
|
await page.goto("/vendors");
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Deactivate vendor" }).first().click();
|
await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click();
|
||||||
|
await page.getByRole("switch", { name: "Active status" }).click();
|
||||||
await page.getByRole("button", { name: "Deactivate", exact: true }).click();
|
await page.getByRole("button", { name: "Deactivate", exact: true }).click();
|
||||||
await expect.poll(() => state.deletedId).toBe("1");
|
await expect.poll(() => state.deletedId).toBe("1");
|
||||||
|
|
||||||
await page.getByRole("tab", { name: "Inactive (2)" }).click();
|
await page.getByRole("tab", { name: "Inactive (2)" }).click();
|
||||||
await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click();
|
await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click();
|
||||||
const detailDrawer = page.locator(".MuiDrawer-paper").last();
|
const detailDrawer = page.locator(".MuiDrawer-paper").last();
|
||||||
await detailDrawer.getByRole("button", { name: "Edit" }).click();
|
await detailDrawer.getByRole("switch", { name: "Active status" }).click();
|
||||||
await expect(page).toHaveURL(/\/vendors\/edit\/1$/);
|
await detailDrawer.getByRole("button", { name: "Save changes" }).click();
|
||||||
await page.getByRole("switch", { name: "Inactive" }).first().click();
|
|
||||||
await page.getByRole("button", { name: "Save" }).click();
|
|
||||||
await expect.poll(() => state.updatedBody).toEqual({ isActive: true });
|
await expect.poll(() => state.updatedBody).toEqual({ isActive: true });
|
||||||
|
await detailDrawer.getByRole("button", { name: "Close drawer" }).click();
|
||||||
await page.getByRole("tab", { name: "Active (2)", exact: true }).click();
|
await page.getByRole("tab", { name: "Active (2)", exact: true }).click();
|
||||||
await expect(page.getByText("Gateway Plumbing").first()).toBeVisible();
|
await expect(page.getByText("Gateway Plumbing").first()).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
@ -468,7 +483,7 @@ test.describe("Vendor directory prototype parity", () => {
|
||||||
const state = await mockVendorApi(page);
|
const state = await mockVendorApi(page);
|
||||||
await page.goto("/vendors");
|
await page.goto("/vendors");
|
||||||
|
|
||||||
const addButton = page.getByRole("button", { name: "+ Add Vendor" });
|
const addButton = page.getByRole("button", { name: "Add Vendor", exact: true });
|
||||||
await addButton.focus();
|
await addButton.focus();
|
||||||
await page.keyboard.press("Enter");
|
await page.keyboard.press("Enter");
|
||||||
await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toBeVisible();
|
await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toBeVisible();
|
||||||
|
|
@ -477,10 +492,12 @@ test.describe("Vendor directory prototype parity", () => {
|
||||||
await expect(addButton).toBeFocused();
|
await expect(addButton).toBeFocused();
|
||||||
|
|
||||||
await addButton.click();
|
await addButton.click();
|
||||||
await expect(page.getByRole("button", { name: "Add Vendor Company" }).last()).toBeDisabled();
|
await expect(
|
||||||
|
page.getByRole("button", { name: "Add Vendor", exact: true }).last(),
|
||||||
|
).toBeEnabled();
|
||||||
await page.getByRole("combobox", { name: "Company" }).fill("Independent Vendor LLC");
|
await page.getByRole("combobox", { name: "Company" }).fill("Independent Vendor LLC");
|
||||||
await page.getByLabel("Company Phone").fill("3145550199");
|
await page.getByLabel("Company Phone (optional)").fill("3145550199");
|
||||||
await page.getByRole("button", { name: "Add Vendor Company" }).last().click();
|
await page.getByRole("button", { name: "Add Vendor", exact: true }).last().click();
|
||||||
|
|
||||||
await expect.poll(() => state.createdBody?.name).toBe("Independent Vendor LLC");
|
await expect.poll(() => state.createdBody?.name).toBe("Independent Vendor LLC");
|
||||||
expect(state.createdBody?.technicians).toEqual([]);
|
expect(state.createdBody?.technicians).toEqual([]);
|
||||||
|
|
@ -506,7 +523,8 @@ test.describe("Vendor directory prototype parity", () => {
|
||||||
const blockedState = await mockVendorApi(page, { deactivationBlocked: true });
|
const blockedState = await mockVendorApi(page, { deactivationBlocked: true });
|
||||||
await page.goto("/vendors");
|
await page.goto("/vendors");
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Deactivate vendor" }).first().click();
|
await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click();
|
||||||
|
await page.getByRole("switch", { name: "Active status" }).click();
|
||||||
const blockedDialog = page.getByRole("dialog", { name: "Deactivate Vendor" });
|
const blockedDialog = page.getByRole("dialog", { name: "Deactivate Vendor" });
|
||||||
await expect(blockedDialog).toContainText("WO-501 — Emergency boiler repair");
|
await expect(blockedDialog).toContainText("WO-501 — Emergency boiler repair");
|
||||||
await expect(blockedDialog.getByRole("button", { name: /^Deactivate$/ })).toBeDisabled();
|
await expect(blockedDialog.getByRole("button", { name: /^Deactivate$/ })).toBeDisabled();
|
||||||
|
|
@ -517,7 +535,8 @@ test.describe("Vendor directory prototype parity", () => {
|
||||||
|
|
||||||
const racedState = await mockVendorApi(page, { deleteConflict: true });
|
const racedState = await mockVendorApi(page, { deleteConflict: true });
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await page.getByRole("button", { name: "Deactivate vendor" }).first().click();
|
await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click();
|
||||||
|
await page.getByRole("switch", { name: "Active status" }).click();
|
||||||
await page
|
await page
|
||||||
.getByRole("dialog", { name: "Deactivate Vendor" })
|
.getByRole("dialog", { name: "Deactivate Vendor" })
|
||||||
.getByRole("button", {
|
.getByRole("button", {
|
||||||
|
|
@ -537,10 +556,25 @@ test.describe("Vendor directory prototype parity", () => {
|
||||||
await mockVendorApi(page, { listError: true });
|
await mockVendorApi(page, { listError: true });
|
||||||
await page.goto("/vendors");
|
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(
|
await expect(page.getByRole("main").getByRole("alert")).toContainText(
|
||||||
/Vendor directory unavailable|failed|server error/i,
|
/Vendor directory unavailable|failed|server error/i,
|
||||||
{ timeout: 15_000 },
|
{ timeout: 15_000 },
|
||||||
);
|
);
|
||||||
|
await captureVisual(page, "/tmp/seahaven-vendor-implemented-mobile-list.png");
|
||||||
await page.getByRole("button", { name: "Open vendor filters" }).click();
|
await page.getByRole("button", { name: "Open vendor filters" }).click();
|
||||||
const filterDrawer = page.locator(".MuiDrawer-paper").filter({ hasText: "Total Jobs" });
|
const filterDrawer = page.locator(".MuiDrawer-paper").filter({ hasText: "Total Jobs" });
|
||||||
await expect(filterDrawer).toBeVisible();
|
await expect(filterDrawer).toBeVisible();
|
||||||
|
|
@ -549,4 +583,23 @@ test.describe("Vendor directory prototype parity", () => {
|
||||||
await captureVisual(page, "/tmp/seahaven-vendor-implemented-mobile.png");
|
await captureVisual(page, "/tmp/seahaven-vendor-implemented-mobile.png");
|
||||||
await page.getByRole("button", { name: "Close filters" }).click();
|
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
|
|
@ -4048,9 +4048,9 @@
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fast-uri": {
|
"node_modules/fast-uri": {
|
||||||
"version": "3.1.4",
|
"version": "3.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
|
||||||
"integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==",
|
"integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
|
|
@ -4578,9 +4578,9 @@
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
|
||||||
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
|
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
|
|
@ -5302,9 +5302,9 @@
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.16",
|
"version": "3.3.18",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||||
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
|
|
@ -5561,9 +5561,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.18",
|
"version": "8.5.26",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.18.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
|
||||||
"integrity": "sha512-xdB1oSLHbz1vRWgCDalrCqEFTWzFlhqFC5tIHLMOSUIjhm3XXQ1qrFy8S/ESr1JYRRXqM3c1QFiMZUJdUTqyMQ==",
|
"integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
|
|
@ -5580,7 +5580,7 @@
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.12",
|
"nanoid": "^3.3.17",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
"source-map-js": "^1.2.1"
|
"source-map-js": "^1.2.1"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
|
"test:e2e:visual": "playwright test --config playwright.visual.config.ts",
|
||||||
"test:e2e:ui": "playwright test --ui",
|
"test:e2e:ui": "playwright test --ui",
|
||||||
"lint": "eslint . --max-warnings=0",
|
"lint": "eslint . --max-warnings=0",
|
||||||
"lint:fix": "eslint . --fix --max-warnings=0",
|
"lint:fix": "eslint . --fix --max-warnings=0",
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./e2e",
|
testDir: "./e2e",
|
||||||
|
testIgnore: "**/*.visual.spec.ts",
|
||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
forbidOnly: Boolean(process.env.CI),
|
forbidOnly: Boolean(process.env.CI),
|
||||||
retries: process.env.CI ? 2 : 0,
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
|
|
||||||
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 { useState } from "react";
|
||||||
import { Outlet } from "react-router";
|
import { Outlet } from "react-router";
|
||||||
import { Box } from "@mui/material";
|
import { Box, useMediaQuery, useTheme } from "@mui/material";
|
||||||
|
|
||||||
import { ProtectedRoute } from "@/components/auth/protected-route";
|
import { ProtectedRoute } from "@/components/auth/protected-route";
|
||||||
import { AppSidebar } from "@/components/layout/app-sidebar";
|
import { AppSidebar } from "@/components/layout/app-sidebar";
|
||||||
|
|
@ -8,19 +8,36 @@ import { getSidebarWidth } from "@/config/menu";
|
||||||
import { AppTopbar } from "@/components/layout/app-topbar";
|
import { AppTopbar } from "@/components/layout/app-topbar";
|
||||||
|
|
||||||
export default function AdminShellLayout() {
|
export default function AdminShellLayout() {
|
||||||
|
const theme = useTheme();
|
||||||
|
const isDesktop = useMediaQuery(theme.breakpoints.up("md"));
|
||||||
const [navCollapsed, setNavCollapsed] = useState(false);
|
const [navCollapsed, setNavCollapsed] = useState(false);
|
||||||
const sidebarWidth = getSidebarWidth(navCollapsed);
|
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||||||
|
const sidebarWidth = isDesktop ? getSidebarWidth(navCollapsed) : 0;
|
||||||
|
|
||||||
|
const handleToggleNav = () => {
|
||||||
|
if (isDesktop) {
|
||||||
|
setNavCollapsed((value) => !value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setMobileNavOpen((value) => !value);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<Box className="flex h-screen w-full overflow-hidden bg-background">
|
<Box className="flex h-screen w-full overflow-hidden bg-background">
|
||||||
<AppSidebar collapsed={navCollapsed} />
|
<AppSidebar
|
||||||
|
collapsed={isDesktop ? navCollapsed : false}
|
||||||
|
mobile={!isDesktop}
|
||||||
|
open={isDesktop || mobileNavOpen}
|
||||||
|
onClose={() => setMobileNavOpen(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
<Box
|
<Box
|
||||||
className="flex h-screen min-w-0 flex-1 flex-col overflow-hidden"
|
className="flex h-screen min-w-0 flex-1 flex-col overflow-hidden"
|
||||||
sx={{ width: `calc(100% - ${sidebarWidth}px)` }}
|
sx={{ width: `calc(100% - ${sidebarWidth}px)` }}
|
||||||
>
|
>
|
||||||
<AppTopbar onToggleNav={() => setNavCollapsed((value) => !value)} />
|
<AppTopbar onToggleNav={handleToggleNav} />
|
||||||
|
|
||||||
<Box
|
<Box
|
||||||
component="main"
|
component="main"
|
||||||
|
|
|
||||||
|
|
@ -27,28 +27,319 @@ import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Typography,
|
Typography,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
|
import { upliftsApi } from "@/domain/uplifts/api/uplifts-api";
|
||||||
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
|
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
|
||||||
import { useApproveUplift, useDenyUplift } from "@/domain/uplifts/use-cases/use-uplift-actions";
|
import { UPLIFT_STATUSES } from "@/domain/uplifts/types/uplift";
|
||||||
|
import {
|
||||||
|
useApproveUplift,
|
||||||
|
useRejectUplift,
|
||||||
|
useRequestChangesUplift,
|
||||||
|
} from "@/domain/uplifts/use-cases/use-uplift-actions";
|
||||||
import { useUpliftsQueue } from "@/domain/uplifts/use-cases/use-uplifts-queue";
|
import { useUpliftsQueue } from "@/domain/uplifts/use-cases/use-uplifts-queue";
|
||||||
import { formatDateTime } from "@/lib/time-utils";
|
import { formatDateTime } from "@/lib/time-utils";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
const STATUS_OPTIONS = ["Pending", "Approved", "Denied", "Cancelled", ""];
|
const STATUS_OPTIONS = ["", ...UPLIFT_STATUSES];
|
||||||
|
|
||||||
|
type UpliftActionType = "approve" | "reject" | "request-changes";
|
||||||
|
|
||||||
|
type UpliftDecision = { type: UpliftActionType; item: UpliftQueueItem };
|
||||||
|
|
||||||
function formatMoney(value: number | null | undefined): string {
|
function formatMoney(value: number | null | undefined): string {
|
||||||
return `$${Number(value ?? 0).toFixed(2)}`;
|
return `$${Number(value ?? 0).toFixed(2)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatBytes(value: number | null | undefined): string {
|
||||||
|
if (value == null) return "";
|
||||||
|
if (value < 1024) return `${value} B`;
|
||||||
|
if (value < 1024 * 1024) return `${(value / 1024).toFixed(0)} KB`;
|
||||||
|
return `${(value / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function UpliftQueueHeader({
|
||||||
|
totalCount,
|
||||||
|
isFetching,
|
||||||
|
isLoading,
|
||||||
|
}: {
|
||||||
|
totalCount: number;
|
||||||
|
isFetching: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Stack direction="row" spacing={2} sx={{ alignItems: "baseline" }}>
|
||||||
|
<Typography variant="h5" component="h1">
|
||||||
|
Uplift Queue
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||||
|
{totalCount} total
|
||||||
|
</Typography>
|
||||||
|
{isFetching && !isLoading && <CircularProgress size={16} />}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UpliftQueueFilters({
|
||||||
|
status,
|
||||||
|
tier,
|
||||||
|
onStatusChange,
|
||||||
|
onTierChange,
|
||||||
|
}: {
|
||||||
|
status: string;
|
||||||
|
tier: string;
|
||||||
|
onStatusChange: (value: string) => void;
|
||||||
|
onTierChange: (value: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Stack direction="row" spacing={2}>
|
||||||
|
<FormControl size="small" className="min-w-[140px]">
|
||||||
|
<InputLabel>Status</InputLabel>
|
||||||
|
<Select label="Status" value={status} onChange={(e) => onStatusChange(e.target.value)}>
|
||||||
|
{STATUS_OPTIONS.map((s) => (
|
||||||
|
<MenuItem key={s || "all"} value={s}>
|
||||||
|
{s || "All"}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormControl size="small" className="min-w-[120px]">
|
||||||
|
<InputLabel>Tier</InputLabel>
|
||||||
|
<Select label="Tier" value={tier} onChange={(e) => onTierChange(e.target.value)}>
|
||||||
|
<MenuItem value="">All</MenuItem>
|
||||||
|
<MenuItem value="1">Tier 1</MenuItem>
|
||||||
|
<MenuItem value="2">Tier 2</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UpliftQueueRow({
|
||||||
|
row,
|
||||||
|
onViewWorkOrder,
|
||||||
|
onDownloadEvidence,
|
||||||
|
onDecide,
|
||||||
|
}: {
|
||||||
|
row: UpliftQueueItem;
|
||||||
|
onViewWorkOrder: (row: UpliftQueueItem) => void;
|
||||||
|
onDownloadEvidence: (row: UpliftQueueItem) => void;
|
||||||
|
onDecide: (type: UpliftActionType, row: UpliftQueueItem) => void;
|
||||||
|
}) {
|
||||||
|
const decisionable = row.status === "Pending";
|
||||||
|
return (
|
||||||
|
<TableRow hover>
|
||||||
|
<TableCell>{row.dispatchNumber}</TableCell>
|
||||||
|
<TableCell>{row.poNumber}</TableCell>
|
||||||
|
<TableCell>{row.vendorCompanyName}</TableCell>
|
||||||
|
<TableCell>{formatMoney(row.currentNTE)}</TableCell>
|
||||||
|
<TableCell>{formatMoney(row.requestedNTE)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip size="small" label={`Tier ${row.requiredTier}`} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip size="small" label={row.status} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{row.evidenceDocumentId != null ? (
|
||||||
|
<Stack direction="column" spacing={0.5}>
|
||||||
|
<Typography variant="caption">{row.evidenceFileName}</Typography>
|
||||||
|
{Boolean(row.evidenceContentType) && (
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{row.evidenceContentType} · {formatBytes(row.evidenceSizeBytes)}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<Button size="small" variant="text" onClick={() => void onDownloadEvidence(row)}>
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
—
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{row.requestedAt ? formatDateTime(row.requestedAt) : ""}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Stack direction="row" spacing={1}>
|
||||||
|
{Boolean(row.workOrderId) && (
|
||||||
|
<Button size="small" variant="outlined" onClick={() => onViewWorkOrder(row)}>
|
||||||
|
View
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{decisionable && (
|
||||||
|
<>
|
||||||
|
<Tooltip title={!row.canDecide ? `Requires Tier ${row.requiredTier} role` : ""}>
|
||||||
|
<span>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="contained"
|
||||||
|
color="success"
|
||||||
|
disabled={!row.canDecide}
|
||||||
|
onClick={() => onDecide("approve", row)}
|
||||||
|
>
|
||||||
|
Approve
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title={!row.canDecide ? `Requires Tier ${row.requiredTier} role` : ""}>
|
||||||
|
<span>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="contained"
|
||||||
|
color="error"
|
||||||
|
disabled={!row.canDecide}
|
||||||
|
onClick={() => onDecide("reject", row)}
|
||||||
|
>
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title={!row.canDecide ? `Requires Tier ${row.requiredTier} role` : ""}>
|
||||||
|
<span>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
disabled={!row.canDecide}
|
||||||
|
onClick={() => onDecide("request-changes", row)}
|
||||||
|
>
|
||||||
|
Request Changes
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UpliftQueueTable({
|
||||||
|
isLoading,
|
||||||
|
rows,
|
||||||
|
onViewWorkOrder,
|
||||||
|
onDownloadEvidence,
|
||||||
|
onDecide,
|
||||||
|
}: {
|
||||||
|
isLoading: boolean;
|
||||||
|
rows: UpliftQueueItem[];
|
||||||
|
onViewWorkOrder: (row: UpliftQueueItem) => void;
|
||||||
|
onDownloadEvidence: (row: UpliftQueueItem) => void;
|
||||||
|
onDecide: (type: UpliftActionType, row: UpliftQueueItem) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<TableContainer component={Paper} variant="outlined">
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="font-semibold">Dispatch</TableCell>
|
||||||
|
<TableCell className="font-semibold">PO</TableCell>
|
||||||
|
<TableCell className="font-semibold">Vendor</TableCell>
|
||||||
|
<TableCell className="font-semibold">Current NTE</TableCell>
|
||||||
|
<TableCell className="font-semibold">Requested</TableCell>
|
||||||
|
<TableCell className="font-semibold">Tier</TableCell>
|
||||||
|
<TableCell className="font-semibold">Status</TableCell>
|
||||||
|
<TableCell className="font-semibold">Evidence</TableCell>
|
||||||
|
<TableCell className="font-semibold">Requested At</TableCell>
|
||||||
|
<TableCell className="font-semibold">Actions</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{isLoading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={10} align="center" className="py-10">
|
||||||
|
<CircularProgress size={28} />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={10} align="center" className="py-10">
|
||||||
|
<Typography sx={{ color: "text.secondary" }}>
|
||||||
|
No uplift requests match these filters.
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
rows.map((row) => (
|
||||||
|
<UpliftQueueRow
|
||||||
|
key={String(row.id)}
|
||||||
|
row={row}
|
||||||
|
onViewWorkOrder={onViewWorkOrder}
|
||||||
|
onDownloadEvidence={onDownloadEvidence}
|
||||||
|
onDecide={onDecide}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UpliftDecisionDialog({
|
||||||
|
action,
|
||||||
|
note,
|
||||||
|
noteRequired,
|
||||||
|
decisionPending,
|
||||||
|
onNoteChange,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
}: {
|
||||||
|
action: UpliftDecision | null;
|
||||||
|
note: string;
|
||||||
|
noteRequired: boolean;
|
||||||
|
decisionPending: boolean;
|
||||||
|
onNoteChange: (value: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Dialog open={Boolean(action)} onClose={onClose}>
|
||||||
|
<DialogTitle>
|
||||||
|
{action?.type === "approve"
|
||||||
|
? `Approve uplift to ${formatMoney(action.item.requestedNTE)}?`
|
||||||
|
: action?.type === "reject"
|
||||||
|
? "Reject Uplift"
|
||||||
|
: "Request Changes"}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
{action?.type === "approve" && (
|
||||||
|
<Typography variant="body2" className="mt-1 mb-3">
|
||||||
|
{formatMoney(action.item.currentNTE)} → {formatMoney(action.item.requestedNTE)}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
className="mt-2"
|
||||||
|
label={noteRequired ? "Note (required)" : "Optional note"}
|
||||||
|
value={note}
|
||||||
|
required={noteRequired}
|
||||||
|
onChange={(e) => onNoteChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={onClose}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color={action?.type === "approve" ? "success" : "primary"}
|
||||||
|
disabled={noteRequired ? !note.trim() || decisionPending : decisionPending}
|
||||||
|
onClick={onConfirm}
|
||||||
|
>
|
||||||
|
Confirm
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function UpliftQueuePage() {
|
export default function UpliftQueuePage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [status, setStatus] = useState("Pending");
|
const [status, setStatus] = useState("Pending");
|
||||||
const [tier, setTier] = useState("");
|
const [tier, setTier] = useState("");
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
const [pageSize] = useState(25);
|
const [pageSize] = useState(25);
|
||||||
|
const [upliftAction, setUpliftAction] = useState<UpliftDecision | null>(null);
|
||||||
const [upliftAction, setUpliftAction] = useState<{
|
|
||||||
type: "approve" | "deny";
|
|
||||||
item: UpliftQueueItem;
|
|
||||||
} | null>(null);
|
|
||||||
const [note, setNote] = useState("");
|
const [note, setNote] = useState("");
|
||||||
|
|
||||||
const listParams = useMemo(
|
const listParams = useMemo(
|
||||||
|
|
@ -58,39 +349,59 @@ export default function UpliftQueuePage() {
|
||||||
|
|
||||||
const { data, isLoading, isFetching, error, refetch } = useUpliftsQueue(listParams);
|
const { data, isLoading, isFetching, error, refetch } = useUpliftsQueue(listParams);
|
||||||
const approveUplift = useApproveUplift();
|
const approveUplift = useApproveUplift();
|
||||||
const denyUplift = useDenyUplift();
|
const rejectUplift = useRejectUplift();
|
||||||
|
const requestChangesUplift = useRequestChangesUplift();
|
||||||
|
|
||||||
const rows = data?.items ?? [];
|
const rows = data?.items ?? [];
|
||||||
const totalCount = data?.totalCount ?? 0;
|
const totalCount = data?.totalCount ?? 0;
|
||||||
|
const noteRequired = upliftAction?.type === "reject" || upliftAction?.type === "request-changes";
|
||||||
|
const decisionPending =
|
||||||
|
approveUplift.isPending || rejectUplift.isPending || requestChangesUplift.isPending;
|
||||||
|
|
||||||
|
const handleStatusChange = (value: string) => {
|
||||||
|
setStatus(value);
|
||||||
|
setPage(0);
|
||||||
|
};
|
||||||
|
const handleTierChange = (value: string) => {
|
||||||
|
setTier(value);
|
||||||
|
setPage(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDialog = () => {
|
||||||
|
setUpliftAction(null);
|
||||||
|
setNote("");
|
||||||
|
};
|
||||||
|
|
||||||
const handleConfirm = () => {
|
const handleConfirm = () => {
|
||||||
if (!upliftAction) return;
|
if (!upliftAction) return;
|
||||||
|
if (noteRequired && !note.trim()) return;
|
||||||
|
const id = upliftAction.item.id;
|
||||||
|
const onDone = () => {
|
||||||
|
closeDialog();
|
||||||
|
void refetch();
|
||||||
|
};
|
||||||
if (upliftAction.type === "approve") {
|
if (upliftAction.type === "approve") {
|
||||||
approveUplift.mutate(
|
approveUplift.mutate({ id, note: note || undefined }, { onSuccess: onDone });
|
||||||
{ id: upliftAction.item.id, note: note || undefined },
|
} else if (upliftAction.type === "reject") {
|
||||||
{
|
rejectUplift.mutate({ id, note: note.trim() }, { onSuccess: onDone });
|
||||||
onSuccess: () => {
|
|
||||||
setUpliftAction(null);
|
|
||||||
setNote("");
|
|
||||||
void refetch();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
if (!note.trim()) return;
|
requestChangesUplift.mutate({ id, note: note.trim() }, { onSuccess: onDone });
|
||||||
denyUplift.mutate(
|
|
||||||
{ id: upliftAction.item.id, note: note.trim() },
|
|
||||||
{
|
|
||||||
onSuccess: () => {
|
|
||||||
setUpliftAction(null);
|
|
||||||
setNote("");
|
|
||||||
void refetch();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const downloadEvidence = async (item: UpliftQueueItem) => {
|
||||||
|
try {
|
||||||
|
await upliftsApi.downloadEvidence(item.id, item.evidenceFileName || "uplift-evidence");
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Unable to download evidence.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDecide = (type: UpliftActionType, item: UpliftQueueItem) => {
|
||||||
|
setUpliftAction({ type, item });
|
||||||
|
setNote("");
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className="flex flex-col gap-4 p-4">
|
<Box className="flex flex-col gap-4 p-4">
|
||||||
<Stack
|
<Stack
|
||||||
|
|
@ -101,177 +412,26 @@ export default function UpliftQueuePage() {
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stack
|
<UpliftQueueHeader totalCount={totalCount} isFetching={isFetching} isLoading={isLoading} />
|
||||||
direction="row"
|
<UpliftQueueFilters
|
||||||
spacing={2}
|
status={status}
|
||||||
sx={{
|
tier={tier}
|
||||||
alignItems: "baseline",
|
onStatusChange={handleStatusChange}
|
||||||
}}
|
onTierChange={handleTierChange}
|
||||||
>
|
/>
|
||||||
<Typography variant="h5" component="h1">
|
|
||||||
Uplift Queue
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
sx={{
|
|
||||||
color: "text.secondary",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{totalCount} total
|
|
||||||
</Typography>
|
|
||||||
{isFetching && !isLoading && <CircularProgress size={16} />}
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Stack direction="row" spacing={2}>
|
|
||||||
<FormControl size="small" className="min-w-[140px]">
|
|
||||||
<InputLabel>Status</InputLabel>
|
|
||||||
<Select
|
|
||||||
label="Status"
|
|
||||||
value={status}
|
|
||||||
onChange={(e) => {
|
|
||||||
setStatus(e.target.value);
|
|
||||||
setPage(0);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{STATUS_OPTIONS.map((s) => (
|
|
||||||
<MenuItem key={s || "all"} value={s}>
|
|
||||||
{s || "All"}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
<FormControl size="small" className="min-w-[120px]">
|
|
||||||
<InputLabel>Tier</InputLabel>
|
|
||||||
<Select
|
|
||||||
label="Tier"
|
|
||||||
value={tier}
|
|
||||||
onChange={(e) => {
|
|
||||||
setTier(e.target.value);
|
|
||||||
setPage(0);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MenuItem value="">All</MenuItem>
|
|
||||||
<MenuItem value="1">Tier 1</MenuItem>
|
|
||||||
<MenuItem value="2">Tier 2</MenuItem>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
{Boolean(error) && (
|
{Boolean(error) && (
|
||||||
<Alert severity="error">
|
<Alert severity="error">
|
||||||
{error instanceof Error ? error.message : "Failed to load uplift queue"}
|
{error instanceof Error ? error.message : "Failed to load uplift queue"}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
<TableContainer component={Paper} variant="outlined">
|
<UpliftQueueTable
|
||||||
<Table size="small">
|
isLoading={isLoading}
|
||||||
<TableHead>
|
rows={rows}
|
||||||
<TableRow>
|
onViewWorkOrder={(r) => navigate(`/workorders/${r.workOrderId}`)}
|
||||||
<TableCell className="font-semibold">Dispatch</TableCell>
|
onDownloadEvidence={(r) => void downloadEvidence(r)}
|
||||||
<TableCell className="font-semibold">PO</TableCell>
|
onDecide={handleDecide}
|
||||||
<TableCell className="font-semibold">Vendor</TableCell>
|
/>
|
||||||
<TableCell className="font-semibold">Current NTE</TableCell>
|
|
||||||
<TableCell className="font-semibold">Requested</TableCell>
|
|
||||||
<TableCell className="font-semibold">Delta</TableCell>
|
|
||||||
<TableCell className="font-semibold">Tier</TableCell>
|
|
||||||
<TableCell className="font-semibold">Status</TableCell>
|
|
||||||
<TableCell className="font-semibold">Requested At</TableCell>
|
|
||||||
<TableCell className="font-semibold">Actions</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableHead>
|
|
||||||
<TableBody>
|
|
||||||
{isLoading ? (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={10} align="center" className="py-10">
|
|
||||||
<CircularProgress size={28} />
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : rows.length === 0 ? (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={10} align="center" className="py-10">
|
|
||||||
<Typography
|
|
||||||
sx={{
|
|
||||||
color: "text.secondary",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
No uplift requests match these filters.
|
|
||||||
</Typography>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : (
|
|
||||||
rows.map((row) => (
|
|
||||||
<TableRow key={String(row.id)} hover>
|
|
||||||
<TableCell>{row.dispatchNumber}</TableCell>
|
|
||||||
<TableCell>{row.poNumber}</TableCell>
|
|
||||||
<TableCell>{row.vendorCompanyName}</TableCell>
|
|
||||||
<TableCell>{formatMoney(row.currentNTE)}</TableCell>
|
|
||||||
<TableCell>{formatMoney(row.requestedNTE)}</TableCell>
|
|
||||||
<TableCell>{formatMoney(row.delta)}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Chip size="small" label={`Tier ${row.requiredTier}`} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Chip size="small" label={row.status} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{row.requestedAt ? formatDateTime(row.requestedAt) : ""}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Stack direction="row" spacing={1}>
|
|
||||||
{Boolean(row.workOrderId) && (
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
variant="outlined"
|
|
||||||
onClick={() => navigate(`/workorders/${row.workOrderId}`)}
|
|
||||||
>
|
|
||||||
View
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{row.status === "Pending" && (
|
|
||||||
<>
|
|
||||||
<Tooltip
|
|
||||||
title={!row.canDecide ? `Requires Tier ${row.requiredTier} role` : ""}
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
variant="contained"
|
|
||||||
color="success"
|
|
||||||
disabled={!row.canDecide}
|
|
||||||
onClick={() => {
|
|
||||||
setUpliftAction({ type: "approve", item: row });
|
|
||||||
setNote("");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Approve
|
|
||||||
</Button>
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip
|
|
||||||
title={!row.canDecide ? `Requires Tier ${row.requiredTier} role` : ""}
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
variant="contained"
|
|
||||||
color="error"
|
|
||||||
disabled={!row.canDecide}
|
|
||||||
onClick={() => {
|
|
||||||
setUpliftAction({ type: "deny", item: row });
|
|
||||||
setNote("");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Deny
|
|
||||||
</Button>
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</TableContainer>
|
|
||||||
<TablePagination
|
<TablePagination
|
||||||
component="div"
|
component="div"
|
||||||
count={totalCount}
|
count={totalCount}
|
||||||
|
|
@ -280,46 +440,15 @@ export default function UpliftQueuePage() {
|
||||||
rowsPerPage={pageSize}
|
rowsPerPage={pageSize}
|
||||||
rowsPerPageOptions={[25]}
|
rowsPerPageOptions={[25]}
|
||||||
/>
|
/>
|
||||||
<Dialog open={Boolean(upliftAction)} onClose={() => setUpliftAction(null)}>
|
<UpliftDecisionDialog
|
||||||
<DialogTitle>
|
action={upliftAction}
|
||||||
{upliftAction?.type === "approve"
|
note={note}
|
||||||
? `Approve uplift to ${formatMoney(upliftAction.item.requestedNTE)}?`
|
noteRequired={noteRequired}
|
||||||
: "Deny Uplift"}
|
decisionPending={decisionPending}
|
||||||
</DialogTitle>
|
onNoteChange={setNote}
|
||||||
<DialogContent>
|
onClose={closeDialog}
|
||||||
{upliftAction?.type === "approve" && (
|
onConfirm={handleConfirm}
|
||||||
<Typography variant="body2" className="mt-1 mb-3">
|
/>
|
||||||
{formatMoney(upliftAction.item.currentNTE)} →{" "}
|
|
||||||
{formatMoney(upliftAction.item.requestedNTE)} (+
|
|
||||||
{formatMoney(upliftAction.item.delta)})
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
multiline
|
|
||||||
minRows={2}
|
|
||||||
className="mt-2"
|
|
||||||
label={upliftAction?.type === "deny" ? "Denial reason (required)" : "Optional note"}
|
|
||||||
value={note}
|
|
||||||
onChange={(e) => setNote(e.target.value)}
|
|
||||||
/>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button onClick={() => setUpliftAction(null)}>Cancel</Button>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
color={upliftAction?.type === "approve" ? "success" : "error"}
|
|
||||||
disabled={
|
|
||||||
upliftAction?.type === "deny"
|
|
||||||
? !note.trim() || denyUplift.isPending
|
|
||||||
: approveUplift.isPending
|
|
||||||
}
|
|
||||||
onClick={handleConfirm}
|
|
||||||
>
|
|
||||||
Confirm
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,90 +1,82 @@
|
||||||
import { useState, type ReactNode } from "react";
|
import { useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router";
|
import { useNavigate, useParams } from "react-router";
|
||||||
import {
|
import { Alert, Box, Button, Chip, CircularProgress, Stack, Typography } from "@mui/material";
|
||||||
Alert,
|
import { toast } from "react-toastify";
|
||||||
Box,
|
import { upliftsApi } from "@/domain/uplifts/api/uplifts-api";
|
||||||
Button,
|
|
||||||
Chip,
|
|
||||||
CircularProgress,
|
|
||||||
Dialog,
|
|
||||||
DialogActions,
|
|
||||||
DialogContent,
|
|
||||||
DialogTitle,
|
|
||||||
Paper,
|
|
||||||
Stack,
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableRow,
|
|
||||||
TextField,
|
|
||||||
Typography,
|
|
||||||
} from "@mui/material";
|
|
||||||
import { useApproveUplift, useDenyUplift } from "@/domain/uplifts/use-cases/use-uplift-actions";
|
|
||||||
import { useVendorPoDetail } from "@/domain/vendor-pos/use-cases/use-vendor-po-detail";
|
|
||||||
import type { UpliftRequest } from "@/domain/uplifts/types/uplift";
|
import type { UpliftRequest } from "@/domain/uplifts/types/uplift";
|
||||||
import { formatDateTime, toDateInputValue } from "@/lib/time-utils";
|
import {
|
||||||
|
useApproveUplift,
|
||||||
|
useRejectUplift,
|
||||||
|
useRequestChangesUplift,
|
||||||
|
} from "@/domain/uplifts/use-cases/use-uplift-actions";
|
||||||
|
import { useVendorPoDetail } from "@/domain/vendor-pos/use-cases/use-vendor-po-detail";
|
||||||
|
import {
|
||||||
|
PoDetailsPaper,
|
||||||
|
SitePaper,
|
||||||
|
UpliftDecisionDialog,
|
||||||
|
UpliftHistoryPaper,
|
||||||
|
VendorSummaryPaper,
|
||||||
|
WorkOrdersPaper,
|
||||||
|
type UpliftActionType,
|
||||||
|
type VendorPoUpliftDecision,
|
||||||
|
} from "@/app/(protected)/vendor-pos/_components/vendor-po-sections";
|
||||||
|
|
||||||
function formatMoney(value: number | null | undefined): string {
|
function formatMoney(value: number | null | undefined): string {
|
||||||
if (value == null) return "—";
|
if (value == null) return "—";
|
||||||
return `$${Number(value).toFixed(2)}`;
|
return `$${Number(value).toFixed(2)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function InfoRow({ label, children }: { label: string; children: ReactNode }) {
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>
|
|
||||||
{label}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2">
|
|
||||||
{children == null || children === "" ? "—" : children}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function VendorPoViewPage() {
|
export default function VendorPoViewPage() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { data: dispatch, isLoading, error, refetch } = useVendorPoDetail(id);
|
const { data: dispatch, isLoading, error, refetch } = useVendorPoDetail(id);
|
||||||
|
|
||||||
const approveUplift = useApproveUplift(id);
|
const approveUplift = useApproveUplift(id);
|
||||||
const denyUplift = useDenyUplift(id);
|
const rejectUplift = useRejectUplift(id);
|
||||||
|
const requestChangesUplift = useRequestChangesUplift(id);
|
||||||
|
|
||||||
const [upliftAction, setUpliftAction] = useState<{
|
const [upliftAction, setUpliftAction] = useState<VendorPoUpliftDecision | null>(null);
|
||||||
type: "approve" | "deny";
|
|
||||||
uplift: UpliftRequest;
|
|
||||||
} | null>(null);
|
|
||||||
const [note, setNote] = useState("");
|
const [note, setNote] = useState("");
|
||||||
|
|
||||||
|
const noteRequired = upliftAction?.type === "reject" || upliftAction?.type === "request-changes";
|
||||||
|
const decisionPending =
|
||||||
|
approveUplift.isPending || rejectUplift.isPending || requestChangesUplift.isPending;
|
||||||
|
|
||||||
|
const closeUpliftDialog = () => {
|
||||||
|
setUpliftAction(null);
|
||||||
|
setNote("");
|
||||||
|
};
|
||||||
|
|
||||||
const handleUpliftConfirm = () => {
|
const handleUpliftConfirm = () => {
|
||||||
if (!upliftAction) return;
|
if (!upliftAction) return;
|
||||||
|
if (noteRequired && !note.trim()) return;
|
||||||
|
const upliftId = upliftAction.uplift.id;
|
||||||
|
const onDone = () => {
|
||||||
|
closeUpliftDialog();
|
||||||
|
void refetch();
|
||||||
|
};
|
||||||
if (upliftAction.type === "approve") {
|
if (upliftAction.type === "approve") {
|
||||||
approveUplift.mutate(
|
approveUplift.mutate({ id: upliftId, note: note || undefined }, { onSuccess: onDone });
|
||||||
{ id: upliftAction.uplift.id, note: note || undefined },
|
} else if (upliftAction.type === "reject") {
|
||||||
{
|
rejectUplift.mutate({ id: upliftId, note: note.trim() }, { onSuccess: onDone });
|
||||||
onSuccess: () => {
|
|
||||||
setUpliftAction(null);
|
|
||||||
setNote("");
|
|
||||||
void refetch();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
if (!note.trim()) return;
|
requestChangesUplift.mutate({ id: upliftId, note: note.trim() }, { onSuccess: onDone });
|
||||||
denyUplift.mutate(
|
|
||||||
{ id: upliftAction.uplift.id, note: note.trim() },
|
|
||||||
{
|
|
||||||
onSuccess: () => {
|
|
||||||
setUpliftAction(null);
|
|
||||||
setNote("");
|
|
||||||
void refetch();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const downloadEvidence = async (uplift: UpliftRequest) => {
|
||||||
|
try {
|
||||||
|
await upliftsApi.downloadEvidence(uplift.id, uplift.evidenceFileName || "uplift-evidence");
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Unable to download evidence.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDecide = (type: UpliftActionType, uplift: UpliftRequest) => {
|
||||||
|
setUpliftAction({ type, uplift });
|
||||||
|
setNote("");
|
||||||
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<Box className="flex justify-center p-10">
|
<Box className="flex justify-center p-10">
|
||||||
|
|
@ -141,212 +133,29 @@ export default function VendorPoViewPage() {
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Stack spacing={3}>
|
<Stack spacing={3}>
|
||||||
<Paper variant="outlined" className="p-4">
|
<VendorSummaryPaper vendor={vendor} />
|
||||||
<Typography variant="subtitle1" className="mb-3 font-semibold">
|
<PoDetailsPaper dispatch={dispatch} />
|
||||||
Vendor
|
<SitePaper location={dispatch.primaryLocation} />
|
||||||
</Typography>
|
<WorkOrdersPaper
|
||||||
{vendor ? (
|
workOrders={dispatch.workOrders}
|
||||||
<Box className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3">
|
onView={(workOrderId) => navigate(`/workorders/${workOrderId}`)}
|
||||||
<InfoRow label="Company">{vendor.companyName}</InfoRow>
|
/>
|
||||||
<InfoRow label="Contact">{vendor.contactName}</InfoRow>
|
<UpliftHistoryPaper
|
||||||
<InfoRow label="Email">{vendor.email}</InfoRow>
|
upliftRequests={dispatch.upliftRequests}
|
||||||
<InfoRow label="Phone">{vendor.phone}</InfoRow>
|
onDownload={(uplift) => void downloadEvidence(uplift)}
|
||||||
<InfoRow label="Address">
|
onDecide={handleDecide}
|
||||||
{[vendor.address, vendor.city, vendor.state, vendor.zip].filter(Boolean).join(", ")}
|
/>
|
||||||
</InfoRow>
|
|
||||||
<InfoRow label="Trades">{vendor.tradeSpecialties}</InfoRow>
|
|
||||||
</Box>
|
|
||||||
) : (
|
|
||||||
<Typography color="text.secondary">No vendor attached.</Typography>
|
|
||||||
)}
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
<Paper variant="outlined" className="p-4">
|
|
||||||
<Typography variant="subtitle1" className="mb-3 font-semibold">
|
|
||||||
PO Details
|
|
||||||
</Typography>
|
|
||||||
<Box className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3">
|
|
||||||
<InfoRow label="PO #">{dispatch.poNumber}</InfoRow>
|
|
||||||
<InfoRow label="Dispatch #">{dispatch.dispatchNumber}</InfoRow>
|
|
||||||
<InfoRow label="Status">{dispatch.status}</InfoRow>
|
|
||||||
<InfoRow label="NTE">{formatMoney(dispatch.nteAmount)}</InfoRow>
|
|
||||||
<InfoRow label="Dispatched">{formatDateTime(dispatch.dispatchedAt)}</InfoRow>
|
|
||||||
<InfoRow label="Acknowledged">{formatDateTime(dispatch.acknowledgedAt)}</InfoRow>
|
|
||||||
<InfoRow label="Scheduled">{toDateInputValue(dispatch.scheduledDate) || "—"}</InfoRow>
|
|
||||||
<InfoRow label="Completed">{toDateInputValue(dispatch.completedDate) || "—"}</InfoRow>
|
|
||||||
<InfoRow label="Verified">{formatDateTime(dispatch.verifiedAt)}</InfoRow>
|
|
||||||
<InfoRow label="Email sent">{dispatch.emailSent ? "Yes" : "No"}</InfoRow>
|
|
||||||
</Box>
|
|
||||||
{Boolean(dispatch.description) && (
|
|
||||||
<Box className="mt-3">
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
Description
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" className="whitespace-pre-wrap">
|
|
||||||
{dispatch.description}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
{dispatch.primaryLocation != null && (
|
|
||||||
<Paper variant="outlined" className="p-4">
|
|
||||||
<Typography variant="subtitle1" className="mb-3 font-semibold">
|
|
||||||
Site
|
|
||||||
</Typography>
|
|
||||||
<Box className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
||||||
<InfoRow label="Location">{dispatch.primaryLocation.name}</InfoRow>
|
|
||||||
<InfoRow label="Address">
|
|
||||||
{[
|
|
||||||
dispatch.primaryLocation.address,
|
|
||||||
dispatch.primaryLocation.city,
|
|
||||||
dispatch.primaryLocation.state,
|
|
||||||
dispatch.primaryLocation.zipCode,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(", ")}
|
|
||||||
</InfoRow>
|
|
||||||
</Box>
|
|
||||||
</Paper>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Paper variant="outlined" className="p-4">
|
|
||||||
<Typography variant="subtitle1" className="mb-3 font-semibold">
|
|
||||||
Linked Work Orders ({dispatch.workOrders.length})
|
|
||||||
</Typography>
|
|
||||||
{dispatch.workOrders.length === 0 ? (
|
|
||||||
<Typography color="text.secondary">No work orders linked.</Typography>
|
|
||||||
) : (
|
|
||||||
<Table size="small">
|
|
||||||
<TableHead>
|
|
||||||
<TableRow>
|
|
||||||
<TableCell>WO #</TableCell>
|
|
||||||
<TableCell>Title</TableCell>
|
|
||||||
<TableCell>Trade</TableCell>
|
|
||||||
<TableCell>Status</TableCell>
|
|
||||||
<TableCell>Location</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableHead>
|
|
||||||
<TableBody>
|
|
||||||
{dispatch.workOrders.map((wo) => (
|
|
||||||
<TableRow key={String(wo.workOrderId)}>
|
|
||||||
<TableCell>
|
|
||||||
<Button
|
|
||||||
variant="text"
|
|
||||||
size="small"
|
|
||||||
onClick={() => navigate(`/workorders/${wo.workOrderId}`)}
|
|
||||||
>
|
|
||||||
{wo.internalWONumber || wo.workOrderId}
|
|
||||||
</Button>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{wo.title}</TableCell>
|
|
||||||
<TableCell>{wo.trade}</TableCell>
|
|
||||||
<TableCell>{wo.status}</TableCell>
|
|
||||||
<TableCell>{wo.locationName}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
)}
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
<Paper variant="outlined" className="p-4">
|
|
||||||
<Typography variant="subtitle1" className="mb-3 font-semibold">
|
|
||||||
NTE Uplift History ({dispatch.upliftRequests.length})
|
|
||||||
</Typography>
|
|
||||||
{dispatch.upliftRequests.length === 0 ? (
|
|
||||||
<Typography color="text.secondary">No uplift requests on this PO.</Typography>
|
|
||||||
) : (
|
|
||||||
<Stack spacing={2}>
|
|
||||||
{dispatch.upliftRequests.map((u) => (
|
|
||||||
<Box key={String(u.id)} className="rounded border border-border p-3">
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
sx={{ justifyContent: "space-between", alignItems: "center" }}
|
|
||||||
>
|
|
||||||
<Typography variant="body2" className="font-semibold">
|
|
||||||
{formatMoney(u.currentNTE)} → {formatMoney(u.requestedNTE)}
|
|
||||||
<Typography
|
|
||||||
component="span"
|
|
||||||
variant="caption"
|
|
||||||
color="text.secondary"
|
|
||||||
className="ml-2"
|
|
||||||
>
|
|
||||||
(Δ {formatMoney(u.delta)}, Tier {u.requiredTier})
|
|
||||||
</Typography>
|
|
||||||
</Typography>
|
|
||||||
<Chip size="small" label={u.status} />
|
|
||||||
</Stack>
|
|
||||||
{Boolean(u.vendorReason) && (
|
|
||||||
<Typography variant="body2" className="mt-1">
|
|
||||||
<strong>Reason:</strong> {u.vendorReason}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
{u.status === "Pending" && (
|
|
||||||
<Stack direction="row" spacing={1} className="mt-2">
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
variant="contained"
|
|
||||||
color="success"
|
|
||||||
onClick={() => {
|
|
||||||
setUpliftAction({ type: "approve", uplift: u });
|
|
||||||
setNote("");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Approve
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
variant="contained"
|
|
||||||
color="error"
|
|
||||||
onClick={() => {
|
|
||||||
setUpliftAction({ type: "deny", uplift: u });
|
|
||||||
setNote("");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Deny
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</Paper>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Dialog open={Boolean(upliftAction)} onClose={() => setUpliftAction(null)}>
|
<UpliftDecisionDialog
|
||||||
<DialogTitle>
|
action={upliftAction}
|
||||||
{upliftAction?.type === "approve" ? "Approve Uplift" : "Deny Uplift"}
|
note={note}
|
||||||
</DialogTitle>
|
noteRequired={noteRequired}
|
||||||
<DialogContent>
|
decisionPending={decisionPending}
|
||||||
<TextField
|
onNoteChange={setNote}
|
||||||
fullWidth
|
onClose={closeUpliftDialog}
|
||||||
multiline
|
onConfirm={handleUpliftConfirm}
|
||||||
minRows={2}
|
/>
|
||||||
className="mt-2"
|
|
||||||
label={upliftAction?.type === "deny" ? "Denial reason (required)" : "Optional note"}
|
|
||||||
value={note}
|
|
||||||
onChange={(e) => setNote(e.target.value)}
|
|
||||||
required={upliftAction?.type === "deny"}
|
|
||||||
/>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button onClick={() => setUpliftAction(null)}>Cancel</Button>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
color={upliftAction?.type === "approve" ? "success" : "error"}
|
|
||||||
disabled={
|
|
||||||
upliftAction?.type === "deny"
|
|
||||||
? !note.trim() || denyUplift.isPending
|
|
||||||
: approveUplift.isPending
|
|
||||||
}
|
|
||||||
onClick={handleUpliftConfirm}
|
|
||||||
>
|
|
||||||
Confirm
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -15,7 +15,7 @@ export interface VendorDeactivationState {
|
||||||
confirm: () => void;
|
confirm: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useVendorDeactivation(): VendorDeactivationState {
|
export function useVendorDeactivation(onSuccess?: () => void): VendorDeactivationState {
|
||||||
const [target, setTarget] = useState<VendorListItem | null>(null);
|
const [target, setTarget] = useState<VendorListItem | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const deleteVendor = useDeleteVendor();
|
const deleteVendor = useDeleteVendor();
|
||||||
|
|
@ -39,7 +39,10 @@ export function useVendorDeactivation(): VendorDeactivationState {
|
||||||
if (!target || target.id == null) return;
|
if (!target || target.id == null) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
deleteVendor.mutate(target.id, {
|
deleteVendor.mutate(target.id, {
|
||||||
onSuccess: () => setTarget(null),
|
onSuccess: () => {
|
||||||
|
setTarget(null);
|
||||||
|
onSuccess?.();
|
||||||
|
},
|
||||||
onError: (err: Error) => setError(err.message || "Failed to deactivate vendor"),
|
onError: (err: Error) => setError(err.message || "Failed to deactivate vendor"),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ export interface VendorListState {
|
||||||
export function useVendorListState(): VendorListState {
|
export function useVendorListState(): VendorListState {
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
const [pageSize, setPageSize] = useState(12);
|
const [pageSize, setPageSize] = useState(48);
|
||||||
const [isActive, setIsActive] = useState(true);
|
const [isActive, setIsActive] = useState(true);
|
||||||
const [appliedFilters, setAppliedFilters] = useState<VendorFilters>(emptyVendorFilters);
|
const [appliedFilters, setAppliedFilters] = useState<VendorFilters>(emptyVendorFilters);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { useCallback, useMemo, useState } from "react";
|
||||||
import { useForm, useWatch, type FieldErrors } from "react-hook-form";
|
import { useForm, useWatch, type FieldErrors } from "react-hook-form";
|
||||||
import {
|
import {
|
||||||
emptyVendorCompanyRosterForm,
|
emptyVendorCompanyRosterForm,
|
||||||
|
emptyRosterTechnician,
|
||||||
vendorCompanyRosterSchema,
|
vendorCompanyRosterSchema,
|
||||||
type VendorCompanyRosterFormValues,
|
type VendorCompanyRosterFormValues,
|
||||||
} from "@/domain/vendors/schemas/vendor-roster-schema";
|
} from "@/domain/vendors/schemas/vendor-roster-schema";
|
||||||
|
|
@ -20,11 +21,30 @@ import type {
|
||||||
VendorRosterConflict,
|
VendorRosterConflict,
|
||||||
} from "@/domain/vendors/types/vendor";
|
} from "@/domain/vendors/types/vendor";
|
||||||
|
|
||||||
|
function withoutBlankNewTechnicians(
|
||||||
|
values: VendorCompanyRosterFormValues,
|
||||||
|
): VendorCompanyRosterFormValues {
|
||||||
|
return {
|
||||||
|
...values,
|
||||||
|
technicians: values.technicians.filter(
|
||||||
|
(technician) =>
|
||||||
|
technician.id != null ||
|
||||||
|
Boolean(
|
||||||
|
technician.contactName.trim() ||
|
||||||
|
technician.phone.trim() ||
|
||||||
|
technician.email.trim() ||
|
||||||
|
technician.tradeSpecialties.trim(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface VendorRosterFormProps {
|
export interface VendorRosterFormProps {
|
||||||
mode: "create" | "update";
|
mode: "create" | "update";
|
||||||
vendorId?: string | number;
|
vendorId?: string | number;
|
||||||
companyId?: string | number;
|
companyId?: string | number;
|
||||||
onSuccess?: (roster: VendorCompanyRoster) => void;
|
onSuccess?: (roster: VendorCompanyRoster) => void;
|
||||||
|
startWithTechnician?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VendorRosterForm {
|
export interface VendorRosterForm {
|
||||||
|
|
@ -55,6 +75,7 @@ export function useVendorRosterForm({
|
||||||
vendorId,
|
vendorId,
|
||||||
companyId,
|
companyId,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
|
startWithTechnician = false,
|
||||||
}: VendorRosterFormProps): VendorRosterForm {
|
}: VendorRosterFormProps): VendorRosterForm {
|
||||||
const query = useVendorCompanyRoster(
|
const query = useVendorCompanyRoster(
|
||||||
mode === "update" ? vendorId : undefined,
|
mode === "update" ? vendorId : undefined,
|
||||||
|
|
@ -69,9 +90,16 @@ export function useVendorRosterForm({
|
||||||
const trades = useMemo(() => facets?.trades ?? [], [facets]);
|
const trades = useMemo(() => facets?.trades ?? [], [facets]);
|
||||||
const resolver = useVendorRosterResolver(routeRoster);
|
const resolver = useVendorRosterResolver(routeRoster);
|
||||||
|
|
||||||
|
const createDefaults = useMemo(
|
||||||
|
() => ({
|
||||||
|
...emptyVendorCompanyRosterForm,
|
||||||
|
technicians: startWithTechnician ? [{ ...emptyRosterTechnician }] : [],
|
||||||
|
}),
|
||||||
|
[startWithTechnician],
|
||||||
|
);
|
||||||
const form = useForm<VendorCompanyRosterFormValues>({
|
const form = useForm<VendorCompanyRosterFormValues>({
|
||||||
resolver,
|
resolver,
|
||||||
defaultValues: emptyVendorCompanyRosterForm,
|
defaultValues: createDefaults,
|
||||||
values: mode === "update" && routeRoster ? toFormValues(routeRoster) : undefined,
|
values: mode === "update" && routeRoster ? toFormValues(routeRoster) : undefined,
|
||||||
mode: "onChange",
|
mode: "onChange",
|
||||||
});
|
});
|
||||||
|
|
@ -96,10 +124,10 @@ export function useVendorRosterForm({
|
||||||
}, [routeRoster, watched]);
|
}, [routeRoster, watched]);
|
||||||
|
|
||||||
const resetForm = useCallback(() => {
|
const resetForm = useCallback(() => {
|
||||||
reset(emptyVendorCompanyRosterForm);
|
reset(createDefaults);
|
||||||
resetSelection();
|
resetSelection();
|
||||||
clearConflict();
|
clearConflict();
|
||||||
}, [clearConflict, reset, resetSelection]);
|
}, [clearConflict, createDefaults, reset, resetSelection]);
|
||||||
|
|
||||||
const committedRoster = mode === "update" ? routeRoster : selection.selectedRoster;
|
const committedRoster = mode === "update" ? routeRoster : selection.selectedRoster;
|
||||||
const isUpdate = mode === "update" || selection.selectedRoster != null;
|
const isUpdate = mode === "update" || selection.selectedRoster != null;
|
||||||
|
|
@ -109,7 +137,7 @@ export function useVendorRosterForm({
|
||||||
save.mutate(
|
save.mutate(
|
||||||
{
|
{
|
||||||
mode: isUpdate ? "update" : "create",
|
mode: isUpdate ? "update" : "create",
|
||||||
values: formValues,
|
values: withoutBlankNewTechnicians(formValues),
|
||||||
companyId: isUpdate ? (committedRoster?.companyId ?? companyId) : undefined,
|
companyId: isUpdate ? (committedRoster?.companyId ?? companyId) : undefined,
|
||||||
rowVersion: isUpdate ? committedRoster?.rowVersion : undefined,
|
rowVersion: isUpdate ? committedRoster?.rowVersion : undefined,
|
||||||
originalRoster: mode === "update" ? routeRoster : undefined,
|
originalRoster: mode === "update" ? routeRoster : undefined,
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@ import {
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
Stack,
|
Stack,
|
||||||
Typography,
|
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
import { VendorRosterConflictAlert } from "./vendor-roster-conflict-alert";
|
import { VendorRosterConflictAlert } from "./vendor-roster-conflict-alert";
|
||||||
import { VendorRosterFormFields } from "./vendor-roster-form-fields";
|
import { VendorRosterFormFields } from "./vendor-roster-form-fields";
|
||||||
import { VendorRosterLoadErrorAlert } from "./vendor-roster-load-error";
|
import { VendorRosterLoadErrorAlert } from "./vendor-roster-load-error";
|
||||||
|
|
@ -21,7 +21,11 @@ interface VendorCreateModalProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VendorCreateModal({ open, onClose }: VendorCreateModalProps) {
|
export function VendorCreateModal({ open, onClose }: VendorCreateModalProps) {
|
||||||
const form = useVendorRosterForm({ mode: "create", onSuccess: onClose });
|
const form = useVendorRosterForm({
|
||||||
|
mode: "create",
|
||||||
|
onSuccess: onClose,
|
||||||
|
startWithTechnician: true,
|
||||||
|
});
|
||||||
const { resetForm } = form;
|
const { resetForm } = form;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -33,17 +37,24 @@ export function VendorCreateModal({ open, onClose }: VendorCreateModalProps) {
|
||||||
open={open}
|
open={open}
|
||||||
onClose={form.isSaving ? undefined : onClose}
|
onClose={form.isSaving ? undefined : onClose}
|
||||||
fullWidth
|
fullWidth
|
||||||
maxWidth="md"
|
maxWidth="sm"
|
||||||
aria-labelledby="vendor-create-dialog-title"
|
aria-labelledby="vendor-create-dialog-title"
|
||||||
slotProps={{ paper: { role: "dialog" } }}
|
slotProps={{
|
||||||
|
paper: {
|
||||||
|
role: "dialog",
|
||||||
|
sx: { maxHeight: "88vh", borderRadius: 2 },
|
||||||
|
},
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<DialogTitle id="vendor-create-dialog-title">
|
<DialogTitle id="vendor-create-dialog-title">
|
||||||
Add Vendor Company
|
<Text variant="title" as="span">
|
||||||
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.5 }}>
|
Add Vendor
|
||||||
Register a company and add zero, one, or multiple technicians.
|
</Text>
|
||||||
</Typography>
|
<Text variant="description" tone="muted" sx={{ mt: 0.5 }}>
|
||||||
|
Register a vendor company and its technicians.
|
||||||
|
</Text>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent sx={{ maxHeight: "55vh" }}>
|
||||||
<Stack spacing={2} className="mt-1">
|
<Stack spacing={2} className="mt-1">
|
||||||
{form.loadErrorMessage !== null && (
|
{form.loadErrorMessage !== null && (
|
||||||
<VendorRosterLoadErrorAlert message={form.loadErrorMessage} onRetry={form.retryLoad} />
|
<VendorRosterLoadErrorAlert message={form.loadErrorMessage} onRetry={form.retryLoad} />
|
||||||
|
|
@ -73,9 +84,9 @@ export function VendorCreateModal({ open, onClose }: VendorCreateModalProps) {
|
||||||
variant="contained"
|
variant="contained"
|
||||||
type="submit"
|
type="submit"
|
||||||
form="vendor-create-form"
|
form="vendor-create-form"
|
||||||
disabled={form.isSaving || !form.isValid}
|
disabled={form.isSaving}
|
||||||
>
|
>
|
||||||
{form.isSaving ? <CircularProgress size={20} color="inherit" /> : "Add Vendor Company"}
|
{form.isSaving ? <CircularProgress size={20} color="inherit" /> : "Add Vendor"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useEffect, type ReactNode } from "react";
|
import { useEffect, useState, type ReactNode } from "react";
|
||||||
import { useNavigate } from "react-router";
|
import { Controller, useWatch } from "react-hook-form";
|
||||||
import CloseIcon from "@mui/icons-material/Close";
|
import CloseIcon from "@mui/icons-material/Close";
|
||||||
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
||||||
import LaunchIcon from "@mui/icons-material/Launch";
|
import LaunchIcon from "@mui/icons-material/Launch";
|
||||||
|
|
@ -13,15 +13,22 @@ import {
|
||||||
Drawer,
|
Drawer,
|
||||||
IconButton,
|
IconButton,
|
||||||
Link,
|
Link,
|
||||||
List,
|
|
||||||
ListItem,
|
|
||||||
Stack,
|
Stack,
|
||||||
Typography,
|
Switch,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
|
import { VendorRosterConflictAlert } from "./vendor-roster-conflict-alert";
|
||||||
|
import { VendorRosterFormFields } from "./vendor-roster-form-fields";
|
||||||
|
import { VendorRosterLoadErrorAlert } from "./vendor-roster-load-error";
|
||||||
|
import { useVendorRosterForm } from "./use-vendor-roster-form";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import type {
|
||||||
|
VendorCompanyRoster,
|
||||||
|
VendorListItem,
|
||||||
|
VendorRosterTechnician,
|
||||||
|
} from "@/domain/vendors/types/vendor";
|
||||||
import { useVendorCompanyRoster } from "@/domain/vendors/use-cases/use-vendor-company-roster";
|
import { useVendorCompanyRoster } from "@/domain/vendors/use-cases/use-vendor-company-roster";
|
||||||
import type { VendorCompanyRoster, VendorListItem } from "@/domain/vendors/types/vendor";
|
|
||||||
|
|
||||||
const DRAWER_WIDTH = 440;
|
const DRAWER_WIDTH = 420;
|
||||||
|
|
||||||
function splitTrades(value: string | undefined): string[] {
|
function splitTrades(value: string | undefined): string[] {
|
||||||
return (value ?? "")
|
return (value ?? "")
|
||||||
|
|
@ -33,106 +40,45 @@ function splitTrades(value: string | undefined): string[] {
|
||||||
interface VendorDetailDrawerProps {
|
interface VendorDetailDrawerProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
vendor: VendorListItem | null;
|
vendor: VendorListItem | null;
|
||||||
|
startInEdit?: boolean;
|
||||||
|
onRequestDeactivation?: (vendor: VendorListItem) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SectionTitle({ children }: { children: ReactNode }) {
|
function SectionTitle({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<Typography
|
<Text
|
||||||
variant="subtitle2"
|
variant="label"
|
||||||
className="font-semibold uppercase"
|
tone="muted"
|
||||||
sx={{ color: "text.secondary", mb: 1 }}
|
className="font-semibold tracking-[0.06em] uppercase"
|
||||||
|
sx={{ mb: 1 }}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Typography>
|
</Text>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Field({ label, value }: { label: string; value: string }) {
|
function Field({ label, value }: { label: string; value?: string }) {
|
||||||
const display = value && value.trim() !== "" ? value : "—";
|
const display = !value || value.trim() === "" ? "—" : value;
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="caption" sx={{ color: "text.secondary", display: "block" }}>
|
<Text variant="caption" tone="muted" sx={{ display: "block" }}>
|
||||||
{label}
|
{label}
|
||||||
</Typography>
|
</Text>
|
||||||
<Typography variant="body2">{display}</Typography>
|
<Text variant="description">{display}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TechnicianEntry({
|
|
||||||
contactName,
|
|
||||||
phone,
|
|
||||||
email,
|
|
||||||
preferredContact,
|
|
||||||
tradeSpecialties,
|
|
||||||
isActive,
|
|
||||||
totalJobs,
|
|
||||||
}: {
|
|
||||||
contactName: string;
|
|
||||||
phone: string;
|
|
||||||
email: string;
|
|
||||||
preferredContact?: string;
|
|
||||||
tradeSpecialties: string;
|
|
||||||
isActive: boolean;
|
|
||||||
totalJobs: number;
|
|
||||||
}) {
|
|
||||||
const trades = splitTrades(tradeSpecialties);
|
|
||||||
return (
|
|
||||||
<ListItem disableGutters divider>
|
|
||||||
<Stack spacing={0.5} className="w-full">
|
|
||||||
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
|
||||||
<Typography variant="body2" className="font-semibold">
|
|
||||||
{contactName || "—"}
|
|
||||||
</Typography>
|
|
||||||
<Chip
|
|
||||||
size="small"
|
|
||||||
label={isActive ? "Active" : "Inactive"}
|
|
||||||
color={isActive ? "success" : "default"}
|
|
||||||
/>
|
|
||||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
|
||||||
{totalJobs} jobs
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
<Stack direction="row" spacing={2} className="flex flex-wrap">
|
|
||||||
{Boolean(phone) && (
|
|
||||||
<Link href={`tel:${phone}`} underline="hover">
|
|
||||||
{phone}
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
{Boolean(email) && (
|
|
||||||
<Link href={`mailto:${email}`} underline="hover">
|
|
||||||
{email}
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
<Stack direction="row" spacing={1} sx={{ alignItems: "center", flexWrap: "wrap" }}>
|
|
||||||
{Boolean(preferredContact) && (
|
|
||||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
|
||||||
Prefers {preferredContact}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
{trades.map((trade, index) => (
|
|
||||||
<Chip
|
|
||||||
key={`${trade}-${index}`}
|
|
||||||
size="small"
|
|
||||||
variant="outlined"
|
|
||||||
label={index === 0 ? `${trade} (primary)` : trade}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
</ListItem>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DrawerHeader({
|
function DrawerHeader({
|
||||||
roster,
|
roster,
|
||||||
vendor,
|
vendor,
|
||||||
|
technician,
|
||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
roster?: VendorCompanyRoster;
|
roster?: VendorCompanyRoster;
|
||||||
vendor: VendorListItem | null;
|
vendor: VendorListItem | null;
|
||||||
|
technician?: VendorRosterTechnician;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -141,23 +87,18 @@ function DrawerHeader({
|
||||||
sx={{
|
sx={{
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
px: 3,
|
px: 2.5,
|
||||||
py: 2,
|
py: 2,
|
||||||
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
|
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box>
|
<Box>
|
||||||
<Typography
|
<Text id="vendor-detail-drawer-title" variant="title" as="h2" className="font-semibold">
|
||||||
id="vendor-detail-drawer-title"
|
{technician?.contactName || vendor?.contactName || roster?.name || "Vendor company"}
|
||||||
variant="subtitle1"
|
</Text>
|
||||||
component="h2"
|
<Text variant="description" tone="muted">
|
||||||
className="font-semibold"
|
{roster?.name || vendor?.companyName || "Vendor details"}
|
||||||
>
|
</Text>
|
||||||
{roster?.name || vendor?.companyName || "Vendor company"}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
|
||||||
{roster ? `${roster.technicians.length} technician(s)` : "Vendor details"}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
</Box>
|
||||||
<IconButton aria-label="Close drawer" onClick={onClose} size="small">
|
<IconButton aria-label="Close drawer" onClick={onClose} size="small">
|
||||||
<CloseIcon fontSize="small" />
|
<CloseIcon fontSize="small" />
|
||||||
|
|
@ -167,18 +108,19 @@ function DrawerHeader({
|
||||||
}
|
}
|
||||||
|
|
||||||
function CompanySection({ roster }: { roster: VendorCompanyRoster }) {
|
function CompanySection({ roster }: { roster: VendorCompanyRoster }) {
|
||||||
const mapsUrl = roster.googleMapsUrl;
|
const address = [roster.address, roster.city, roster.state, roster.zip]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", ");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack spacing={1.5}>
|
<Stack spacing={1.5}>
|
||||||
|
<Field label="Company" value={roster.name} />
|
||||||
<Field label="Company Phone" value={roster.companyPhone} />
|
<Field label="Company Phone" value={roster.companyPhone} />
|
||||||
<Field label="Email" value={roster.email} />
|
<Field label="Company Email" value={roster.email} />
|
||||||
<Field
|
<Field label="Address" value={address} />
|
||||||
label="Address"
|
{Boolean(roster.googleMapsUrl) && (
|
||||||
value={[roster.address, roster.city, roster.state, roster.zip].filter(Boolean).join(", ")}
|
|
||||||
/>
|
|
||||||
{Boolean(mapsUrl) && (
|
|
||||||
<Link
|
<Link
|
||||||
href={mapsUrl}
|
href={roster.googleMapsUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="inline-flex items-center gap-1"
|
className="inline-flex items-center gap-1"
|
||||||
|
|
@ -191,49 +133,79 @@ function CompanySection({ roster }: { roster: VendorCompanyRoster }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TechniciansSection({ roster }: { roster: VendorCompanyRoster }) {
|
function TechnicianSection({ technician }: { technician: VendorRosterTechnician }) {
|
||||||
|
const trades = splitTrades(technician.tradeSpecialties);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<SectionTitle>Technicians</SectionTitle>
|
<Stack spacing={1.5}>
|
||||||
{roster.technicians.length === 0 ? (
|
<Field label="Phone" value={technician.phone} />
|
||||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
<Box>
|
||||||
No technicians assigned to this company.
|
<Text variant="caption" tone="muted" sx={{ display: "block", mb: 0.75 }}>
|
||||||
</Typography>
|
Trade
|
||||||
) : (
|
</Text>
|
||||||
<List disablePadding>
|
<Stack direction="row" spacing={0.75} sx={{ flexWrap: "wrap" }}>
|
||||||
{roster.technicians.map((technician) => (
|
{trades.length > 0 ? (
|
||||||
<TechnicianEntry
|
trades.map((trade, index) => (
|
||||||
key={String(technician.id ?? technician.contactName)}
|
<Chip key={`${trade}-${index}`} size="small" variant="outlined" label={trade} />
|
||||||
contactName={technician.contactName}
|
))
|
||||||
phone={technician.phone}
|
) : (
|
||||||
email={technician.email}
|
<Text variant="description">—</Text>
|
||||||
preferredContact={technician.preferredContact}
|
)}
|
||||||
tradeSpecialties={technician.tradeSpecialties}
|
</Stack>
|
||||||
isActive={technician.isActive}
|
</Box>
|
||||||
totalJobs={technician.totalJobs}
|
<Stack direction="row" spacing={4} sx={{ alignItems: "flex-end" }}>
|
||||||
|
<Box>
|
||||||
|
<Text variant="title">{technician.totalJobs ?? 0}</Text>
|
||||||
|
<Text variant="caption" tone="muted" sx={{ display: "block" }}>
|
||||||
|
Total Jobs
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text variant="caption" tone="muted" sx={{ display: "block", mb: 0.5 }}>
|
||||||
|
Status
|
||||||
|
</Text>
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={technician.isActive ? "Active" : "Inactive"}
|
||||||
|
color={technician.isActive ? "success" : "default"}
|
||||||
/>
|
/>
|
||||||
))}
|
</Box>
|
||||||
</List>
|
</Stack>
|
||||||
)}
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DrawerBody({ roster }: { roster: VendorCompanyRoster }) {
|
function DrawerBody({
|
||||||
|
roster,
|
||||||
|
technician,
|
||||||
|
}: {
|
||||||
|
roster: VendorCompanyRoster;
|
||||||
|
technician?: VendorRosterTechnician;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<Stack spacing={3} className="flex-1 overflow-y-auto" sx={{ px: 3, py: 3 }}>
|
<Stack spacing={3} className="flex-1 overflow-y-auto" sx={{ p: 2.5 }}>
|
||||||
<SectionTitle>Company</SectionTitle>
|
<Box>
|
||||||
<CompanySection roster={roster} />
|
<SectionTitle>Company</SectionTitle>
|
||||||
|
<CompanySection roster={roster} />
|
||||||
|
</Box>
|
||||||
<Divider />
|
<Divider />
|
||||||
<TechniciansSection roster={roster} />
|
{technician ? (
|
||||||
|
<TechnicianSection technician={technician} />
|
||||||
|
) : (
|
||||||
|
<Text variant="description" tone="muted">
|
||||||
|
No technicians assigned to this company.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
{Boolean(roster.notes) && (
|
{Boolean(roster.notes) && (
|
||||||
<>
|
<>
|
||||||
<Divider />
|
<Divider />
|
||||||
<Box>
|
<Box>
|
||||||
<SectionTitle>Notes</SectionTitle>
|
<SectionTitle>Notes</SectionTitle>
|
||||||
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>
|
<Text variant="description" sx={{ whiteSpace: "pre-wrap" }}>
|
||||||
{roster.notes}
|
{roster.notes}
|
||||||
</Typography>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
@ -246,8 +218,8 @@ function DrawerActions({ onEdit }: { onEdit: () => void }) {
|
||||||
<Stack
|
<Stack
|
||||||
direction="row"
|
direction="row"
|
||||||
sx={{
|
sx={{
|
||||||
px: 3,
|
px: 2.5,
|
||||||
py: 2,
|
py: 1.5,
|
||||||
borderTop: (theme) => `1px solid ${theme.palette.divider}`,
|
borderTop: (theme) => `1px solid ${theme.palette.divider}`,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "flex-end",
|
justifyContent: "flex-end",
|
||||||
|
|
@ -260,8 +232,143 @@ function DrawerActions({ onEdit }: { onEdit: () => void }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VendorDetailDrawer({ open, vendor, onClose }: VendorDetailDrawerProps) {
|
function DrawerEditor({
|
||||||
const navigate = useNavigate();
|
vendor,
|
||||||
|
onClose,
|
||||||
|
onSaved,
|
||||||
|
onRequestDeactivation = () => undefined,
|
||||||
|
}: {
|
||||||
|
vendor: VendorListItem;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: () => void;
|
||||||
|
onRequestDeactivation: (vendor: VendorListItem) => void;
|
||||||
|
}) {
|
||||||
|
const form = useVendorRosterForm({
|
||||||
|
mode: "update",
|
||||||
|
vendorId: vendor.id ?? undefined,
|
||||||
|
companyId: vendor.id == null ? (vendor.companyId ?? undefined) : undefined,
|
||||||
|
onSuccess: onSaved,
|
||||||
|
});
|
||||||
|
const roster = form.roster;
|
||||||
|
const technicians = useWatch({ control: form.control, name: "technicians" }) ?? [];
|
||||||
|
const selectedIndex = technicians.findIndex(
|
||||||
|
(technician) => technician.id != null && String(technician.id) === String(vendor.id),
|
||||||
|
);
|
||||||
|
const selectedTotalJobs =
|
||||||
|
roster?.technicians.find(
|
||||||
|
(technician) => technician.id != null && String(technician.id) === String(vendor.id),
|
||||||
|
)?.totalJobs ?? vendor.totalJobs;
|
||||||
|
|
||||||
|
if (form.isLoading) {
|
||||||
|
return <CircularProgress size={28} sx={{ m: "auto" }} />;
|
||||||
|
}
|
||||||
|
if (form.isError || !roster) {
|
||||||
|
return (
|
||||||
|
<Alert severity="error" sx={{ m: 2 }}>
|
||||||
|
Failed to load vendor company. Try again.
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
component="form"
|
||||||
|
id="vendor-drawer-edit-form"
|
||||||
|
onSubmit={form.handleSubmit(form.submit)}
|
||||||
|
className="flex min-h-0 flex-1 flex-col"
|
||||||
|
>
|
||||||
|
<Stack spacing={2} className="flex-1 overflow-y-auto" sx={{ p: 2.5 }}>
|
||||||
|
{form.loadErrorMessage !== null && (
|
||||||
|
<VendorRosterLoadErrorAlert message={form.loadErrorMessage} onRetry={form.retryLoad} />
|
||||||
|
)}
|
||||||
|
{form.conflict !== null && (
|
||||||
|
<VendorRosterConflictAlert conflict={form.conflict} onReload={form.reload} />
|
||||||
|
)}
|
||||||
|
<VendorRosterFormFields
|
||||||
|
control={form.control}
|
||||||
|
errors={form.errors}
|
||||||
|
tradeOptions={form.trades}
|
||||||
|
showTechnicianStatus={false}
|
||||||
|
/>
|
||||||
|
{selectedIndex >= 0 && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<Stack direction="row" sx={{ alignItems: "center", justifyContent: "space-between" }}>
|
||||||
|
<Box>
|
||||||
|
<Text variant="title">{selectedTotalJobs}</Text>
|
||||||
|
<Text variant="caption" tone="muted">
|
||||||
|
Total Jobs
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name={`technicians.${selectedIndex}.isActive`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
||||||
|
<Text variant="description">{field.value ? "Active" : "Inactive"}</Text>
|
||||||
|
<Switch
|
||||||
|
checked={Boolean(field.value)}
|
||||||
|
slotProps={{ input: { "aria-label": "Active status" } }}
|
||||||
|
onChange={(_event, checked) => {
|
||||||
|
if (field.value && !checked) onRequestDeactivation(vendor);
|
||||||
|
else field.onChange(checked);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
spacing={1.5}
|
||||||
|
sx={{ p: 2, borderTop: 1, borderColor: "divider", justifyContent: "flex-end" }}
|
||||||
|
>
|
||||||
|
<Button onClick={onClose} disabled={form.isSaving}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" variant="contained" disabled={form.isSaving || !form.isValid}>
|
||||||
|
{form.isSaving ? "Saving..." : "Save changes"}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTechnician(
|
||||||
|
roster: VendorCompanyRoster | undefined,
|
||||||
|
vendor: VendorListItem | null,
|
||||||
|
): VendorRosterTechnician | undefined {
|
||||||
|
if (!roster || !vendor) return undefined;
|
||||||
|
|
||||||
|
const match = roster.technicians.find(
|
||||||
|
(technician) =>
|
||||||
|
vendor.id != null && technician.id != null && String(technician.id) === String(vendor.id),
|
||||||
|
);
|
||||||
|
if (match) return match;
|
||||||
|
if (!vendor.contactName) return undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: vendor.id ?? undefined,
|
||||||
|
contactName: vendor.contactName,
|
||||||
|
phone: vendor.phone,
|
||||||
|
email: vendor.email,
|
||||||
|
tradeSpecialties: vendor.tradeSpecialties,
|
||||||
|
isActive: vendor.isActive,
|
||||||
|
totalJobs: vendor.totalJobs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VendorDetailDrawer({
|
||||||
|
open,
|
||||||
|
vendor,
|
||||||
|
startInEdit = false,
|
||||||
|
onRequestDeactivation = () => undefined,
|
||||||
|
onClose,
|
||||||
|
}: VendorDetailDrawerProps) {
|
||||||
|
const [isEditing, setIsEditing] = useState(startInEdit);
|
||||||
const vendorId = vendor?.id;
|
const vendorId = vendor?.id;
|
||||||
const companyId = vendor?.companyId;
|
const companyId = vendor?.companyId;
|
||||||
const hasVendorId = vendorId != null;
|
const hasVendorId = vendorId != null;
|
||||||
|
|
@ -274,25 +381,30 @@ export function VendorDetailDrawer({ open, vendor, onClose }: VendorDetailDrawer
|
||||||
!hasVendorId ? (companyId ?? undefined) : undefined,
|
!hasVendorId ? (companyId ?? undefined) : undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
const editHref = hasVendorId
|
const selectedTechnician = resolveTechnician(roster, vendor);
|
||||||
? `/vendors/edit/${vendorId}`
|
|
||||||
: `/vendors/company/${companyId ?? ""}`;
|
useEffect(() => {
|
||||||
|
if (open) setIsEditing(startInEdit);
|
||||||
|
}, [open, startInEdit, vendor]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
|
|
||||||
const closeOnEscape = (event: KeyboardEvent) => {
|
const closeOnEscape = (event: KeyboardEvent) => {
|
||||||
if (event.key === "Escape") onClose();
|
if (event.key === "Escape" && !isEditing) onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("keydown", closeOnEscape);
|
window.addEventListener("keydown", closeOnEscape);
|
||||||
return () => window.removeEventListener("keydown", closeOnEscape);
|
return () => window.removeEventListener("keydown", closeOnEscape);
|
||||||
}, [onClose, open]);
|
}, [isEditing, onClose, open]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer
|
<Drawer
|
||||||
open={open}
|
open={open}
|
||||||
onClose={onClose}
|
onClose={(_event, reason) => {
|
||||||
|
if (reason === "escapeKeyDown" && isEditing) return;
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
anchor="right"
|
anchor="right"
|
||||||
aria-labelledby="vendor-detail-drawer-title"
|
aria-labelledby="vendor-detail-drawer-title"
|
||||||
ModalProps={{ keepMounted: false }}
|
ModalProps={{ keepMounted: false }}
|
||||||
|
|
@ -304,9 +416,21 @@ export function VendorDetailDrawer({ open, vendor, onClose }: VendorDetailDrawer
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stack className="flex h-full flex-col">
|
<Stack className="flex h-full flex-col">
|
||||||
<DrawerHeader roster={roster} vendor={vendor} onClose={onClose} />
|
<DrawerHeader
|
||||||
|
roster={roster}
|
||||||
|
vendor={vendor}
|
||||||
|
technician={selectedTechnician}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
{isLoading ? (
|
{isEditing && vendor ? (
|
||||||
|
<DrawerEditor
|
||||||
|
vendor={vendor}
|
||||||
|
onClose={onClose}
|
||||||
|
onSaved={() => setIsEditing(false)}
|
||||||
|
onRequestDeactivation={onRequestDeactivation}
|
||||||
|
/>
|
||||||
|
) : isLoading ? (
|
||||||
<Box className="flex flex-1 items-center justify-center">
|
<Box className="flex flex-1 items-center justify-center">
|
||||||
<CircularProgress size={28} />
|
<CircularProgress size={28} />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -316,14 +440,14 @@ export function VendorDetailDrawer({ open, vendor, onClose }: VendorDetailDrawer
|
||||||
</Alert>
|
</Alert>
|
||||||
) : roster ? (
|
) : roster ? (
|
||||||
<>
|
<>
|
||||||
<DrawerBody roster={roster} />
|
<DrawerBody roster={roster} technician={selectedTechnician} />
|
||||||
<DrawerActions onEdit={() => navigate(editHref)} />
|
<DrawerActions onEdit={() => setIsEditing(true)} />
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Box className="flex flex-1 items-center justify-center">
|
<Box className="flex flex-1 items-center justify-center">
|
||||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
<Text variant="description" tone="muted">
|
||||||
No vendor selected.
|
No vendor selected.
|
||||||
</Typography>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
|
||||||
252
src/app/(protected)/vendors/_components/vendor-filter-options.tsx
vendored
Normal file
|
|
@ -0,0 +1,252 @@
|
||||||
|
import type { Dispatch, ReactNode, SetStateAction } from "react";
|
||||||
|
import BusinessOutlinedIcon from "@mui/icons-material/BusinessOutlined";
|
||||||
|
import BuildOutlinedIcon from "@mui/icons-material/BuildOutlined";
|
||||||
|
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||||
|
import LocationOnOutlinedIcon from "@mui/icons-material/LocationOnOutlined";
|
||||||
|
import TagOutlinedIcon from "@mui/icons-material/TagOutlined";
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionDetails,
|
||||||
|
AccordionSummary,
|
||||||
|
Autocomplete,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Chip,
|
||||||
|
Stack,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import type {
|
||||||
|
VendorFacets,
|
||||||
|
VendorFacetCompany,
|
||||||
|
VendorFacetJobBucket,
|
||||||
|
VendorFacetLocation,
|
||||||
|
} from "@/domain/vendors/types/vendor";
|
||||||
|
import type { VendorFilters } from "./vendor-filters";
|
||||||
|
|
||||||
|
type DraftSetter = Dispatch<SetStateAction<VendorFilters>>;
|
||||||
|
|
||||||
|
function FilterSection({
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Accordion disableGutters elevation={0} square sx={{ borderBottom: 1, borderColor: "divider" }}>
|
||||||
|
<AccordionSummary expandIcon={<ExpandMoreIcon />} aria-controls={`${title}-filters-content`}>
|
||||||
|
<Stack direction="row" spacing={1.5} sx={{ alignItems: "center" }}>
|
||||||
|
<Box sx={{ color: "primary.main", display: "flex" }}>{icon}</Box>
|
||||||
|
<Typography variant="subtitle1" className="font-semibold">
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</AccordionSummary>
|
||||||
|
<AccordionDetails id={`${title}-filters-content`} sx={{ pt: 0, pb: 2.5 }}>
|
||||||
|
{children}
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CompanyFilter({
|
||||||
|
options,
|
||||||
|
draft,
|
||||||
|
setDraft,
|
||||||
|
}: {
|
||||||
|
options: VendorFacetCompany[];
|
||||||
|
draft: VendorFilters;
|
||||||
|
setDraft: DraftSetter;
|
||||||
|
}) {
|
||||||
|
const allSelected = draft.companies.length === options.length && options.length > 0;
|
||||||
|
return (
|
||||||
|
<FilterSection title="Company" icon={<BusinessOutlinedIcon />}>
|
||||||
|
<Stack direction="row" sx={{ alignItems: "center", justifyContent: "space-between" }}>
|
||||||
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||||
|
Select vendor companies
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={() =>
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
companies: allSelected ? [] : options.map((company) => company.name),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{allSelected ? "Clear all" : "Select all"}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
<Autocomplete
|
||||||
|
multiple
|
||||||
|
size="small"
|
||||||
|
options={options}
|
||||||
|
getOptionLabel={(option) => (typeof option === "string" ? option : option.name)}
|
||||||
|
isOptionEqualToValue={(option, value) =>
|
||||||
|
typeof option === "string" || typeof value === "string"
|
||||||
|
? option === value
|
||||||
|
: option.name === value.name
|
||||||
|
}
|
||||||
|
value={options.filter((option) => draft.companies.includes(option.name))}
|
||||||
|
onChange={(_event, value) => {
|
||||||
|
const next = (value as (string | VendorFacetCompany)[]).map((entry) =>
|
||||||
|
typeof entry === "string" ? entry : entry.name,
|
||||||
|
);
|
||||||
|
setDraft((current) => ({ ...current, companies: next }));
|
||||||
|
}}
|
||||||
|
renderOption={(props, option, { selected }) => {
|
||||||
|
const { key, ...optionProps } = props;
|
||||||
|
return (
|
||||||
|
<li key={key} {...optionProps}>
|
||||||
|
<Checkbox checked={selected} size="small" sx={{ mr: 1 }} />
|
||||||
|
{option.name}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
renderValue={(value, getItemProps) =>
|
||||||
|
value.map((option, index) => {
|
||||||
|
const label = typeof option === "string" ? option : option.name;
|
||||||
|
const { key, ...chipProps } = getItemProps({ index });
|
||||||
|
return <Chip key={key} size="small" label={label} {...chipProps} />;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
placeholder={draft.companies.length === 0 ? "All companies" : ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FilterSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TradeFilter({ options, draft, setDraft }: FilterProps<string>) {
|
||||||
|
return (
|
||||||
|
<FilterSection title="Trade" icon={<BuildOutlinedIcon />}>
|
||||||
|
<Autocomplete
|
||||||
|
multiple
|
||||||
|
size="small"
|
||||||
|
options={options}
|
||||||
|
value={draft.trades}
|
||||||
|
onChange={(_event, value) =>
|
||||||
|
setDraft((current) => ({ ...current, trades: value as string[] }))
|
||||||
|
}
|
||||||
|
renderValue={(value, getItemProps) =>
|
||||||
|
value.map((option, index) => {
|
||||||
|
const { key, ...chipProps } = getItemProps({ index });
|
||||||
|
return <Chip key={key} size="small" label={option} {...chipProps} />;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField {...params} placeholder={draft.trades.length === 0 ? "All trades" : ""} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FilterSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilterProps<T> {
|
||||||
|
options: T[];
|
||||||
|
draft: VendorFilters;
|
||||||
|
setDraft: DraftSetter;
|
||||||
|
}
|
||||||
|
|
||||||
|
function LocationFilter({ options, draft, setDraft }: FilterProps<VendorFacetLocation>) {
|
||||||
|
return (
|
||||||
|
<FilterSection title="Location" icon={<LocationOnOutlinedIcon />}>
|
||||||
|
<Autocomplete
|
||||||
|
multiple
|
||||||
|
size="small"
|
||||||
|
options={options}
|
||||||
|
getOptionLabel={(option) => (typeof option === "string" ? option : option.label)}
|
||||||
|
isOptionEqualToValue={(option, value) =>
|
||||||
|
typeof option === "string" || typeof value === "string"
|
||||||
|
? option === value
|
||||||
|
: option.label === value.label
|
||||||
|
}
|
||||||
|
value={options.filter((option) => draft.locations.includes(option.label))}
|
||||||
|
onChange={(_event, value) => {
|
||||||
|
const next = (value as (string | VendorFacetLocation)[]).map((entry) =>
|
||||||
|
typeof entry === "string" ? entry : entry.label,
|
||||||
|
);
|
||||||
|
setDraft((current) => ({ ...current, locations: next }));
|
||||||
|
}}
|
||||||
|
renderValue={(value, getItemProps) =>
|
||||||
|
value.map((option, index) => {
|
||||||
|
const label = typeof option === "string" ? option : option.label;
|
||||||
|
const { key, ...chipProps } = getItemProps({ index });
|
||||||
|
return <Chip key={key} size="small" label={label} {...chipProps} />;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
placeholder={draft.locations.length === 0 ? "All locations" : ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FilterSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function JobCountFilter({ options, draft, setDraft }: FilterProps<VendorFacetJobBucket>) {
|
||||||
|
return (
|
||||||
|
<FilterSection title="Total Jobs" icon={<TagOutlinedIcon />}>
|
||||||
|
<Autocomplete
|
||||||
|
multiple
|
||||||
|
size="small"
|
||||||
|
options={options}
|
||||||
|
getOptionLabel={(option) => (typeof option === "string" ? option : option.label)}
|
||||||
|
isOptionEqualToValue={(option, value) =>
|
||||||
|
typeof option === "string" || typeof value === "string"
|
||||||
|
? option === value
|
||||||
|
: option.id === value.id
|
||||||
|
}
|
||||||
|
value={options.filter((option) => draft.jobBuckets.includes(option.id))}
|
||||||
|
onChange={(_event, value) => {
|
||||||
|
const next = (value as (string | VendorFacetJobBucket)[]).map((entry) =>
|
||||||
|
typeof entry === "string" ? entry : entry.id,
|
||||||
|
);
|
||||||
|
setDraft((current) => ({ ...current, jobBuckets: next }));
|
||||||
|
}}
|
||||||
|
renderValue={(value, getItemProps) =>
|
||||||
|
value.map((option, index) => {
|
||||||
|
const label = typeof option === "string" ? option : option.label;
|
||||||
|
const { key, ...chipProps } = getItemProps({ index });
|
||||||
|
return <Chip key={key} size="small" label={label} {...chipProps} />;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
placeholder={draft.jobBuckets.length === 0 ? "All job counts" : ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FilterSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VendorFilterOptions({
|
||||||
|
facets,
|
||||||
|
draft,
|
||||||
|
setDraft,
|
||||||
|
}: {
|
||||||
|
facets: VendorFacets | undefined;
|
||||||
|
draft: VendorFilters;
|
||||||
|
setDraft: DraftSetter;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<CompanyFilter options={facets?.companies ?? []} draft={draft} setDraft={setDraft} />
|
||||||
|
<TradeFilter options={facets?.trades ?? []} draft={draft} setDraft={setDraft} />
|
||||||
|
<LocationFilter options={facets?.locations ?? []} draft={draft} setDraft={setDraft} />
|
||||||
|
<JobCountFilter options={facets?.jobBuckets ?? []} draft={draft} setDraft={setDraft} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,32 +1,9 @@
|
||||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import BusinessOutlinedIcon from "@mui/icons-material/BusinessOutlined";
|
|
||||||
import BuildOutlinedIcon from "@mui/icons-material/BuildOutlined";
|
|
||||||
import CloseIcon from "@mui/icons-material/Close";
|
import CloseIcon from "@mui/icons-material/Close";
|
||||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
import { Box, Button, Chip, Drawer, IconButton, Stack, Typography } from "@mui/material";
|
||||||
import LocationOnOutlinedIcon from "@mui/icons-material/LocationOnOutlined";
|
import { VendorFilterOptions } from "./vendor-filter-options";
|
||||||
import TagOutlinedIcon from "@mui/icons-material/TagOutlined";
|
|
||||||
import {
|
|
||||||
Accordion,
|
|
||||||
AccordionDetails,
|
|
||||||
AccordionSummary,
|
|
||||||
Autocomplete,
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Checkbox,
|
|
||||||
Chip,
|
|
||||||
Drawer,
|
|
||||||
IconButton,
|
|
||||||
Stack,
|
|
||||||
TextField,
|
|
||||||
Typography,
|
|
||||||
} from "@mui/material";
|
|
||||||
import type {
|
|
||||||
VendorFacets,
|
|
||||||
VendorFacetCompany,
|
|
||||||
VendorFacetJobBucket,
|
|
||||||
VendorFacetLocation,
|
|
||||||
} from "@/domain/vendors/types/vendor";
|
|
||||||
import { emptyVendorFilters, type VendorFilters } from "./vendor-filters";
|
import { emptyVendorFilters, type VendorFilters } from "./vendor-filters";
|
||||||
|
import type { VendorFacets } from "@/domain/vendors/types/vendor";
|
||||||
|
|
||||||
interface VendorFiltersDrawerProps {
|
interface VendorFiltersDrawerProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|
@ -38,32 +15,6 @@ interface VendorFiltersDrawerProps {
|
||||||
|
|
||||||
const DRAWER_WIDTH = 360;
|
const DRAWER_WIDTH = 360;
|
||||||
|
|
||||||
function FilterSection({
|
|
||||||
title,
|
|
||||||
icon,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
icon: ReactNode;
|
|
||||||
children: ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Accordion disableGutters elevation={0} square sx={{ borderBottom: 1, borderColor: "divider" }}>
|
|
||||||
<AccordionSummary expandIcon={<ExpandMoreIcon />} aria-controls={`${title}-filters-content`}>
|
|
||||||
<Stack direction="row" spacing={1.5} sx={{ alignItems: "center" }}>
|
|
||||||
<Box sx={{ color: "primary.main", display: "flex" }}>{icon}</Box>
|
|
||||||
<Typography variant="subtitle1" className="font-semibold">
|
|
||||||
{title}
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
</AccordionSummary>
|
|
||||||
<AccordionDetails id={`${title}-filters-content`} sx={{ pt: 0, pb: 2.5 }}>
|
|
||||||
{children}
|
|
||||||
</AccordionDetails>
|
|
||||||
</Accordion>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function VendorFiltersDrawer({
|
export function VendorFiltersDrawer({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
|
|
@ -74,23 +25,12 @@ export function VendorFiltersDrawer({
|
||||||
const [draft, setDraft] = useState<VendorFilters>(appliedFilters);
|
const [draft, setDraft] = useState<VendorFilters>(appliedFilters);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) setDraft(appliedFilters);
|
||||||
setDraft(appliedFilters);
|
|
||||||
}
|
|
||||||
}, [open, appliedFilters]);
|
}, [open, appliedFilters]);
|
||||||
|
|
||||||
const companyOptions = useMemo(() => facets?.companies ?? [], [facets]);
|
|
||||||
const tradeOptions = useMemo(() => facets?.trades ?? [], [facets]);
|
|
||||||
const locationOptions = useMemo(() => facets?.locations ?? [], [facets]);
|
|
||||||
const jobBucketOptions = useMemo(() => facets?.jobBuckets ?? [], [facets]);
|
|
||||||
|
|
||||||
const activeCount =
|
const activeCount =
|
||||||
draft.companies.length + draft.trades.length + draft.locations.length + draft.jobBuckets.length;
|
draft.companies.length + draft.trades.length + draft.locations.length + draft.jobBuckets.length;
|
||||||
|
|
||||||
const handleClear = () => {
|
|
||||||
setDraft(emptyVendorFilters);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleApply = () => {
|
const handleApply = () => {
|
||||||
onApply(draft);
|
onApply(draft);
|
||||||
onClose();
|
onClose();
|
||||||
|
|
@ -133,165 +73,7 @@ export function VendorFiltersDrawer({
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Box className="flex-1 overflow-y-auto" sx={{ px: 2.5 }}>
|
<Box className="flex-1 overflow-y-auto" sx={{ px: 2.5 }}>
|
||||||
<FilterSection title="Company" icon={<BusinessOutlinedIcon />}>
|
<VendorFilterOptions facets={facets} draft={draft} setDraft={setDraft} />
|
||||||
<Stack direction="row" sx={{ alignItems: "center", justifyContent: "space-between" }}>
|
|
||||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
|
||||||
Select vendor companies
|
|
||||||
</Typography>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
onClick={() =>
|
|
||||||
setDraft((current) => ({
|
|
||||||
...current,
|
|
||||||
companies:
|
|
||||||
current.companies.length === companyOptions.length
|
|
||||||
? []
|
|
||||||
: companyOptions.map((company) => company.name),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{draft.companies.length === companyOptions.length && companyOptions.length > 0
|
|
||||||
? "Clear all"
|
|
||||||
: "Select all"}
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
<Autocomplete
|
|
||||||
multiple
|
|
||||||
size="small"
|
|
||||||
options={companyOptions}
|
|
||||||
getOptionLabel={(option) => (typeof option === "string" ? option : option.name)}
|
|
||||||
isOptionEqualToValue={(option, value) =>
|
|
||||||
typeof option === "string" || typeof value === "string"
|
|
||||||
? option === value
|
|
||||||
: option.name === value.name
|
|
||||||
}
|
|
||||||
value={companyOptions.filter((option) => draft.companies.includes(option.name))}
|
|
||||||
onChange={(_event, value) => {
|
|
||||||
const next = (value as (string | VendorFacetCompany)[]).map((entry) =>
|
|
||||||
typeof entry === "string" ? entry : entry.name,
|
|
||||||
);
|
|
||||||
setDraft((current) => ({ ...current, companies: next }));
|
|
||||||
}}
|
|
||||||
renderOption={(props, option, { selected }) => {
|
|
||||||
const { key, ...optionProps } = props;
|
|
||||||
return (
|
|
||||||
<li key={key} {...optionProps}>
|
|
||||||
<Checkbox checked={selected} size="small" sx={{ mr: 1 }} />
|
|
||||||
{option.name}
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
renderValue={(value, getItemProps) =>
|
|
||||||
value.map((option, index) => {
|
|
||||||
const label = typeof option === "string" ? option : option.name;
|
|
||||||
const tagProps = getItemProps({ index });
|
|
||||||
const { key, ...chipProps } = tagProps;
|
|
||||||
return <Chip key={key} size="small" label={label} {...chipProps} />;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
renderInput={(params) => (
|
|
||||||
<TextField
|
|
||||||
{...params}
|
|
||||||
placeholder={draft.companies.length === 0 ? "All companies" : ""}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</FilterSection>
|
|
||||||
|
|
||||||
<FilterSection title="Trade" icon={<BuildOutlinedIcon />}>
|
|
||||||
<Autocomplete
|
|
||||||
multiple
|
|
||||||
size="small"
|
|
||||||
options={tradeOptions}
|
|
||||||
value={draft.trades}
|
|
||||||
onChange={(_event, value) =>
|
|
||||||
setDraft((current) => ({ ...current, trades: value as string[] }))
|
|
||||||
}
|
|
||||||
renderValue={(value, getItemProps) =>
|
|
||||||
value.map((option, index) => {
|
|
||||||
const tagProps = getItemProps({ index });
|
|
||||||
const { key, ...chipProps } = tagProps;
|
|
||||||
return <Chip key={key} size="small" label={option} {...chipProps} />;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
renderInput={(params) => (
|
|
||||||
<TextField
|
|
||||||
{...params}
|
|
||||||
placeholder={draft.trades.length === 0 ? "All trades" : ""}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</FilterSection>
|
|
||||||
|
|
||||||
<FilterSection title="Location" icon={<LocationOnOutlinedIcon />}>
|
|
||||||
<Autocomplete
|
|
||||||
multiple
|
|
||||||
size="small"
|
|
||||||
options={locationOptions}
|
|
||||||
getOptionLabel={(option) => (typeof option === "string" ? option : option.label)}
|
|
||||||
isOptionEqualToValue={(option, value) =>
|
|
||||||
typeof option === "string" || typeof value === "string"
|
|
||||||
? option === value
|
|
||||||
: option.label === value.label
|
|
||||||
}
|
|
||||||
value={locationOptions.filter((option) => draft.locations.includes(option.label))}
|
|
||||||
onChange={(_event, value) => {
|
|
||||||
const next = (value as (string | VendorFacetLocation)[]).map((entry) =>
|
|
||||||
typeof entry === "string" ? entry : entry.label,
|
|
||||||
);
|
|
||||||
setDraft((current) => ({ ...current, locations: next }));
|
|
||||||
}}
|
|
||||||
renderValue={(value, getItemProps) =>
|
|
||||||
value.map((option, index) => {
|
|
||||||
const label = typeof option === "string" ? option : option.label;
|
|
||||||
const tagProps = getItemProps({ index });
|
|
||||||
const { key, ...chipProps } = tagProps;
|
|
||||||
return <Chip key={key} size="small" label={label} {...chipProps} />;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
renderInput={(params) => (
|
|
||||||
<TextField
|
|
||||||
{...params}
|
|
||||||
placeholder={draft.locations.length === 0 ? "All locations" : ""}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</FilterSection>
|
|
||||||
|
|
||||||
<FilterSection title="Total Jobs" icon={<TagOutlinedIcon />}>
|
|
||||||
<Autocomplete
|
|
||||||
multiple
|
|
||||||
size="small"
|
|
||||||
options={jobBucketOptions}
|
|
||||||
getOptionLabel={(option) => (typeof option === "string" ? option : option.label)}
|
|
||||||
isOptionEqualToValue={(option, value) =>
|
|
||||||
typeof option === "string" || typeof value === "string"
|
|
||||||
? option === value
|
|
||||||
: option.id === value.id
|
|
||||||
}
|
|
||||||
value={jobBucketOptions.filter((option) => draft.jobBuckets.includes(option.id))}
|
|
||||||
onChange={(_event, value) => {
|
|
||||||
const next = (value as (string | VendorFacetJobBucket)[]).map((entry) =>
|
|
||||||
typeof entry === "string" ? entry : entry.id,
|
|
||||||
);
|
|
||||||
setDraft((current) => ({ ...current, jobBuckets: next }));
|
|
||||||
}}
|
|
||||||
renderValue={(value, getItemProps) =>
|
|
||||||
value.map((option, index) => {
|
|
||||||
const label = typeof option === "string" ? option : option.label;
|
|
||||||
const tagProps = getItemProps({ index });
|
|
||||||
const { key, ...chipProps } = tagProps;
|
|
||||||
return <Chip key={key} size="small" label={label} {...chipProps} />;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
renderInput={(params) => (
|
|
||||||
<TextField
|
|
||||||
{...params}
|
|
||||||
placeholder={draft.jobBuckets.length === 0 ? "All job counts" : ""}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</FilterSection>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Stack
|
<Stack
|
||||||
|
|
@ -303,11 +85,16 @@ export function VendorFiltersDrawer({
|
||||||
borderTop: (theme) => `1px solid ${theme.palette.divider}`,
|
borderTop: (theme) => `1px solid ${theme.palette.divider}`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Button variant="outlined" onClick={handleClear} disabled={activeCount === 0} fullWidth>
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => setDraft(emptyVendorFilters)}
|
||||||
|
disabled={activeCount === 0}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
Clear all
|
Clear all
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="contained" onClick={handleApply} fullWidth>
|
<Button variant="contained" onClick={handleApply} fullWidth>
|
||||||
Apply
|
Apply filters
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ interface VendorRosterFormFieldsProps {
|
||||||
selectedCompanyId?: string | number | null;
|
selectedCompanyId?: string | number | null;
|
||||||
onSelectCompany?: (company: VendorFacetCompany | null) => Promise<void>;
|
onSelectCompany?: (company: VendorFacetCompany | null) => Promise<void>;
|
||||||
onClearSelectedCompany?: (nextName?: string) => void;
|
onClearSelectedCompany?: (nextName?: string) => void;
|
||||||
|
showTechnicianStatus?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CompanyNameField({
|
function CompanyNameField({
|
||||||
|
|
@ -54,6 +55,7 @@ function CompanyNameField({
|
||||||
<TextField
|
<TextField
|
||||||
{...field}
|
{...field}
|
||||||
label="Company"
|
label="Company"
|
||||||
|
placeholder="Start typing a company…"
|
||||||
required
|
required
|
||||||
error={Boolean(errors.name)}
|
error={Boolean(errors.name)}
|
||||||
helperText={errors.name?.message}
|
helperText={errors.name?.message}
|
||||||
|
|
@ -98,8 +100,9 @@ function CompanyNameField({
|
||||||
{...params}
|
{...params}
|
||||||
label="Company"
|
label="Company"
|
||||||
required
|
required
|
||||||
|
placeholder="Start typing a company…"
|
||||||
error={Boolean(errors.name)}
|
error={Boolean(errors.name)}
|
||||||
helperText={errors.name?.message ?? "Choose an existing company or enter a new one"}
|
helperText={errors.name?.message}
|
||||||
inputRef={field.ref}
|
inputRef={field.ref}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
@ -119,10 +122,7 @@ function CompanyFields({
|
||||||
}: VendorRosterFormFieldsProps) {
|
}: VendorRosterFormFieldsProps) {
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="subtitle2" className="font-semibold uppercase">
|
<Stack spacing={2}>
|
||||||
Company
|
|
||||||
</Typography>
|
|
||||||
<Stack spacing={2} className="mt-2">
|
|
||||||
<CompanyNameField
|
<CompanyNameField
|
||||||
control={control}
|
control={control}
|
||||||
errors={errors}
|
errors={errors}
|
||||||
|
|
@ -138,13 +138,13 @@ function CompanyFields({
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<TextField
|
<TextField
|
||||||
{...field}
|
{...field}
|
||||||
label="Company Phone"
|
label="Company Phone (optional)"
|
||||||
placeholder="(XXX) XXX-XXXX"
|
placeholder="(214) 555-0100"
|
||||||
inputMode="tel"
|
inputMode="tel"
|
||||||
value={field.value ?? ""}
|
value={field.value ?? ""}
|
||||||
onChange={(event) => field.onChange(formatNorthAmericanPhone(event.target.value))}
|
onChange={(event) => field.onChange(formatNorthAmericanPhone(event.target.value))}
|
||||||
error={Boolean(errors.companyPhone)}
|
error={Boolean(errors.companyPhone)}
|
||||||
helperText={errors.companyPhone?.message ?? "Phone or email required"}
|
helperText={errors.companyPhone?.message}
|
||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
@ -155,7 +155,8 @@ function CompanyFields({
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<TextField
|
<TextField
|
||||||
{...field}
|
{...field}
|
||||||
label="Email"
|
label="Email (optional)"
|
||||||
|
placeholder="dispatch@vendor.com"
|
||||||
type="email"
|
type="email"
|
||||||
error={Boolean(errors.email)}
|
error={Boolean(errors.email)}
|
||||||
helperText={errors.email?.message}
|
helperText={errors.email?.message}
|
||||||
|
|
@ -167,9 +168,16 @@ function CompanyFields({
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="address"
|
name="address"
|
||||||
render={({ field }) => <TextField {...field} label="Address" fullWidth />}
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
label="Address (optional)"
|
||||||
|
placeholder="123 Industrial Pkwy, Dallas, TX 75201"
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} sx={{ display: "none" }}>
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="city"
|
name="city"
|
||||||
|
|
@ -194,10 +202,10 @@ function CompanyFields({
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<TextField
|
<TextField
|
||||||
{...field}
|
{...field}
|
||||||
label="Google Maps URL"
|
label="Google Maps URL (optional)"
|
||||||
placeholder="https://maps.google.com/..."
|
placeholder="https://maps.google.com/…"
|
||||||
error={Boolean(errors.googleMapsUrl)}
|
error={Boolean(errors.googleMapsUrl)}
|
||||||
helperText={errors.googleMapsUrl?.message ?? "Must be an HTTPS link"}
|
helperText={errors.googleMapsUrl?.message}
|
||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
@ -206,7 +214,14 @@ function CompanyFields({
|
||||||
control={control}
|
control={control}
|
||||||
name="notes"
|
name="notes"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<TextField {...field} label="Notes" multiline minRows={2} fullWidth />
|
<TextField
|
||||||
|
{...field}
|
||||||
|
label="Notes (optional)"
|
||||||
|
placeholder="Internal notes, scheduling preferences, certifications…"
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
@ -221,6 +236,7 @@ interface TechnicianRowProps {
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
canRemove: boolean;
|
canRemove: boolean;
|
||||||
tradeOptions: string[];
|
tradeOptions: string[];
|
||||||
|
showStatus: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function TechnicianRow({
|
function TechnicianRow({
|
||||||
|
|
@ -230,12 +246,13 @@ function TechnicianRow({
|
||||||
onRemove,
|
onRemove,
|
||||||
canRemove,
|
canRemove,
|
||||||
tradeOptions,
|
tradeOptions,
|
||||||
|
showStatus,
|
||||||
}: TechnicianRowProps) {
|
}: TechnicianRowProps) {
|
||||||
return (
|
return (
|
||||||
<Stack spacing={2} className="rounded-md border border-solid border-gray-200 p-3">
|
<Stack spacing={2} className="rounded-md border border-solid border-gray-200 p-3">
|
||||||
<Stack direction="row" sx={{ alignItems: "center", justifyContent: "space-between" }}>
|
<Stack direction="row" sx={{ alignItems: "center", justifyContent: "space-between" }}>
|
||||||
<Typography variant="subtitle2" className="font-semibold">
|
<Typography variant="subtitle2" className="font-semibold">
|
||||||
Technician {index + 1}
|
{index + 1} - TECHNICIAN
|
||||||
</Typography>
|
</Typography>
|
||||||
<Tooltip title="Remove technician">
|
<Tooltip title="Remove technician">
|
||||||
<span>
|
<span>
|
||||||
|
|
@ -256,7 +273,8 @@ function TechnicianRow({
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<TextField
|
<TextField
|
||||||
{...field}
|
{...field}
|
||||||
label="Technician Name"
|
label="Technician name (optional)"
|
||||||
|
placeholder="Technician name"
|
||||||
error={Boolean(error?.contactName)}
|
error={Boolean(error?.contactName)}
|
||||||
helperText={error?.contactName?.message}
|
helperText={error?.contactName?.message}
|
||||||
fullWidth
|
fullWidth
|
||||||
|
|
@ -270,8 +288,8 @@ function TechnicianRow({
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<TextField
|
<TextField
|
||||||
{...field}
|
{...field}
|
||||||
label="Technician Phone"
|
label="Technician phone (optional)"
|
||||||
placeholder="(XXX) XXX-XXXX"
|
placeholder="(000) 000-0000"
|
||||||
inputMode="tel"
|
inputMode="tel"
|
||||||
value={field.value ?? ""}
|
value={field.value ?? ""}
|
||||||
onChange={(event) => field.onChange(formatNorthAmericanPhone(event.target.value))}
|
onChange={(event) => field.onChange(formatNorthAmericanPhone(event.target.value))}
|
||||||
|
|
@ -292,26 +310,29 @@ function TechnicianRow({
|
||||||
error={Boolean(error?.email)}
|
error={Boolean(error?.email)}
|
||||||
helperText={error?.email?.message}
|
helperText={error?.email?.message}
|
||||||
fullWidth
|
fullWidth
|
||||||
|
sx={{ display: "none" }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
<VendorTradeSpecialtiesField control={control} index={index} tradeOptions={tradeOptions} />
|
<VendorTradeSpecialtiesField control={control} index={index} tradeOptions={tradeOptions} />
|
||||||
<Controller
|
{showStatus && (
|
||||||
control={control}
|
<Controller
|
||||||
name={`technicians.${index}.isActive`}
|
control={control}
|
||||||
render={({ field }) => (
|
name={`technicians.${index}.isActive`}
|
||||||
<FormControlLabel
|
render={({ field }) => (
|
||||||
control={
|
<FormControlLabel
|
||||||
<Switch
|
control={
|
||||||
checked={field.value}
|
<Switch
|
||||||
onChange={(_event, checked) => field.onChange(checked)}
|
checked={field.value}
|
||||||
/>
|
onChange={(_event, checked) => field.onChange(checked)}
|
||||||
}
|
/>
|
||||||
label={field.value ? "Active" : "Inactive"}
|
}
|
||||||
/>
|
label={field.value ? "Active" : "Inactive"}
|
||||||
)}
|
/>
|
||||||
/>
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -320,10 +341,12 @@ function TechniciansFieldArray({
|
||||||
control,
|
control,
|
||||||
errors,
|
errors,
|
||||||
tradeOptions,
|
tradeOptions,
|
||||||
|
showStatus,
|
||||||
}: {
|
}: {
|
||||||
control: Control<VendorCompanyRosterFormValues>;
|
control: Control<VendorCompanyRosterFormValues>;
|
||||||
errors: FieldErrors<VendorCompanyRosterFormValues>;
|
errors: FieldErrors<VendorCompanyRosterFormValues>;
|
||||||
tradeOptions: string[];
|
tradeOptions: string[];
|
||||||
|
showStatus: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { fields, append, remove } = useFieldArray({ control, name: "technicians" });
|
const { fields, append, remove } = useFieldArray({ control, name: "technicians" });
|
||||||
|
|
||||||
|
|
@ -366,6 +389,7 @@ function TechniciansFieldArray({
|
||||||
onRemove={() => remove(index)}
|
onRemove={() => remove(index)}
|
||||||
canRemove
|
canRemove
|
||||||
tradeOptions={tradeOptions}
|
tradeOptions={tradeOptions}
|
||||||
|
showStatus={showStatus}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|
@ -375,12 +399,17 @@ function TechniciansFieldArray({
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VendorRosterFormFields(props: VendorRosterFormFieldsProps) {
|
export function VendorRosterFormFields(props: VendorRosterFormFieldsProps) {
|
||||||
const { control, errors, tradeOptions = [] } = props;
|
const { control, errors, tradeOptions = [], showTechnicianStatus = true } = props;
|
||||||
return (
|
return (
|
||||||
<Stack spacing={3}>
|
<Stack spacing={3}>
|
||||||
<CompanyFields {...props} />
|
<CompanyFields {...props} />
|
||||||
<Divider />
|
<Divider />
|
||||||
<TechniciansFieldArray control={control} errors={errors} tradeOptions={tradeOptions} />
|
<TechniciansFieldArray
|
||||||
|
control={control}
|
||||||
|
errors={errors}
|
||||||
|
tradeOptions={tradeOptions}
|
||||||
|
showStatus={showTechnicianStatus}
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,9 @@
|
||||||
import FilterListIcon from "@mui/icons-material/FilterList";
|
import { Button, Chip, CircularProgress, Stack, TablePagination } from "@mui/material";
|
||||||
import {
|
import { Plus, SlidersHorizontal } from "lucide-react";
|
||||||
Button,
|
import { PageHeader } from "@/components/ui/page-header";
|
||||||
Chip,
|
import { SearchInput } from "@/components/ui/search-input";
|
||||||
CircularProgress,
|
import { Text } from "@/components/ui/text";
|
||||||
Stack,
|
import { ToolbarButton } from "@/components/ui/toolbar-button";
|
||||||
TablePagination,
|
|
||||||
TextField,
|
|
||||||
Typography,
|
|
||||||
} from "@mui/material";
|
|
||||||
|
|
||||||
interface VendorsListToolbarProps {
|
interface VendorsListToolbarProps {
|
||||||
globalCount: number;
|
globalCount: number;
|
||||||
|
|
@ -33,52 +29,46 @@ export function VendorsListToolbar({
|
||||||
onAddVendor,
|
onAddVendor,
|
||||||
}: VendorsListToolbarProps) {
|
}: VendorsListToolbarProps) {
|
||||||
return (
|
return (
|
||||||
<Stack
|
<PageHeader
|
||||||
direction={{ xs: "column", md: "row" }}
|
surface
|
||||||
spacing={2}
|
className="-mx-4 -mt-4"
|
||||||
sx={{
|
title="Vendors / Technicians"
|
||||||
alignItems: { xs: "stretch", md: "center" },
|
subtitle={`${globalCount} technicians · ${activeCount} active`}
|
||||||
justifyContent: "space-between",
|
actions={
|
||||||
}}
|
<>
|
||||||
>
|
<SearchInput
|
||||||
<Stack direction="row" spacing={2} sx={{ alignItems: "baseline" }}>
|
value={search}
|
||||||
<Typography variant="h5" component="h1">
|
onChange={onSearchChange}
|
||||||
Vendors / Technicians
|
placeholder="Search name, company, trade…"
|
||||||
</Typography>
|
aria-label="Search vendors"
|
||||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
fullWidth
|
||||||
{globalCount} vendor entries · {activeCount} active
|
className="w-full md:w-56"
|
||||||
</Typography>
|
inputClassName="h-8 py-0 text-[13px]"
|
||||||
{isFetching && !isLoading && <CircularProgress size={16} />}
|
/>
|
||||||
</Stack>
|
<ToolbarButton
|
||||||
|
variant="outline"
|
||||||
<Stack
|
icon={<SlidersHorizontal size={16} />}
|
||||||
direction={{ xs: "column", sm: "row" }}
|
onClick={onOpenFilters}
|
||||||
spacing={2}
|
aria-label="Open vendor filters"
|
||||||
sx={{ alignItems: { xs: "stretch", sm: "center" } }}
|
badge={appliedFilterCount}
|
||||||
>
|
className="min-w-[88.609375px] flex-1 text-[13px] md:w-[88.609375px] md:flex-none"
|
||||||
<TextField
|
>
|
||||||
size="small"
|
Filters
|
||||||
value={search}
|
</ToolbarButton>
|
||||||
onChange={(e) => onSearchChange(e.target.value)}
|
<ToolbarButton
|
||||||
placeholder="Search name, company, trade..."
|
variant="primary"
|
||||||
className="min-w-[260px]"
|
icon={<Plus size={16} />}
|
||||||
/>
|
onClick={onAddVendor}
|
||||||
<Button
|
className="min-w-[115.546875px] flex-1 border-0 px-3 text-[13px] md:w-[115.546875px] md:flex-none"
|
||||||
variant="outlined"
|
>
|
||||||
onClick={onOpenFilters}
|
Add Vendor
|
||||||
startIcon={<FilterListIcon />}
|
</ToolbarButton>
|
||||||
aria-label="Open vendor filters"
|
{isFetching && !isLoading && (
|
||||||
>
|
<CircularProgress size={14} aria-label="Refreshing vendors" role="status" />
|
||||||
Filters
|
|
||||||
{appliedFilterCount > 0 && (
|
|
||||||
<Chip size="small" color="primary" label={appliedFilterCount} sx={{ ml: 1 }} />
|
|
||||||
)}
|
)}
|
||||||
</Button>
|
</>
|
||||||
<Button variant="contained" onClick={onAddVendor}>
|
}
|
||||||
+ Add Vendor
|
/>
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -90,16 +80,21 @@ interface AppliedChipEntry {
|
||||||
|
|
||||||
interface VendorAppliedFiltersBarProps {
|
interface VendorAppliedFiltersBarProps {
|
||||||
chips: AppliedChipEntry[];
|
chips: AppliedChipEntry[];
|
||||||
|
resultCount: number;
|
||||||
onClearAll: () => void;
|
onClearAll: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VendorAppliedFiltersBar({ chips, onClearAll }: VendorAppliedFiltersBarProps) {
|
export function VendorAppliedFiltersBar({
|
||||||
|
chips,
|
||||||
|
resultCount,
|
||||||
|
onClearAll,
|
||||||
|
}: VendorAppliedFiltersBarProps) {
|
||||||
if (chips.length === 0) return null;
|
if (chips.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<Stack direction="row" spacing={1} className="flex flex-wrap items-center">
|
<Stack direction="row" spacing={1} className="flex flex-wrap items-center">
|
||||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
<Text variant="caption" tone="muted">
|
||||||
Applied:
|
{resultCount} {resultCount === 1 ? "technician" : "technicians"} · filtered by
|
||||||
</Typography>
|
</Text>
|
||||||
{chips.map((chip) => (
|
{chips.map((chip) => (
|
||||||
<Chip
|
<Chip
|
||||||
key={chip.key}
|
key={chip.key}
|
||||||
|
|
@ -146,23 +141,31 @@ export function VendorsListFooter({
|
||||||
sx={{
|
sx={{
|
||||||
alignItems: { xs: "stretch", sm: "center" },
|
alignItems: { xs: "stretch", sm: "center" },
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
|
minHeight: 33.5,
|
||||||
|
mx: -2,
|
||||||
|
px: 2,
|
||||||
|
backgroundColor: "var(--color-bg-surface)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
<Text variant="caption" as="span" sx={{ color: "#6b7a96", fontSize: 11, lineHeight: 1.5 }}>
|
||||||
{totalCount} {isActive ? "active" : "inactive"}{" "}
|
{totalCount} {isActive ? "active" : "inactive"}{" "}
|
||||||
{totalCount === 1 ? "vendor entry" : "vendor entries"}
|
{totalCount === 1 ? "technician" : "technicians"}
|
||||||
{isFiltered && " · filtered"}
|
{isFiltered && " · filtered"}
|
||||||
{` · ${activeCount} active · ${inactiveCount} inactive`}
|
</Text>
|
||||||
</Typography>
|
<Text variant="caption" as="span" sx={{ color: "#6b7a96", fontSize: 11, lineHeight: 1.5 }}>
|
||||||
<TablePagination
|
{activeCount} active · {inactiveCount} inactive
|
||||||
component="div"
|
</Text>
|
||||||
count={totalCount}
|
{totalCount > pageSize && (
|
||||||
page={page}
|
<TablePagination
|
||||||
onPageChange={(_e, nextPage) => onPageChange(nextPage)}
|
component="div"
|
||||||
rowsPerPage={pageSize}
|
count={totalCount}
|
||||||
onRowsPerPageChange={(e) => onPageSizeChange(Number(e.target.value))}
|
page={page}
|
||||||
rowsPerPageOptions={[12, 24, 48]}
|
onPageChange={(_e, nextPage) => onPageChange(nextPage)}
|
||||||
/>
|
rowsPerPage={pageSize}
|
||||||
|
onRowsPerPageChange={(e) => onPageSizeChange(Number(e.target.value))}
|
||||||
|
rowsPerPageOptions={[24, 48, 96]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
import { type KeyboardEvent, type MouseEvent } from "react";
|
import { type KeyboardEvent, type MouseEvent } from "react";
|
||||||
import { useNavigate } from "react-router";
|
import SearchOffOutlinedIcon from "@mui/icons-material/SearchOffOutlined";
|
||||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
|
|
||||||
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
||||||
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
|
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Chip,
|
Box,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
IconButton,
|
IconButton,
|
||||||
Link,
|
Link,
|
||||||
|
|
@ -18,8 +17,8 @@ import {
|
||||||
TableHead,
|
TableHead,
|
||||||
TableRow,
|
TableRow,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Typography,
|
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
import type { VendorListItem } from "@/domain/vendors/types/vendor";
|
import type { VendorListItem } from "@/domain/vendors/types/vendor";
|
||||||
|
|
||||||
const TABLE_COLUMNS = [
|
const TABLE_COLUMNS = [
|
||||||
|
|
@ -30,9 +29,6 @@ const TABLE_COLUMNS = [
|
||||||
"Phone",
|
"Phone",
|
||||||
"Email",
|
"Email",
|
||||||
"Total Jobs",
|
"Total Jobs",
|
||||||
"Status",
|
|
||||||
"View",
|
|
||||||
"Edit",
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
function getInitials(name: string): string {
|
function getInitials(name: string): string {
|
||||||
|
|
@ -54,19 +50,34 @@ function stopPropagation(event: MouseEvent): void {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function VendorStatus({ isActive }: { isActive: boolean }) {
|
||||||
|
return (
|
||||||
|
<Stack direction="row" spacing={1} sx={{ alignItems: "center", pl: 1 }}>
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
sx={{
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: "9999px",
|
||||||
|
backgroundColor: isActive ? "success.main" : "text.secondary",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text variant="description">{isActive ? "Active" : "Inactive"}</Text>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface VendorTableRowProps {
|
interface VendorTableRowProps {
|
||||||
row: VendorListItem;
|
row: VendorListItem;
|
||||||
onOpenDetail: (row: VendorListItem) => void;
|
onOpenDetail: (row: VendorListItem) => void;
|
||||||
onOpenDeactivation: (row: VendorListItem) => void;
|
onOpenEdit: (row: VendorListItem) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function VendorTableRow({ row, onOpenDetail, onOpenDeactivation }: VendorTableRowProps) {
|
function VendorTableRow({ row, onOpenDetail, onOpenEdit }: VendorTableRowProps) {
|
||||||
const navigate = useNavigate();
|
|
||||||
const trades = splitTrades(row.tradeSpecialties);
|
const trades = splitTrades(row.tradeSpecialties);
|
||||||
const primaryTrade = trades[0] ?? "—";
|
const primaryTrade = trades[0] ?? "—";
|
||||||
const extraTrades = trades.length > 1 ? trades.length - 1 : 0;
|
const extraTrades = trades.length > 1 ? trades.length - 1 : 0;
|
||||||
const locationText = [row.city, row.state].filter(Boolean).join(", ") || "—";
|
const locationText = [row.city, row.state].filter(Boolean).join(", ") || "—";
|
||||||
const hasVendorId = row.id != null;
|
|
||||||
const rowKey = String(row.id ?? `company-${row.companyId ?? ""}`);
|
const rowKey = String(row.id ?? `company-${row.companyId ?? ""}`);
|
||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
|
@ -88,37 +99,62 @@ function VendorTableRow({ row, onOpenDetail, onOpenDeactivation }: VendorTableRo
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
>
|
>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Typography className="font-semibold">{row.companyName || "—"}</Typography>
|
<Text variant="description" sx={{ color: "var(--color-foreground)" }}>
|
||||||
|
{row.companyName || "—"}
|
||||||
|
</Text>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Stack direction="row" spacing={1.5} sx={{ alignItems: "center" }}>
|
<Stack direction="row" spacing={1.25} sx={{ alignItems: "center" }}>
|
||||||
<Avatar
|
<Avatar
|
||||||
sx={{
|
sx={{
|
||||||
width: 28,
|
width: 28,
|
||||||
height: 28,
|
height: 28,
|
||||||
fontSize: 12,
|
backgroundColor: "var(--color-bg-muted)",
|
||||||
backgroundColor: "var(--accent, #1976d2)",
|
color: "var(--color-primary)",
|
||||||
|
fontSize: 10.5,
|
||||||
|
fontWeight: 600,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{getInitials(row.contactName)}
|
{getInitials(row.contactName)}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<Typography variant="body2">{row.contactName || "—"}</Typography>
|
<Text variant="description" sx={{ color: "var(--color-foreground)" }}>
|
||||||
|
{row.contactName || "—"}
|
||||||
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{locationText}</TableCell>
|
<TableCell sx={{ color: "var(--color-muted-foreground)" }}>{locationText}</TableCell>
|
||||||
<TableCell>
|
<TableCell sx={{ color: "var(--color-muted-foreground)" }}>
|
||||||
{primaryTrade === "—" ? (
|
{primaryTrade === "—" ? (
|
||||||
"—"
|
"—"
|
||||||
) : (
|
) : (
|
||||||
<Stack direction="row" spacing={0.5} sx={{ alignItems: "center" }}>
|
<Stack direction="row" spacing={0.5} sx={{ alignItems: "center" }}>
|
||||||
<span>{primaryTrade}</span>
|
<span>{primaryTrade}</span>
|
||||||
{extraTrades > 0 && <Chip size="small" label={`+${extraTrades}`} />}
|
{extraTrades > 0 && (
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
sx={{
|
||||||
|
borderRadius: "9999px",
|
||||||
|
backgroundColor: "var(--color-bg-muted)",
|
||||||
|
px: 0.75,
|
||||||
|
py: 0.25,
|
||||||
|
fontSize: 10.5,
|
||||||
|
lineHeight: 1.2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+{extraTrades}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell sx={{ color: "var(--color-muted-foreground)" }}>
|
||||||
{row.phone ? (
|
{row.phone ? (
|
||||||
<Link href={`tel:${row.phone}`} underline="hover" onClick={stopPropagation}>
|
<Link
|
||||||
|
href={`tel:${row.phone}`}
|
||||||
|
underline="hover"
|
||||||
|
color="inherit"
|
||||||
|
onClick={stopPropagation}
|
||||||
|
>
|
||||||
{row.phone}
|
{row.phone}
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -127,71 +163,53 @@ function VendorTableRow({ row, onOpenDetail, onOpenDeactivation }: VendorTableRo
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{row.email ? (
|
{row.email ? (
|
||||||
<Link href={`mailto:${row.email}`} underline="hover" onClick={stopPropagation}>
|
<Link
|
||||||
|
href={`mailto:${row.email}`}
|
||||||
|
underline="hover"
|
||||||
|
sx={{ color: "#6b7a96" }}
|
||||||
|
onClick={stopPropagation}
|
||||||
|
>
|
||||||
{row.email}
|
{row.email}
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
"—"
|
"—"
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{row.totalJobs ?? 0}</TableCell>
|
<TableCell align="center">{row.totalJobs ?? 0}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Chip
|
<VendorStatus isActive={row.isActive} />
|
||||||
size="small"
|
|
||||||
label={row.isActive ? "Active" : "Inactive"}
|
|
||||||
color={row.isActive ? "success" : "default"}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell align="center">
|
<TableCell align="right">
|
||||||
<Tooltip title="View details">
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
aria-label={`View vendor ${row.companyName}`}
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
onOpenDetail(row);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<VisibilityOutlinedIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell align="center">
|
|
||||||
<Stack
|
<Stack
|
||||||
direction="row"
|
direction="row"
|
||||||
spacing={0.5}
|
spacing={0.5}
|
||||||
sx={{ alignItems: "center", justifyContent: "center" }}
|
className="vendor-row-actions"
|
||||||
|
sx={{ alignItems: "center", justifyContent: "flex-end" }}
|
||||||
>
|
>
|
||||||
|
<Tooltip title="View details">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label={`View vendor ${row.companyName}`}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onOpenDetail(row);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<VisibilityOutlinedIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
<Tooltip title="Edit">
|
<Tooltip title="Edit">
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
aria-label={`Edit vendor ${row.companyName}`}
|
aria-label={`Edit vendor ${row.companyName}`}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
navigate(
|
onOpenEdit(row);
|
||||||
row.id != null
|
|
||||||
? `/vendors/edit/${row.id}`
|
|
||||||
: `/vendors/company/${row.companyId ?? ""}`,
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<EditOutlinedIcon fontSize="small" />
|
<EditOutlinedIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{hasVendorId && row.isActive && (
|
|
||||||
<Tooltip title="Deactivate">
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
aria-label="Deactivate vendor"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
onOpenDeactivation(row);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DeleteOutlineIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|
@ -200,50 +218,118 @@ function VendorTableRow({ row, onOpenDetail, onOpenDeactivation }: VendorTableRo
|
||||||
|
|
||||||
interface VendorsTableProps {
|
interface VendorsTableProps {
|
||||||
rows: VendorListItem[];
|
rows: VendorListItem[];
|
||||||
|
isActive: boolean;
|
||||||
|
isFiltered: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
showError: boolean;
|
showError: boolean;
|
||||||
onOpenDetail: (row: VendorListItem) => void;
|
onOpenDetail: (row: VendorListItem) => void;
|
||||||
onOpenDeactivation: (row: VendorListItem) => void;
|
onOpenEdit: (row: VendorListItem) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VendorsTable({
|
export function VendorsTable({
|
||||||
rows,
|
rows,
|
||||||
|
isActive,
|
||||||
|
isFiltered,
|
||||||
isLoading,
|
isLoading,
|
||||||
showError,
|
showError,
|
||||||
onOpenDetail,
|
onOpenDetail,
|
||||||
onOpenDeactivation,
|
onOpenEdit,
|
||||||
}: VendorsTableProps) {
|
}: VendorsTableProps) {
|
||||||
const showEmptyRow = !isLoading && !showError && rows.length === 0;
|
const showEmptyRow = !isLoading && !showError && rows.length === 0;
|
||||||
|
const columnCount = TABLE_COLUMNS.length + 2;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableContainer component={Paper} variant="outlined" sx={{ overflowX: "auto" }}>
|
<TableContainer
|
||||||
<Table size="small" sx={{ minWidth: 960 }}>
|
component={Paper}
|
||||||
|
variant="outlined"
|
||||||
|
sx={{
|
||||||
|
mt: 2,
|
||||||
|
mb: 2,
|
||||||
|
flex: "1 1 auto",
|
||||||
|
overflow: "auto",
|
||||||
|
borderRadius: 1.5,
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
stickyHeader
|
||||||
|
sx={{
|
||||||
|
minWidth: 1550,
|
||||||
|
tableLayout: "fixed",
|
||||||
|
"& .MuiTableCell-head": {
|
||||||
|
backgroundColor: "var(--color-bg-muted)",
|
||||||
|
color: "var(--color-foreground)",
|
||||||
|
fontSize: 10.5,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: "0.06em",
|
||||||
|
lineHeight: "24px",
|
||||||
|
px: 1.75,
|
||||||
|
py: 1.375,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
},
|
||||||
|
"& .MuiTableCell-body": {
|
||||||
|
fontSize: 13,
|
||||||
|
px: 1.5,
|
||||||
|
py: 1,
|
||||||
|
height: 52,
|
||||||
|
lineHeight: 1.43,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
},
|
||||||
|
"& .vendor-row-actions": {
|
||||||
|
opacity: { xs: 1, md: 0 },
|
||||||
|
transition: "opacity 120ms ease",
|
||||||
|
},
|
||||||
|
"& .MuiTableRow-root:hover .vendor-row-actions, & .MuiTableRow-root:focus-within .vendor-row-actions":
|
||||||
|
{
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<colgroup>
|
||||||
|
<col style={{ width: 196.34375 }} />
|
||||||
|
<col style={{ width: 207.25 }} />
|
||||||
|
<col style={{ width: 152.703125 }} />
|
||||||
|
<col style={{ width: 218.15625 }} />
|
||||||
|
<col style={{ width: 152.703125 }} />
|
||||||
|
<col style={{ width: 251.90625 }} />
|
||||||
|
<col style={{ width: 109.078125 }} />
|
||||||
|
<col style={{ width: 119.984375 }} />
|
||||||
|
<col style={{ width: 141.875 }} />
|
||||||
|
</colgroup>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
{TABLE_COLUMNS.map((column) => (
|
{TABLE_COLUMNS.map((column) => (
|
||||||
<TableCell
|
<TableCell key={column} align={column === "Total Jobs" ? "center" : "left"}>
|
||||||
key={column}
|
|
||||||
align={column === "View" || column === "Edit" ? "center" : "left"}
|
|
||||||
className="font-semibold"
|
|
||||||
>
|
|
||||||
{column}
|
{column}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
))}
|
))}
|
||||||
|
<TableCell>Status</TableCell>
|
||||||
|
<TableCell align="right" aria-label="Vendor actions" />
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={TABLE_COLUMNS.length} align="center" className="py-10">
|
<TableCell colSpan={columnCount} align="center" className="py-10">
|
||||||
<CircularProgress size={28} />
|
<CircularProgress size={28} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : showEmptyRow ? (
|
) : showEmptyRow ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={TABLE_COLUMNS.length} align="center" className="py-10">
|
<TableCell colSpan={columnCount} align="center" className="py-10">
|
||||||
<Typography sx={{ color: "text.secondary" }}>
|
<Stack spacing={1} sx={{ alignItems: "center" }}>
|
||||||
No vendors match the current filters.
|
<SearchOffOutlinedIcon sx={{ color: "text.secondary", fontSize: 32 }} />
|
||||||
</Typography>
|
<Text variant="description" className="font-semibold">
|
||||||
|
{`No ${isActive ? "active" : "inactive"} technicians found`}
|
||||||
|
</Text>
|
||||||
|
<Text variant="caption" tone="muted">
|
||||||
|
{isFiltered
|
||||||
|
? "Try a different search, or add a new vendor."
|
||||||
|
: "Add a vendor to get started."}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -252,7 +338,7 @@ export function VendorsTable({
|
||||||
key={String(row.id ?? `company-${row.companyId ?? ""}`)}
|
key={String(row.id ?? `company-${row.companyId ?? ""}`)}
|
||||||
row={row}
|
row={row}
|
||||||
onOpenDetail={onOpenDetail}
|
onOpenDetail={onOpenDetail}
|
||||||
onOpenDeactivation={onOpenDeactivation}
|
onOpenEdit={onOpenEdit}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
125
src/app/(protected)/vendors/index.tsx
vendored
|
|
@ -23,16 +23,91 @@ interface AppliedChip {
|
||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function statusTabLabel(label: string, count: number) {
|
||||||
|
return (
|
||||||
|
<Box className="flex items-center gap-2">
|
||||||
|
<Box component="span">{label}</Box>
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
className="min-w-5 rounded-full px-1.5 py-0.5 text-[10px] font-semibold leading-none"
|
||||||
|
sx={{ backgroundColor: "action.hover", color: "text.secondary" }}
|
||||||
|
>
|
||||||
|
{count}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VendorStatusTabs({
|
||||||
|
isActive,
|
||||||
|
activeCount,
|
||||||
|
inactiveCount,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
isActive: boolean;
|
||||||
|
activeCount: number;
|
||||||
|
inactiveCount: number;
|
||||||
|
onChange: (isActive: boolean) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Tabs
|
||||||
|
value={isActive ? "active" : "inactive"}
|
||||||
|
onChange={(_event, value: "active" | "inactive") => onChange(value === "active")}
|
||||||
|
aria-label="Vendor status"
|
||||||
|
sx={{
|
||||||
|
minHeight: 36,
|
||||||
|
flexShrink: 0,
|
||||||
|
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
|
||||||
|
backgroundColor: "var(--color-bg-surface)",
|
||||||
|
mx: -2,
|
||||||
|
px: 1.5,
|
||||||
|
"& .MuiTab-root": {
|
||||||
|
minHeight: 36,
|
||||||
|
px: 2,
|
||||||
|
py: 0,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
textTransform: "none",
|
||||||
|
},
|
||||||
|
"& .MuiTab-root:first-of-type": { width: 102.84375 },
|
||||||
|
"& .Mui-selected": { fontWeight: 600 },
|
||||||
|
"& .MuiTabs-indicator": { height: 2 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tab
|
||||||
|
value="active"
|
||||||
|
label={statusTabLabel("Active", activeCount)}
|
||||||
|
aria-label={`Active (${activeCount})`}
|
||||||
|
/>
|
||||||
|
<Tab
|
||||||
|
value="inactive"
|
||||||
|
label={statusTabLabel("Inactive", inactiveCount)}
|
||||||
|
aria-label={`Inactive (${inactiveCount})`}
|
||||||
|
/>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function describeChips(
|
function describeChips(
|
||||||
filters: VendorFilters,
|
filters: VendorFilters,
|
||||||
jobBucketLabels: Map<string, string>,
|
jobBucketLabels: Map<string, string>,
|
||||||
): AppliedChip[] {
|
): AppliedChip[] {
|
||||||
const chips: AppliedChip[] = [];
|
const chips: AppliedChip[] = [];
|
||||||
filters.companies.forEach((value) => chips.push({ category: "companies", value, label: value }));
|
filters.companies.forEach((value) =>
|
||||||
filters.trades.forEach((value) => chips.push({ category: "trades", value, label: value }));
|
chips.push({ category: "companies", value, label: `Company: ${value}` }),
|
||||||
filters.locations.forEach((value) => chips.push({ category: "locations", value, label: value }));
|
);
|
||||||
|
filters.trades.forEach((value) =>
|
||||||
|
chips.push({ category: "trades", value, label: `Trade: ${value}` }),
|
||||||
|
);
|
||||||
|
filters.locations.forEach((value) =>
|
||||||
|
chips.push({ category: "locations", value, label: `Location: ${value}` }),
|
||||||
|
);
|
||||||
filters.jobBuckets.forEach((value) =>
|
filters.jobBuckets.forEach((value) =>
|
||||||
chips.push({ category: "jobBuckets", value, label: jobBucketLabels.get(value) ?? value }),
|
chips.push({
|
||||||
|
category: "jobBuckets",
|
||||||
|
value,
|
||||||
|
label: `Total Jobs: ${jobBucketLabels.get(value) ?? value}`,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
return chips;
|
return chips;
|
||||||
}
|
}
|
||||||
|
|
@ -42,7 +117,12 @@ export default function VendorsListPage() {
|
||||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [detailTarget, setDetailTarget] = useState<VendorListItem | null>(null);
|
const [detailTarget, setDetailTarget] = useState<VendorListItem | null>(null);
|
||||||
const deactivation = useVendorDeactivation();
|
const [detailStartsInEdit, setDetailStartsInEdit] = useState(false);
|
||||||
|
const closeDetail = () => {
|
||||||
|
setDetailTarget(null);
|
||||||
|
setDetailStartsInEdit(false);
|
||||||
|
};
|
||||||
|
const deactivation = useVendorDeactivation(closeDetail);
|
||||||
|
|
||||||
const { data: facets } = useVendorFacets(filters.isActive);
|
const { data: facets } = useVendorFacets(filters.isActive);
|
||||||
const { data, isLoading, isFetching, error } = useVendorsList(filters.listParams);
|
const { data, isLoading, isFetching, error } = useVendorsList(filters.listParams);
|
||||||
|
|
@ -61,7 +141,7 @@ export default function VendorsListPage() {
|
||||||
const appliedChips = describeChips(filters.appliedFilters, jobBucketLabels);
|
const appliedChips = describeChips(filters.appliedFilters, jobBucketLabels);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className="flex flex-col gap-4 p-4">
|
<Box className="flex h-full flex-col overflow-hidden px-4 pt-4 pb-0">
|
||||||
<VendorsListToolbar
|
<VendorsListToolbar
|
||||||
globalCount={activeCount + inactiveCount}
|
globalCount={activeCount + inactiveCount}
|
||||||
activeCount={activeCount}
|
activeCount={activeCount}
|
||||||
|
|
@ -74,18 +154,15 @@ export default function VendorsListPage() {
|
||||||
onAddVendor={() => setCreateOpen(true)}
|
onAddVendor={() => setCreateOpen(true)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Tabs
|
<VendorStatusTabs
|
||||||
value={filters.isActive ? "active" : "inactive"}
|
isActive={filters.isActive}
|
||||||
onChange={(_event, value: "active" | "inactive") =>
|
activeCount={activeCount}
|
||||||
filters.setIsActiveWithReset(value === "active")
|
inactiveCount={inactiveCount}
|
||||||
}
|
onChange={filters.setIsActiveWithReset}
|
||||||
aria-label="Vendor status"
|
/>
|
||||||
>
|
|
||||||
<Tab value="active" label={`Active (${activeCount})`} />
|
|
||||||
<Tab value="inactive" label={`Inactive (${inactiveCount})`} />
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
<VendorAppliedFiltersBar
|
<VendorAppliedFiltersBar
|
||||||
|
resultCount={totalCount}
|
||||||
chips={appliedChips.map((chip) => ({
|
chips={appliedChips.map((chip) => ({
|
||||||
key: `${chip.category}-${chip.value}`,
|
key: `${chip.category}-${chip.value}`,
|
||||||
label: chip.label,
|
label: chip.label,
|
||||||
|
|
@ -102,10 +179,18 @@ export default function VendorsListPage() {
|
||||||
|
|
||||||
<VendorsTable
|
<VendorsTable
|
||||||
rows={rows}
|
rows={rows}
|
||||||
|
isActive={filters.isActive}
|
||||||
|
isFiltered={appliedChips.length > 0 || Boolean(filters.debouncedSearch)}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
showError={Boolean(error)}
|
showError={Boolean(error)}
|
||||||
onOpenDetail={setDetailTarget}
|
onOpenDetail={(row) => {
|
||||||
onOpenDeactivation={deactivation.open}
|
setDetailStartsInEdit(false);
|
||||||
|
setDetailTarget(row);
|
||||||
|
}}
|
||||||
|
onOpenEdit={(row) => {
|
||||||
|
setDetailStartsInEdit(true);
|
||||||
|
setDetailTarget(row);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<VendorsListFooter
|
<VendorsListFooter
|
||||||
|
|
@ -133,7 +218,9 @@ export default function VendorsListPage() {
|
||||||
<VendorDetailDrawer
|
<VendorDetailDrawer
|
||||||
open={Boolean(detailTarget)}
|
open={Boolean(detailTarget)}
|
||||||
vendor={detailTarget}
|
vendor={detailTarget}
|
||||||
onClose={() => setDetailTarget(null)}
|
startInEdit={detailStartsInEdit}
|
||||||
|
onRequestDeactivation={deactivation.open}
|
||||||
|
onClose={closeDetail}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<VendorDeactivationDialog
|
<VendorDeactivationDialog
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,21 @@
|
||||||
import { useState } from "react";
|
import { useParams } from "react-router";
|
||||||
import { useNavigate, useParams } from "react-router";
|
|
||||||
import { Alert, Box, CircularProgress } from "@mui/material";
|
import { Alert, Box, CircularProgress } from "@mui/material";
|
||||||
import { DispatchCreateModal } from "./_components/dispatch-create-modal";
|
import { DispatchCreateModal } from "./_components/dispatch-create-modal";
|
||||||
import { DispatchDetailModal } from "./_components/dispatch-detail-modal";
|
import { DispatchDetailModal } from "./_components/dispatch-detail-modal";
|
||||||
import { VendorEditorDialog } from "./_components/vendor-editor-dialog";
|
import { WorkOrderActivityPanel } from "./_components/view/work-order-activity-panel";
|
||||||
import { useVendorEditor } from "./_components/use-vendor-editor";
|
import { WorkOrderDispatchesAccordion } from "./_components/view/work-order-dispatches-accordion";
|
||||||
import { WorkOrderActivity } from "./_components/work-order-activity";
|
import { WorkOrderVendorEditorDialog } from "./_components/view/work-order-vendor-editor-dialog";
|
||||||
import {
|
import { WorkOrderViewAccordions } from "./_components/view/work-order-view-accordions";
|
||||||
DispatchAccordion,
|
import { WorkOrderViewDetails } from "./_components/view/work-order-view-details";
|
||||||
WorkOrderDetailsSection,
|
import { WorkOrderViewHeader } from "./_components/view/work-order-view-header";
|
||||||
WorkOrderHeader,
|
import { useWorkOrderViewPage } from "./_hooks/use-work-order-view-page";
|
||||||
WorkOrderSummaryAccordions,
|
|
||||||
} from "./_components/work-order-view-sections";
|
|
||||||
import { useWorkOrderDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
|
|
||||||
import {
|
|
||||||
useChangeWorkOrderAssignment,
|
|
||||||
useChangeWorkOrderStatus,
|
|
||||||
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
|
||||||
import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
|
|
||||||
|
|
||||||
export default function WorkOrderViewPage() {
|
export default function WorkOrderViewPage() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const navigate = useNavigate();
|
|
||||||
const workOrderId = id ?? "";
|
const workOrderId = id ?? "";
|
||||||
|
const view = useWorkOrderViewPage(workOrderId);
|
||||||
|
|
||||||
const { data: wo, isLoading, error, refetch } = useWorkOrderDetail(workOrderId);
|
if (view.isLoading) {
|
||||||
const { data: users = [] } = useWorkOrderUsers();
|
|
||||||
const statusMutation = useChangeWorkOrderStatus(workOrderId);
|
|
||||||
const assignmentMutation = useChangeWorkOrderAssignment(workOrderId);
|
|
||||||
const vendorEditor = useVendorEditor({ workOrderId, onSaved: () => void refetch() });
|
|
||||||
|
|
||||||
const [dispatchOpen, setDispatchOpen] = useState(false);
|
|
||||||
const [dispatchDetailId, setDispatchDetailId] = useState<string | number | null>(null);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
return (
|
||||||
<Box className="flex justify-center p-12">
|
<Box className="flex justify-center p-12">
|
||||||
<CircularProgress />
|
<CircularProgress />
|
||||||
|
|
@ -41,11 +23,11 @@ export default function WorkOrderViewPage() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error || !wo) {
|
if (view.error || !view.wo) {
|
||||||
return (
|
return (
|
||||||
<Box className="p-4">
|
<Box className="p-4">
|
||||||
<Alert severity="error">
|
<Alert severity="error">
|
||||||
{error instanceof Error ? error.message : "Work order not found."}
|
{view.error instanceof Error ? view.error.message : "Work order not found."}
|
||||||
</Alert>
|
</Alert>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|
@ -53,56 +35,67 @@ export default function WorkOrderViewPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className="flex flex-col gap-4 p-4">
|
<Box className="flex flex-col gap-4 p-4">
|
||||||
<WorkOrderHeader
|
<WorkOrderViewHeader
|
||||||
wo={wo}
|
wo={view.wo}
|
||||||
onSelectStatus={(status) => statusMutation.mutate(status)}
|
|
||||||
onDispatch={() => setDispatchOpen(true)}
|
|
||||||
onEdit={() => navigate(`/workorders/edit/${id}`)}
|
|
||||||
onBack={() => navigate("/workorders")}
|
|
||||||
/>
|
|
||||||
<WorkOrderDetailsSection
|
|
||||||
wo={wo}
|
|
||||||
users={users}
|
|
||||||
onAssign={(payload) => assignmentMutation.mutate(payload)}
|
|
||||||
/>
|
|
||||||
<WorkOrderSummaryAccordions wo={wo} />
|
|
||||||
<DispatchAccordion
|
|
||||||
wo={wo}
|
|
||||||
onEditVendor={(event, vendorId, vendorName) =>
|
|
||||||
void vendorEditor.open(event, vendorId, vendorName)
|
|
||||||
}
|
|
||||||
onViewDispatch={setDispatchDetailId}
|
|
||||||
/>
|
|
||||||
<WorkOrderActivity
|
|
||||||
workOrderId={workOrderId}
|
workOrderId={workOrderId}
|
||||||
comments={wo.comments ?? []}
|
statusAnchor={view.statusAnchor}
|
||||||
auditLog={wo.auditLog ?? []}
|
onStatusAnchorChange={view.setStatusAnchor}
|
||||||
|
onStatusChange={(status) => view.statusMutation.mutate(status)}
|
||||||
|
onOpenDispatch={() => view.setDispatchOpen(true)}
|
||||||
/>
|
/>
|
||||||
{dispatchOpen && (
|
|
||||||
|
<WorkOrderViewDetails
|
||||||
|
wo={view.wo}
|
||||||
|
users={view.users}
|
||||||
|
assignAnchor={view.assignAnchor}
|
||||||
|
onAssignAnchorChange={view.setAssignAnchor}
|
||||||
|
onAssign={(userId, userName) => view.assignmentMutation.mutate({ userId, userName })}
|
||||||
|
onUnassign={() => view.assignmentMutation.mutate({ userId: "", userName: "" })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<WorkOrderViewAccordions wo={view.wo} />
|
||||||
|
|
||||||
|
<WorkOrderDispatchesAccordion
|
||||||
|
wo={view.wo}
|
||||||
|
onOpenDispatchDetail={view.setDispatchDetailId}
|
||||||
|
onOpenVendorEditor={view.openVendorEditor}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<WorkOrderActivityPanel
|
||||||
|
customerComments={view.customerComments}
|
||||||
|
internalComments={view.internalComments}
|
||||||
|
auditLog={view.auditLog}
|
||||||
|
mentionUsers={view.mentionUsers}
|
||||||
|
isSubmitting={view.commentMutation.isPending}
|
||||||
|
onSubmitComment={view.submitComment}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{view.dispatchOpen && (
|
||||||
<DispatchCreateModal
|
<DispatchCreateModal
|
||||||
workOrder={wo}
|
workOrder={view.wo}
|
||||||
onClose={() => setDispatchOpen(false)}
|
onClose={() => view.setDispatchOpen(false)}
|
||||||
onDispatched={() => void refetch()}
|
onDispatched={() => void view.refetch()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{dispatchDetailId != null && (
|
{view.dispatchDetailId != null && (
|
||||||
<DispatchDetailModal
|
<DispatchDetailModal
|
||||||
dispatchId={dispatchDetailId}
|
dispatchId={view.dispatchDetailId}
|
||||||
workOrderId={workOrderId}
|
workOrderId={workOrderId}
|
||||||
onClose={() => setDispatchDetailId(null)}
|
onClose={() => view.setDispatchDetailId(null)}
|
||||||
onUpdated={() => void refetch()}
|
onUpdated={() => void view.refetch()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<VendorEditorDialog
|
|
||||||
editor={vendorEditor.editor}
|
<WorkOrderVendorEditorDialog
|
||||||
loading={vendorEditor.loading}
|
editor={view.vendorEditor}
|
||||||
error={vendorEditor.error}
|
loading={view.vendorEditorLoading}
|
||||||
isPending={vendorEditor.isPending}
|
error={view.vendorEditorError}
|
||||||
phoneInvalid={vendorEditor.phoneInvalid}
|
isSaving={view.vendorMutation.isPending}
|
||||||
isValid={vendorEditor.isValid}
|
onClose={() => {
|
||||||
onClose={vendorEditor.close}
|
if (!view.vendorMutation.isPending) view.setVendorEditor(null);
|
||||||
onSave={vendorEditor.save}
|
}}
|
||||||
onFieldChange={vendorEditor.updateField}
|
onSave={view.saveVendorEditor}
|
||||||
|
onEditorChange={view.setVendorEditor}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
import type { ElementType } from "react";
|
||||||
|
|
||||||
|
type CompDocCoLineProps = {
|
||||||
|
icon: ElementType;
|
||||||
|
text: string;
|
||||||
|
bold?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CompDocCoLine({ icon: Icon, text, bold }: CompDocCoLineProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-end gap-1.5 break-words"
|
||||||
|
style={{ fontSize: 11, color: "#161E38", overflowWrap: "break-word" }}
|
||||||
|
>
|
||||||
|
<span style={{ fontWeight: bold ? 600 : 400 }}>{text}</span>
|
||||||
|
<Icon size={12} className="shrink-0" style={{ color: "#1C75BC" }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
import type { ElementType } from "react";
|
||||||
|
|
||||||
|
type CompDocDetailRowProps = {
|
||||||
|
icon: ElementType;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
last?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CompDocDetailRow({ icon: Icon, label, value, last }: CompDocDetailRowProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-start gap-2.5"
|
||||||
|
style={{
|
||||||
|
padding: "10px 0",
|
||||||
|
borderBottom: last ? "none" : "1px solid var(--color-border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
size={15}
|
||||||
|
className="shrink-0"
|
||||||
|
style={{ color: "var(--muted-foreground)", marginTop: 2 }}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div style={{ fontSize: 11.5, color: "var(--muted-foreground)" }}>{label}</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "var(--color-text-primary)",
|
||||||
|
marginTop: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
import type { DocState } from "@/domain/work-orders/types/completion-doc";
|
||||||
|
import { CompDocDialogFooterDraft } from "./comp-doc-dialog-footer-draft";
|
||||||
|
import { CompDocDialogFooterGenerated } from "./comp-doc-dialog-footer-generated";
|
||||||
|
import { CompDocDialogFooterNotRequired } from "./comp-doc-dialog-footer-not-required";
|
||||||
|
|
||||||
|
type CompDocDialogFooterProps = {
|
||||||
|
docState: DocState;
|
||||||
|
generatedAt: string;
|
||||||
|
onMarkNotRequired: () => void;
|
||||||
|
onUndo: () => void;
|
||||||
|
onSaveDraft: () => void;
|
||||||
|
onGenerate: () => void;
|
||||||
|
onDownloadPdf: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CompDocDialogFooter({
|
||||||
|
docState,
|
||||||
|
generatedAt,
|
||||||
|
onMarkNotRequired,
|
||||||
|
onUndo,
|
||||||
|
onSaveDraft,
|
||||||
|
onGenerate,
|
||||||
|
onDownloadPdf,
|
||||||
|
}: CompDocDialogFooterProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex shrink-0 items-center justify-between gap-3"
|
||||||
|
style={{
|
||||||
|
padding: "14px 20px",
|
||||||
|
borderTop: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-bg-surface)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{docState === "draft" && (
|
||||||
|
<CompDocDialogFooterDraft
|
||||||
|
onMarkNotRequired={onMarkNotRequired}
|
||||||
|
onSaveDraft={onSaveDraft}
|
||||||
|
onGenerate={onGenerate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{docState === "generated" && (
|
||||||
|
<CompDocDialogFooterGenerated generatedAt={generatedAt} onDownloadPdf={onDownloadPdf} />
|
||||||
|
)}
|
||||||
|
{docState === "not-required" && <CompDocDialogFooterNotRequired onUndo={onUndo} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
import { FileText, X } from "lucide-react";
|
||||||
|
import type { CompletionDocStateConfig } 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 CompDocDialogHeaderProps = {
|
||||||
|
row: WorkOrderTableRow;
|
||||||
|
cfg: CompletionDocStateConfig;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CompDocDialogHeader({ row, cfg, onClose }: CompDocDialogHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex shrink-0 items-center justify-between"
|
||||||
|
style={{
|
||||||
|
height: 60,
|
||||||
|
padding: "0 20px",
|
||||||
|
borderBottom: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-bg-surface)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 items-center gap-2.5">
|
||||||
|
<FileText size={18} className="shrink-0" style={{ color: "var(--color-primary)" }} />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--font-display)",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "var(--color-text-primary)",
|
||||||
|
lineHeight: 1.1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Completion Document
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="truncate"
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--muted-foreground)",
|
||||||
|
marginTop: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
WO{" "}
|
||||||
|
<span className="font-mono" style={{ color: "var(--color-primary)", fontWeight: 600 }}>
|
||||||
|
#{row.woNumber}
|
||||||
|
</span>
|
||||||
|
{" · "}
|
||||||
|
{serviceTitle(row)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1.5"
|
||||||
|
style={{
|
||||||
|
height: 26,
|
||||||
|
padding: "0 10px",
|
||||||
|
borderRadius: 9999,
|
||||||
|
background: cfg.pillBg,
|
||||||
|
color: cfg.pillText,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: 9999,
|
||||||
|
background: cfg.pillText,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{cfg.pillLabel}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close"
|
||||||
|
className="flex items-center justify-center rounded-md transition-colors hover:bg-[var(--color-bg-muted)]"
|
||||||
|
style={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,128 @@
|
||||||
|
import type { MouseEvent } from "react";
|
||||||
|
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
|
||||||
|
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 { 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 { CompDocPreview } from "./comp-doc-preview";
|
||||||
|
import { CompDocSidebar } from "./comp-doc-sidebar";
|
||||||
|
|
||||||
|
type CompDocDialogProps = {
|
||||||
|
row: WorkOrderTableRow;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
data?: CompDocData;
|
||||||
|
onSave: (data: CompDocData, status: CompDocSaveStatus) => void | Promise<void>;
|
||||||
|
onDateCompletedChange?: (date: string) => void | Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CompDocDialog({
|
||||||
|
row,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
data,
|
||||||
|
onSave,
|
||||||
|
onDateCompletedChange,
|
||||||
|
}: CompDocDialogProps) {
|
||||||
|
const {
|
||||||
|
site,
|
||||||
|
notes,
|
||||||
|
setNotes,
|
||||||
|
docState,
|
||||||
|
generatedAt,
|
||||||
|
editAddress,
|
||||||
|
setEditAddress,
|
||||||
|
editDateCompleted,
|
||||||
|
setEditDateCompleted,
|
||||||
|
amazonRep,
|
||||||
|
seaRep,
|
||||||
|
cfg,
|
||||||
|
isNotRequired,
|
||||||
|
isGenerated,
|
||||||
|
handleMarkNotRequired,
|
||||||
|
handleUndo,
|
||||||
|
handleSaveDraft,
|
||||||
|
handleGenerate,
|
||||||
|
handleDownloadPdf,
|
||||||
|
} = useCompDocDialog({ row, open, data, onSave, onDateCompletedChange });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={() => onOpenChange(false)}
|
||||||
|
maxWidth={false}
|
||||||
|
slotProps={{
|
||||||
|
...dialogBlurBackdropSlotProps,
|
||||||
|
paper: {
|
||||||
|
className: "flex h-[85vh] max-h-[85vh] flex-col overflow-hidden border border-border p-0",
|
||||||
|
style: {
|
||||||
|
borderRadius: 12,
|
||||||
|
width: "min(1024px, calc(100vw - 32px))",
|
||||||
|
maxWidth: "none",
|
||||||
|
},
|
||||||
|
onClick: (e: MouseEvent<HTMLDivElement>) => e.stopPropagation(),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent
|
||||||
|
dividers={false}
|
||||||
|
className="flex min-h-0 flex-1 flex-col gap-0 overflow-hidden p-0"
|
||||||
|
sx={{
|
||||||
|
p: 0,
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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 row={row} cfg={cfg} onClose={() => onOpenChange(false)} />
|
||||||
|
|
||||||
|
<div className="flex min-h-0 flex-1 flex-row">
|
||||||
|
<CompDocPreview
|
||||||
|
row={row}
|
||||||
|
site={site}
|
||||||
|
notes={notes}
|
||||||
|
generatedAt={generatedAt}
|
||||||
|
isGenerated={isGenerated}
|
||||||
|
isNotRequired={isNotRequired}
|
||||||
|
/>
|
||||||
|
<CompDocSidebar
|
||||||
|
row={row}
|
||||||
|
site={site}
|
||||||
|
docState={docState}
|
||||||
|
generatedAt={generatedAt}
|
||||||
|
notes={notes}
|
||||||
|
editAddress={editAddress}
|
||||||
|
onAddressChange={setEditAddress}
|
||||||
|
editDateCompleted={editDateCompleted}
|
||||||
|
amazonRep={amazonRep}
|
||||||
|
seaRep={seaRep}
|
||||||
|
isGenerated={isGenerated}
|
||||||
|
onNotesChange={setNotes}
|
||||||
|
onDateCompletedChange={setEditDateCompleted}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CompDocDialogFooter
|
||||||
|
docState={docState}
|
||||||
|
generatedAt={generatedAt}
|
||||||
|
onMarkNotRequired={handleMarkNotRequired}
|
||||||
|
onUndo={handleUndo}
|
||||||
|
onSaveDraft={handleSaveDraft}
|
||||||
|
onGenerate={handleGenerate}
|
||||||
|
onDownloadPdf={handleDownloadPdf}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
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;
|
||||||
|
site: CompDocSiteInfo;
|
||||||
|
notes: string;
|
||||||
|
generatedAt: string;
|
||||||
|
isGenerated: boolean;
|
||||||
|
isNotRequired: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CompDocPreview({
|
||||||
|
row,
|
||||||
|
site,
|
||||||
|
notes,
|
||||||
|
generatedAt,
|
||||||
|
isGenerated,
|
||||||
|
isNotRequired,
|
||||||
|
}: CompDocPreviewProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex min-h-0 flex-col"
|
||||||
|
style={{
|
||||||
|
width: "58%",
|
||||||
|
borderRight: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-bg-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CompDocPreviewBadge isGenerated={isGenerated} />
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto"
|
||||||
|
style={{ padding: "0 20px 20px" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="relative break-words"
|
||||||
|
style={{
|
||||||
|
background: "#ffffff",
|
||||||
|
borderRadius: 8,
|
||||||
|
boxShadow: "0 1px 3px rgba(0,0,0,.08), 0 12px 32px -16px rgba(0,0,0,.25)",
|
||||||
|
overflow: "hidden",
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
overflowWrap: "break-word",
|
||||||
|
wordBreak: "break-word",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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 && <CompDocNotRequiredStamp />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
import type { Procedure } from "@/domain/work-orders/utils/completion-doc-procedures";
|
||||||
|
|
||||||
|
type CompDocProcedureListProps = {
|
||||||
|
proc: Procedure;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CompDocProcedureList({ proc }: CompDocProcedureListProps) {
|
||||||
|
return (
|
||||||
|
<ol style={{ listStyle: "decimal", paddingLeft: 22, margin: 0 }}>
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
import type { CompDocSiteInfo, DocState } from "@/domain/work-orders/types/completion-doc";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-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;
|
||||||
|
site: CompDocSiteInfo;
|
||||||
|
docState: DocState;
|
||||||
|
generatedAt: string;
|
||||||
|
notes: string;
|
||||||
|
editAddress: string;
|
||||||
|
onAddressChange: (value: string) => void;
|
||||||
|
editDateCompleted: string;
|
||||||
|
amazonRep: string;
|
||||||
|
seaRep: string;
|
||||||
|
isGenerated: boolean;
|
||||||
|
onNotesChange: (value: string) => void;
|
||||||
|
onDateCompletedChange: (value: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CompDocSidebar({
|
||||||
|
row,
|
||||||
|
site,
|
||||||
|
docState,
|
||||||
|
generatedAt,
|
||||||
|
notes,
|
||||||
|
editAddress,
|
||||||
|
onAddressChange,
|
||||||
|
editDateCompleted,
|
||||||
|
amazonRep,
|
||||||
|
seaRep,
|
||||||
|
isGenerated,
|
||||||
|
onNotesChange,
|
||||||
|
onDateCompletedChange,
|
||||||
|
}: CompDocSidebarProps) {
|
||||||
|
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 }}>
|
||||||
|
<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,82 +1,178 @@
|
||||||
import { Box, Button, Chip, Stack, Tooltip, Typography } from "@mui/material";
|
import { Box, Button, Chip, Stack, Tooltip, Typography } from "@mui/material";
|
||||||
import type { UpliftRequest } from "@/domain/uplifts/types/uplift";
|
import type { UpliftRequest } from "@/domain/uplifts/types/uplift";
|
||||||
|
import { upliftsApi } from "@/domain/uplifts/api/uplifts-api";
|
||||||
import { formatDateTime } from "@/lib/time-utils";
|
import { formatDateTime } from "@/lib/time-utils";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
|
type UpliftAction = "approve" | "reject" | "request-changes";
|
||||||
|
|
||||||
type DispatchUpliftsSectionProps = {
|
type DispatchUpliftsSectionProps = {
|
||||||
uplifts: UpliftRequest[];
|
uplifts: UpliftRequest[];
|
||||||
isLocked: boolean;
|
isLocked: boolean;
|
||||||
onApprove: (uplift: UpliftRequest) => void;
|
onApprove: (uplift: UpliftRequest) => void;
|
||||||
onDeny: (uplift: UpliftRequest) => void;
|
onReject: (uplift: UpliftRequest) => void;
|
||||||
|
onRequestChanges: (uplift: UpliftRequest) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ACTION_LABELS: Record<Exclude<UpliftAction, "approve">, string> = {
|
||||||
|
reject: "Reject",
|
||||||
|
"request-changes": "Request Changes",
|
||||||
|
};
|
||||||
|
|
||||||
|
function actionColor(action: UpliftAction): "success" | "error" | "primary" {
|
||||||
|
if (action === "approve") return "success";
|
||||||
|
if (action === "reject") return "error";
|
||||||
|
return "primary";
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionVariant(action: UpliftAction): "contained" | "outlined" {
|
||||||
|
return action === "request-changes" ? "outlined" : "contained";
|
||||||
|
}
|
||||||
|
|
||||||
type UpliftCardProps = Omit<DispatchUpliftsSectionProps, "uplifts"> & {
|
type UpliftCardProps = Omit<DispatchUpliftsSectionProps, "uplifts"> & {
|
||||||
uplift: UpliftRequest;
|
uplift: UpliftRequest;
|
||||||
};
|
};
|
||||||
|
|
||||||
function UpliftCard({ uplift, isLocked, onApprove, onDeny }: UpliftCardProps) {
|
type UpliftCardActionsProps = Pick<
|
||||||
const canDecide = uplift.canDecide && uplift.status === "Pending" && !isLocked;
|
UpliftCardProps,
|
||||||
|
"isLocked" | "onApprove" | "onReject" | "onRequestChanges"
|
||||||
|
> & {
|
||||||
|
uplift: UpliftRequest;
|
||||||
|
};
|
||||||
|
|
||||||
|
function downloadUpliftEvidence(uplift: UpliftRequest) {
|
||||||
|
return upliftsApi
|
||||||
|
.downloadEvidence(uplift.id, uplift.evidenceFileName || "uplift-evidence")
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Unable to download evidence.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function UpliftCardHeader({ uplift }: { uplift: UpliftRequest }) {
|
||||||
|
const evidenceDocumentId = uplift.evidenceDocumentId;
|
||||||
return (
|
return (
|
||||||
<Box className="rounded border border-border p-3">
|
<>
|
||||||
<Stack direction="row" spacing={1} sx={{ alignItems: "center", flexWrap: "wrap" }}>
|
<Stack direction="row" spacing={1} sx={{ alignItems: "center", flexWrap: "wrap" }}>
|
||||||
<Chip label={uplift.status} size="small" />
|
<Chip label={uplift.status} size="small" />
|
||||||
{uplift.requiredTier === 2 && (
|
{uplift.requiredTier === 2 && (
|
||||||
<Typography variant="caption">Tier 2 · Manager approval</Typography>
|
<Typography variant="caption">Tier 2 · Manager approval</Typography>
|
||||||
)}
|
)}
|
||||||
<Typography variant="body2">
|
<Typography variant="body2">
|
||||||
${Number(uplift.currentNTE ?? 0).toFixed(2)} → ${Number(uplift.requestedNTE).toFixed(2)}{" "}
|
${Number(uplift.currentNTE ?? 0).toFixed(2)} → ${Number(uplift.requestedNTE).toFixed(2)}
|
||||||
(+${Number(uplift.delta).toFixed(2)})
|
|
||||||
</Typography>
|
</Typography>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
{Boolean(uplift.expiresAt) && (
|
||||||
|
<Typography variant="caption" color="text.secondary" className="mt-1 block">
|
||||||
|
Expires {formatDateTime(uplift.expiresAt)}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
{Boolean(uplift.vendorReason) && (
|
{Boolean(uplift.vendorReason) && (
|
||||||
<Typography variant="body2" className="mt-1">
|
<Typography variant="body2" className="mt-1">
|
||||||
<strong>Reason:</strong> {uplift.vendorReason}
|
<strong>Reason:</strong> {uplift.vendorReason}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
{evidenceDocumentId != null && (
|
||||||
|
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }} className="mt-1">
|
||||||
|
<Typography variant="body2">
|
||||||
|
<strong>Evidence:</strong>{" "}
|
||||||
|
{uplift.evidenceFileName || `Document #${evidenceDocumentId}`}
|
||||||
|
</Typography>
|
||||||
|
<Button size="small" variant="text" onClick={() => void downloadUpliftEvidence(uplift)}>
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
{uplift.notificationStatus === "Error" && Boolean(uplift.notificationError) && (
|
||||||
|
<Typography variant="body2" color="error" className="mt-1">
|
||||||
|
Notification error: {uplift.notificationError}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
{Boolean(uplift.requestedByVendorName || uplift.requestedAt) && (
|
{Boolean(uplift.requestedByVendorName || uplift.requestedAt) && (
|
||||||
<Typography variant="body2" color="text.secondary" className="mt-1">
|
<Typography variant="body2" color="text.secondary" className="mt-1">
|
||||||
Requested by {uplift.requestedByVendorName || "vendor"}
|
Requested by {uplift.requestedByVendorName || "vendor"}
|
||||||
{uplift.requestedAt ? ` · ${formatDateTime(uplift.requestedAt)}` : ""}
|
{uplift.requestedAt ? ` · ${formatDateTime(uplift.requestedAt)}` : ""}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
{uplift.status !== "Pending" &&
|
</>
|
||||||
Boolean(uplift.decidedByName || uplift.decidedAt || uplift.decisionNote) && (
|
);
|
||||||
<Box className="mt-1">
|
}
|
||||||
{Boolean(uplift.decidedByName || uplift.decidedAt) && (
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
function UpliftCardDecisionSummary({ uplift }: { uplift: UpliftRequest }) {
|
||||||
{uplift.status} by {uplift.decidedByName || "—"}
|
if (uplift.status === "Pending") return null;
|
||||||
{uplift.decidedAt ? ` · ${formatDateTime(uplift.decidedAt)}` : ""}
|
if (!uplift.decidedByName && !uplift.decidedAt && !uplift.decisionNote) return null;
|
||||||
</Typography>
|
return (
|
||||||
)}
|
<Box className="mt-1">
|
||||||
{Boolean(uplift.decisionNote) && (
|
{Boolean(uplift.decidedByName || uplift.decidedAt) && (
|
||||||
<Typography variant="body2" className="mt-0.5">
|
<Typography variant="body2" color="text.secondary">
|
||||||
<strong>Note:</strong> {uplift.decisionNote}
|
{uplift.status} by {uplift.decidedByName || "—"}
|
||||||
</Typography>
|
{uplift.decidedAt ? ` · ${formatDateTime(uplift.decidedAt)}` : ""}
|
||||||
)}
|
</Typography>
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
{uplift.status === "Pending" && (
|
|
||||||
<Stack direction="row" spacing={1} className="mt-2">
|
|
||||||
{(["approve", "deny"] as const).map((action) => (
|
|
||||||
<Tooltip
|
|
||||||
key={action}
|
|
||||||
title={!canDecide ? `Requires Tier ${uplift.requiredTier} role` : ""}
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
color={action === "approve" ? "success" : "error"}
|
|
||||||
variant="contained"
|
|
||||||
disabled={!canDecide}
|
|
||||||
onClick={() => (action === "approve" ? onApprove(uplift) : onDeny(uplift))}
|
|
||||||
>
|
|
||||||
{action === "approve" ? "Approve" : "Deny"}
|
|
||||||
</Button>
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
)}
|
)}
|
||||||
|
{Boolean(uplift.decisionNote) && (
|
||||||
|
<Typography variant="body2" className="mt-0.5">
|
||||||
|
<strong>Note:</strong> {uplift.decisionNote}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UpliftCardActions({
|
||||||
|
uplift,
|
||||||
|
isLocked,
|
||||||
|
onApprove,
|
||||||
|
onReject,
|
||||||
|
onRequestChanges,
|
||||||
|
}: UpliftCardActionsProps) {
|
||||||
|
if (uplift.status !== "Pending") return null;
|
||||||
|
const canDecide = uplift.canDecide && !isLocked;
|
||||||
|
const decisionActions: Exclude<UpliftAction, "approve">[] = ["reject", "request-changes"];
|
||||||
|
return (
|
||||||
|
<Stack direction="row" spacing={1} className="mt-2">
|
||||||
|
<Tooltip title={!canDecide ? `Requires Tier ${uplift.requiredTier} role` : ""}>
|
||||||
|
<span>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
color="success"
|
||||||
|
variant="contained"
|
||||||
|
disabled={!canDecide}
|
||||||
|
onClick={() => onApprove(uplift)}
|
||||||
|
>
|
||||||
|
Approve
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
{decisionActions.map((action) => (
|
||||||
|
<Tooltip key={action} title={!canDecide ? `Requires Tier ${uplift.requiredTier} role` : ""}>
|
||||||
|
<span>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
color={actionColor(action)}
|
||||||
|
variant={actionVariant(action)}
|
||||||
|
disabled={!canDecide}
|
||||||
|
onClick={() => (action === "reject" ? onReject(uplift) : onRequestChanges(uplift))}
|
||||||
|
>
|
||||||
|
{ACTION_LABELS[action]}
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UpliftCard({ uplift, isLocked, onApprove, onReject, onRequestChanges }: UpliftCardProps) {
|
||||||
|
return (
|
||||||
|
<Box className="rounded border border-border p-3">
|
||||||
|
<UpliftCardHeader uplift={uplift} />
|
||||||
|
<UpliftCardDecisionSummary uplift={uplift} />
|
||||||
|
<UpliftCardActions
|
||||||
|
uplift={uplift}
|
||||||
|
isLocked={isLocked}
|
||||||
|
onApprove={onApprove}
|
||||||
|
onReject={onReject}
|
||||||
|
onRequestChanges={onRequestChanges}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -85,7 +181,8 @@ export function DispatchUpliftsSection({
|
||||||
uplifts,
|
uplifts,
|
||||||
isLocked,
|
isLocked,
|
||||||
onApprove,
|
onApprove,
|
||||||
onDeny,
|
onReject,
|
||||||
|
onRequestChanges,
|
||||||
}: DispatchUpliftsSectionProps) {
|
}: DispatchUpliftsSectionProps) {
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
|
|
@ -102,7 +199,8 @@ export function DispatchUpliftsSection({
|
||||||
uplift={uplift}
|
uplift={uplift}
|
||||||
isLocked={isLocked}
|
isLocked={isLocked}
|
||||||
onApprove={onApprove}
|
onApprove={onApprove}
|
||||||
onDeny={onDeny}
|
onReject={onReject}
|
||||||
|
onRequestChanges={onRequestChanges}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,10 @@ export function DispatchDetailModal({
|
||||||
uplifts={controller.uplifts}
|
uplifts={controller.uplifts}
|
||||||
isLocked={controller.isLocked}
|
isLocked={controller.isLocked}
|
||||||
onApprove={(uplift) => controller.setUpliftAction({ type: "approve", uplift })}
|
onApprove={(uplift) => controller.setUpliftAction({ type: "approve", uplift })}
|
||||||
onDeny={(uplift) => controller.setUpliftAction({ type: "deny", uplift })}
|
onReject={(uplift) => controller.setUpliftAction({ type: "reject", uplift })}
|
||||||
|
onRequestChanges={(uplift) =>
|
||||||
|
controller.setUpliftAction({ type: "request-changes", uplift })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DispatchVendorCommunication
|
<DispatchVendorCommunication
|
||||||
|
|
@ -151,7 +154,9 @@ export function DispatchDetailModal({
|
||||||
onClose={() => controller.setUpliftAction(null)}
|
onClose={() => controller.setUpliftAction(null)}
|
||||||
onConfirm={controller.handleUpliftConfirm}
|
onConfirm={controller.handleUpliftConfirm}
|
||||||
isPending={
|
isPending={
|
||||||
controller.approveUpliftMutation.isPending || controller.denyUpliftMutation.isPending
|
controller.approveUpliftMutation.isPending ||
|
||||||
|
controller.rejectUpliftMutation.isPending ||
|
||||||
|
controller.requestChangesUpliftMutation.isPending
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import {
|
||||||
|
|
||||||
type UpliftDecisionDialogProps = {
|
type UpliftDecisionDialogProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
type: "approve" | "deny";
|
type: "approve" | "reject" | "request-changes";
|
||||||
requestedNTE: number;
|
requestedNTE: number;
|
||||||
currentNTE: number | null;
|
currentNTE: number | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
|
@ -19,6 +19,13 @@ type UpliftDecisionDialogProps = {
|
||||||
isPending?: boolean;
|
isPending?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function titleFor(type: UpliftDecisionDialogProps["type"], requestedNTE: number): string {
|
||||||
|
if (type === "approve") {
|
||||||
|
return `Approve uplift to $${requestedNTE.toFixed(2)}?`;
|
||||||
|
}
|
||||||
|
return type === "reject" ? "Reject Uplift" : "Request Changes";
|
||||||
|
}
|
||||||
|
|
||||||
export function UpliftDecisionDialog({
|
export function UpliftDecisionDialog({
|
||||||
open,
|
open,
|
||||||
type,
|
type,
|
||||||
|
|
@ -29,6 +36,7 @@ export function UpliftDecisionDialog({
|
||||||
isPending = false,
|
isPending = false,
|
||||||
}: UpliftDecisionDialogProps) {
|
}: UpliftDecisionDialogProps) {
|
||||||
const [note, setNote] = useState("");
|
const [note, setNote] = useState("");
|
||||||
|
const noteRequired = type === "reject" || type === "request-changes";
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
setNote("");
|
setNote("");
|
||||||
|
|
@ -36,15 +44,14 @@ export function UpliftDecisionDialog({
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleConfirm = () => {
|
const handleConfirm = () => {
|
||||||
|
if (noteRequired && !note.trim()) return;
|
||||||
onConfirm(note.trim());
|
onConfirm(note.trim());
|
||||||
setNote("");
|
setNote("");
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onClose={handleClose}>
|
<Dialog open={open} onClose={handleClose}>
|
||||||
<DialogTitle>
|
<DialogTitle>{titleFor(type, requestedNTE)}</DialogTitle>
|
||||||
{type === "approve" ? `Approve uplift to $${requestedNTE.toFixed(2)}?` : "Deny Uplift"}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
{type === "approve" && (
|
{type === "approve" && (
|
||||||
<Typography variant="body2" className="mt-1 mb-3">
|
<Typography variant="body2" className="mt-1 mb-3">
|
||||||
|
|
@ -57,7 +64,8 @@ export function UpliftDecisionDialog({
|
||||||
multiline
|
multiline
|
||||||
minRows={2}
|
minRows={2}
|
||||||
className="mt-2"
|
className="mt-2"
|
||||||
label={type === "deny" ? "Denial reason (required)" : "Optional note"}
|
label={noteRequired ? "Note (required)" : "Optional note"}
|
||||||
|
required={noteRequired}
|
||||||
value={note}
|
value={note}
|
||||||
onChange={(event) => setNote(event.target.value)}
|
onChange={(event) => setNote(event.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -66,8 +74,8 @@ export function UpliftDecisionDialog({
|
||||||
<Button onClick={handleClose}>Cancel</Button>
|
<Button onClick={handleClose}>Cancel</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
color={type === "approve" ? "success" : "error"}
|
color={type === "approve" ? "success" : "primary"}
|
||||||
disabled={type === "deny" ? !note.trim() || isPending : isPending}
|
disabled={noteRequired ? !note.trim() || isPending : isPending}
|
||||||
onClick={handleConfirm}
|
onClick={handleConfirm}
|
||||||
>
|
>
|
||||||
Confirm
|
Confirm
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { Ban, FileText, Image as ImageIcon } from "lucide-react";
|
import { Ban, FileText, Image as ImageIcon } from "lucide-react";
|
||||||
import type { CompletionDocStatus } from "@/domain/work-orders/types/work-order-table-row";
|
import type { CompletionDocStatus } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
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 {
|
function isMediaWO(type: WOType | ""): boolean {
|
||||||
return type === "Emergency" || type === "Reactive";
|
return type === "Emergency" || type === "Reactive";
|
||||||
|
|
@ -9,6 +10,8 @@ function isMediaWO(type: WOType | ""): boolean {
|
||||||
type CompDocCellProps = {
|
type CompDocCellProps = {
|
||||||
status: CompletionDocStatus;
|
status: CompletionDocStatus;
|
||||||
woType: WOType | "";
|
woType: WOType | "";
|
||||||
|
/** Primary service (`pm`). Required before CompDoc can open. */
|
||||||
|
pm?: string;
|
||||||
mediaCount?: number;
|
mediaCount?: number;
|
||||||
/** Omit until completion-doc slice mounts CompDocDialog. */
|
/** Omit until completion-doc slice mounts CompDocDialog. */
|
||||||
onOpen?: () => void;
|
onOpen?: () => void;
|
||||||
|
|
@ -18,6 +21,7 @@ type CompDocCellProps = {
|
||||||
export function CompDocCell({
|
export function CompDocCell({
|
||||||
status,
|
status,
|
||||||
woType,
|
woType,
|
||||||
|
pm = "",
|
||||||
mediaCount = 0,
|
mediaCount = 0,
|
||||||
onOpen,
|
onOpen,
|
||||||
onOpenMedia,
|
onOpenMedia,
|
||||||
|
|
@ -43,6 +47,20 @@ export function CompDocCell({
|
||||||
return null;
|
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 generated = status === "Yes";
|
||||||
const notRequired = status === "NN";
|
const notRequired = status === "NN";
|
||||||
const color = generated ? "var(--color-success)" : "var(--color-text-muted)";
|
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);
|
setAnchorEl(e.currentTarget);
|
||||||
}}
|
}}
|
||||||
title="This WO missed its service window. Reschedule to unlock status updates."
|
title="This WO missed its deadline. Update the Due Date to unlock status updates."
|
||||||
className="group/cell flex w-full cursor-pointer items-center gap-1.5 whitespace-nowrap leading-none transition-opacity hover:opacity-80"
|
className="group/cell flex w-full cursor-pointer items-center gap-1.5 whitespace-nowrap leading-none transition-opacity hover:opacity-80"
|
||||||
style={{ fontSize: 12.5, fontWeight: 600, color: "var(--status-pastdue-text)" }}
|
style={{ fontSize: 12.5, fontWeight: 600, color: "var(--status-pastdue-text)" }}
|
||||||
>
|
>
|
||||||
|
|
@ -83,8 +83,7 @@ export function StatusCell({
|
||||||
color: "var(--status-pending-text)",
|
color: "var(--status-pending-text)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
This work order is past due. A new date must be scheduled before the status can be
|
This work order is past due. Update the Due Date before the status can be updated.
|
||||||
updated.
|
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|
@ -98,7 +97,7 @@ export function StatusCell({
|
||||||
}}
|
}}
|
||||||
className="mt-2 block text-[11px] font-medium text-[var(--color-primary)]"
|
className="mt-2 block text-[11px] font-medium text-[var(--color-primary)]"
|
||||||
>
|
>
|
||||||
Reschedule →
|
Update Due Date →
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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";
|
import { VendorDialog, type VendorPatch } from "./vendor-dialog";
|
||||||
|
|
||||||
type VendorCellProps = {
|
type VendorCellProps = {
|
||||||
|
vendorId?: string | number;
|
||||||
company: string;
|
company: string;
|
||||||
tech: string;
|
tech: string;
|
||||||
techPhone?: string;
|
techPhone?: string;
|
||||||
|
|
@ -13,7 +14,15 @@ type VendorCellProps = {
|
||||||
onSave: (patch: VendorPatch) => void;
|
onSave: (patch: VendorPatch) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function VendorCell({ company, tech, techPhone, vendors, q, onSave }: VendorCellProps) {
|
export function VendorCell({
|
||||||
|
vendorId = "",
|
||||||
|
company,
|
||||||
|
tech,
|
||||||
|
techPhone,
|
||||||
|
vendors,
|
||||||
|
q,
|
||||||
|
onSave,
|
||||||
|
}: VendorCellProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const hasAny = !!(tech || company);
|
const hasAny = !!(tech || company);
|
||||||
|
|
||||||
|
|
@ -46,6 +55,7 @@ export function VendorCell({ company, tech, techPhone, vendors, q, onSave }: Ven
|
||||||
<VendorDialog
|
<VendorDialog
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={setOpen}
|
onOpenChange={setOpen}
|
||||||
|
vendorId={vendorId}
|
||||||
company={company}
|
company={company}
|
||||||
tech={tech}
|
tech={tech}
|
||||||
techPhone={techPhone}
|
techPhone={techPhone}
|
||||||
|
|
|
||||||
|
|
@ -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 { useEffect, useMemo, useState, type MouseEvent } from "react";
|
||||||
import { Wrench } from "lucide-react";
|
|
||||||
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
|
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
|
||||||
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
|
|
||||||
import {
|
|
||||||
DLG_INPUT_CLS,
|
|
||||||
DLG_INPUT_STYLE,
|
|
||||||
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
||||||
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||||
import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label";
|
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
||||||
|
import { useCreateVendor } from "@/domain/vendors/use-cases/use-create-vendor";
|
||||||
import {
|
import {
|
||||||
buildTechnicianOptions,
|
buildTechnicianOptions,
|
||||||
phoneForOption,
|
phoneForOption,
|
||||||
} from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-options";
|
} from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-options";
|
||||||
import { VendorDialogCompanyCard } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-company-card";
|
import { VendorDialogBody } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-body";
|
||||||
|
import { VendorDialogFooter } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-footer";
|
||||||
|
import {
|
||||||
|
mergeVendorDropdownItems,
|
||||||
|
resolveVendorCompanyAnchor,
|
||||||
|
} from "@/app/(protected)/workorders/_components/list/table/cells/resolve-vendor-company-anchor";
|
||||||
|
import { resolveTechnicianKey } from "@/app/(protected)/workorders/_components/list/table/cells/resolve-technician-key";
|
||||||
|
import { useVendorDialogSave } from "@/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-save";
|
||||||
|
import type { VendorPatch } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types";
|
||||||
|
|
||||||
export type VendorPatch = {
|
export type { VendorPatch } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types";
|
||||||
vendorId: string;
|
|
||||||
company: string;
|
|
||||||
tech: string;
|
|
||||||
techPhone: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type VendorDialogProps = {
|
type VendorDialogProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
|
vendorId?: string | number;
|
||||||
company: string;
|
company: string;
|
||||||
tech: string;
|
tech: string;
|
||||||
techPhone?: string;
|
techPhone?: string;
|
||||||
|
|
@ -37,43 +35,94 @@ type VendorDialogProps = {
|
||||||
export function VendorDialog({
|
export function VendorDialog({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
|
vendorId = "",
|
||||||
company,
|
company,
|
||||||
tech,
|
tech,
|
||||||
techPhone = "",
|
techPhone = "",
|
||||||
vendors,
|
vendors,
|
||||||
onSave,
|
onSave,
|
||||||
}: VendorDialogProps) {
|
}: VendorDialogProps) {
|
||||||
|
const createVendor = useCreateVendor();
|
||||||
|
const [selectedKey, setSelectedKey] = useState(() =>
|
||||||
|
resolveTechnicianKey(vendorId, tech, company, vendors),
|
||||||
|
);
|
||||||
const [t, setT] = useState(tech);
|
const [t, setT] = useState(tech);
|
||||||
const [phone, setPhone] = useState(techPhone);
|
const [phone, setPhone] = useState(techPhone);
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
|
const [addedVendors, setAddedVendors] = useState<VendorDropdownItem[]>([]);
|
||||||
|
const [pendingCreates, setPendingCreates] = useState<Record<string, VendorFormValues>>({});
|
||||||
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const allVendors = useMemo(
|
||||||
|
() => mergeVendorDropdownItems(vendors, addedVendors),
|
||||||
|
[vendors, addedVendors],
|
||||||
|
);
|
||||||
|
|
||||||
const technicianOptions = useMemo(
|
const technicianOptions = useMemo(
|
||||||
() => buildTechnicianOptions(vendors, tech, company),
|
() => buildTechnicianOptions(allVendors, tech, company),
|
||||||
[vendors, tech, company],
|
[allVendors, tech, company],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
|
const key = resolveTechnicianKey(vendorId, tech, company, vendors);
|
||||||
|
setSelectedKey(key);
|
||||||
setT(tech);
|
setT(tech);
|
||||||
setPhone(techPhone || phoneForOption(tech, technicianOptions));
|
setPhone(techPhone || phoneForOption(tech, buildTechnicianOptions(vendors, tech, company)));
|
||||||
setNotes("");
|
setNotes("");
|
||||||
|
setAddedVendors([]);
|
||||||
|
setPendingCreates({});
|
||||||
|
setSaveError(null);
|
||||||
}
|
}
|
||||||
}, [open, tech, techPhone, technicianOptions]);
|
// Intentionally omit technicianOptions: rebuilding options after Add must not clear staged drafts.
|
||||||
|
}, [open, tech, techPhone, vendors, company, vendorId]);
|
||||||
|
|
||||||
const selected =
|
const selected = technicianOptions.find((option) => option.key === selectedKey);
|
||||||
technicianOptions.find((o) => o.name === t && (!company || o.company === company)) ??
|
const resolvedCompany = selectedKey ? (selected?.company ?? company) : company;
|
||||||
technicianOptions.find((o) => o.name === t || o.key === t);
|
const selectedVendor =
|
||||||
const selectedKey = selected?.key ?? "";
|
selected?.vendor ?? allVendors.find((vendor) => String(vendor.id) === selectedKey);
|
||||||
const resolvedCompany = t ? (selected?.company ?? company) : "";
|
|
||||||
const selectedVendor = selected?.vendor ?? vendors.find((v) => v.companyName === resolvedCompany);
|
const { companyName: anchorCompanyName, companyVendor } = resolveVendorCompanyAnchor({
|
||||||
|
company,
|
||||||
|
resolvedCompany,
|
||||||
|
selectedVendor,
|
||||||
|
vendors: allVendors,
|
||||||
|
});
|
||||||
|
|
||||||
const handlePick = (key: string) => {
|
const handlePick = (key: string) => {
|
||||||
const option = technicianOptions.find((o) => o.key === key);
|
const option = technicianOptions.find((o) => o.key === key);
|
||||||
const name = option?.name ?? key;
|
setSelectedKey(key);
|
||||||
setT(name);
|
setT(option?.name ?? key);
|
||||||
setPhone(phoneForOption(name, technicianOptions));
|
setPhone(option?.phone || phoneForOption(option?.name ?? key, technicianOptions));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleTechnicianCreated = (
|
||||||
|
created: VendorDropdownItem,
|
||||||
|
createdPhone: string,
|
||||||
|
payload: VendorFormValues,
|
||||||
|
) => {
|
||||||
|
setAddedVendors((current) => [...current, created]);
|
||||||
|
setPendingCreates((current) => ({ ...current, [String(created.id)]: payload }));
|
||||||
|
setSelectedKey(String(created.id));
|
||||||
|
setT(created.contactName);
|
||||||
|
setPhone(createdPhone);
|
||||||
|
};
|
||||||
|
|
||||||
|
const { handleSave } = useVendorDialogSave({
|
||||||
|
allVendors,
|
||||||
|
pendingCreates,
|
||||||
|
selectedKey,
|
||||||
|
selectedVendor,
|
||||||
|
resolvedCompany,
|
||||||
|
anchorCompanyName,
|
||||||
|
tech: selected?.name ?? t,
|
||||||
|
phone,
|
||||||
|
createVendor,
|
||||||
|
onSave,
|
||||||
|
onOpenChange,
|
||||||
|
setSaveError,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open={open}
|
open={open}
|
||||||
|
|
@ -96,84 +145,28 @@ export function VendorDialog({
|
||||||
Search and select the technician
|
Search and select the technician
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<div className="mt-3 space-y-3">
|
<VendorDialogBody
|
||||||
<div>
|
selectedKey={selectedKey}
|
||||||
<FieldLabel>Technician</FieldLabel>
|
technicianOptions={technicianOptions}
|
||||||
<WizardFieldSelect
|
anchorCompanyName={anchorCompanyName}
|
||||||
value={selectedKey}
|
companyVendorForAdd={companyVendor ?? undefined}
|
||||||
placeholder="Search technician …"
|
tech={selected?.name ?? t}
|
||||||
icon={
|
phone={phone}
|
||||||
<Wrench
|
notes={notes}
|
||||||
size={14}
|
resolvedCompany={resolvedCompany}
|
||||||
className="shrink-0"
|
selectedVendor={selectedVendor}
|
||||||
style={{ color: "var(--color-text-muted)" }}
|
saveError={saveError}
|
||||||
/>
|
onPick={handlePick}
|
||||||
}
|
onPhoneChange={setPhone}
|
||||||
searchPlaceholder="Search technician…"
|
onNotesChange={setNotes}
|
||||||
options={technicianOptions.map((option) => ({
|
onTechnicianCreated={handleTechnicianCreated}
|
||||||
key: option.key,
|
/>
|
||||||
label: option.label,
|
|
||||||
}))}
|
|
||||||
onPick={handlePick}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{Boolean(t) && (
|
<VendorDialogFooter
|
||||||
<div>
|
isPending={createVendor.isPending}
|
||||||
<FieldLabel>Technician Phone</FieldLabel>
|
onCancel={() => onOpenChange(false)}
|
||||||
<input
|
onSave={handleSave}
|
||||||
type="tel"
|
/>
|
||||||
value={phone}
|
|
||||||
onChange={(e) => setPhone(e.target.value)}
|
|
||||||
className={DLG_INPUT_CLS}
|
|
||||||
style={DLG_INPUT_STYLE}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{Boolean(t) && (
|
|
||||||
<div>
|
|
||||||
<FieldLabel>Company</FieldLabel>
|
|
||||||
<VendorDialogCompanyCard company={resolvedCompany} vendor={selectedVendor} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<FieldLabel>Notes</FieldLabel>
|
|
||||||
<textarea
|
|
||||||
value={notes}
|
|
||||||
onChange={(e) => setNotes(e.target.value)}
|
|
||||||
placeholder="Contact preferences, availability, etc."
|
|
||||||
rows={3}
|
|
||||||
className={`${DLG_INPUT_CLS} resize-none`}
|
|
||||||
style={{ ...DLG_INPUT_STYLE, fontFamily: "inherit" }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 flex items-center justify-end gap-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="min-h-0 px-2 py-1 text-[12px]"
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="min-h-0 px-3 py-1 text-[12px]"
|
|
||||||
onClick={() => {
|
|
||||||
onSave({
|
|
||||||
vendorId: selectedVendor ? String(selectedVendor.id) : "",
|
|
||||||
company: resolvedCompany,
|
|
||||||
tech: t,
|
|
||||||
techPhone: phone,
|
|
||||||
});
|
|
||||||
onOpenChange(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import type {
|
||||||
import { AssignedToCell } from "./cells/assigned-to-cell";
|
import { AssignedToCell } from "./cells/assigned-to-cell";
|
||||||
import { ApptTimeCell } from "./cells/appt-time-cell";
|
import { ApptTimeCell } from "./cells/appt-time-cell";
|
||||||
import { DateCell } from "./cells/date-cell";
|
import { DateCell } from "./cells/date-cell";
|
||||||
|
import { DueDateCell } from "./cells/due-date-cell";
|
||||||
import { EditableWONumberCell } from "./cells/editable-wo-number-cell";
|
import { EditableWONumberCell } from "./cells/editable-wo-number-cell";
|
||||||
import { PMTypeCell } from "./cells/pm-type-cell";
|
import { PMTypeCell } from "./cells/pm-type-cell";
|
||||||
import { SiteCell } from "./cells/site-cell";
|
import { SiteCell } from "./cells/site-cell";
|
||||||
|
|
@ -112,7 +113,11 @@ export function WoTableInlineRowCells({
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||||
<DateCell value={draft.dueDate} onSave={(date) => onPatch({ dueDate: date })} />
|
<DueDateCell
|
||||||
|
type={draft.type}
|
||||||
|
value={draft.dueDate}
|
||||||
|
onSave={(date) => onPatch({ dueDate: date })}
|
||||||
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||||
|
|
@ -135,6 +140,7 @@ export function WoTableInlineRowCells({
|
||||||
|
|
||||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||||
<VendorCell
|
<VendorCell
|
||||||
|
vendorId={draft.vendorId}
|
||||||
company={draft.company}
|
company={draft.company}
|
||||||
tech={draft.tech}
|
tech={draft.tech}
|
||||||
techPhone={draft.techPhone}
|
techPhone={draft.techPhone}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import type { LocationOption, WorkOrderUser } from "@/domain/work-orders/types/w
|
||||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
import { AssignedToCell } from "./cells/assigned-to-cell";
|
import { AssignedToCell } from "./cells/assigned-to-cell";
|
||||||
import { DateCell } from "./cells/date-cell";
|
import { DateCell } from "./cells/date-cell";
|
||||||
|
import { DueDateCell } from "./cells/due-date-cell";
|
||||||
import { EditableWONumberCell } from "./cells/editable-wo-number-cell";
|
import { EditableWONumberCell } from "./cells/editable-wo-number-cell";
|
||||||
import { SiteCell } from "./cells/site-cell";
|
import { SiteCell } from "./cells/site-cell";
|
||||||
import { TypeCell } from "./cells/type-cell";
|
import { TypeCell } from "./cells/type-cell";
|
||||||
|
|
@ -16,6 +17,7 @@ type WoTableRowCellsProps = {
|
||||||
sitePlace?: { city: string; state: string };
|
sitePlace?: { city: string; state: string };
|
||||||
search?: string;
|
search?: string;
|
||||||
forceScheduleHighlight?: boolean;
|
forceScheduleHighlight?: boolean;
|
||||||
|
forceDueDateHighlight?: boolean;
|
||||||
mutationCellStyle?: CSSProperties;
|
mutationCellStyle?: CSSProperties;
|
||||||
handlers: WoTableRowHandlers;
|
handlers: WoTableRowHandlers;
|
||||||
onPatchRow: (patch: Partial<WorkOrderTableRow>) => void;
|
onPatchRow: (patch: Partial<WorkOrderTableRow>) => void;
|
||||||
|
|
@ -29,6 +31,7 @@ export function WoTableRowCells({
|
||||||
sitePlace,
|
sitePlace,
|
||||||
search,
|
search,
|
||||||
forceScheduleHighlight,
|
forceScheduleHighlight,
|
||||||
|
forceDueDateHighlight,
|
||||||
mutationCellStyle,
|
mutationCellStyle,
|
||||||
handlers,
|
handlers,
|
||||||
onPatchRow,
|
onPatchRow,
|
||||||
|
|
@ -104,7 +107,7 @@ export function WoTableRowCells({
|
||||||
<DateCell
|
<DateCell
|
||||||
value={row.scheduledOn}
|
value={row.scheduledOn}
|
||||||
q={search}
|
q={search}
|
||||||
highlight={forceScheduleHighlight || row.isPastDue}
|
highlight={forceScheduleHighlight}
|
||||||
allowWeekMode
|
allowWeekMode
|
||||||
targetWeek={row.targetWeek}
|
targetWeek={row.targetWeek}
|
||||||
onSave={(date) => handlers.onScheduleDate(row.id, date)}
|
onSave={(date) => handlers.onScheduleDate(row.id, date)}
|
||||||
|
|
@ -116,7 +119,13 @@ export function WoTableRowCells({
|
||||||
style={{ padding: "0 14px", ...mutationCellStyle }}
|
style={{ padding: "0 14px", ...mutationCellStyle }}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<DateCell value={row.dueDate} q={search} onSave={(date) => onPatchRow({ dueDate: date })} />
|
<DueDateCell
|
||||||
|
type={row.type}
|
||||||
|
value={row.dueDate}
|
||||||
|
q={search}
|
||||||
|
highlight={forceDueDateHighlight || row.isPastDue}
|
||||||
|
onSave={(date) => onPatchRow({ dueDate: date })}
|
||||||
|
/>
|
||||||
</td>
|
</td>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { CSSProperties } from "react";
|
||||||
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
import { ApptTimeCell } from "./cells/appt-time-cell";
|
import { ApptTimeCell } from "./cells/appt-time-cell";
|
||||||
|
|
@ -12,7 +13,7 @@ type WoTableRowServiceCellsProps = {
|
||||||
vendors: VendorDropdownItem[];
|
vendors: VendorDropdownItem[];
|
||||||
pmTypes: string[];
|
pmTypes: string[];
|
||||||
search?: string;
|
search?: string;
|
||||||
mutationCellStyle?: React.CSSProperties;
|
mutationCellStyle?: CSSProperties;
|
||||||
handlers: WoTableRowHandlers;
|
handlers: WoTableRowHandlers;
|
||||||
onPatchRow: (patch: Partial<WorkOrderTableRow>) => void;
|
onPatchRow: (patch: Partial<WorkOrderTableRow>) => void;
|
||||||
};
|
};
|
||||||
|
|
@ -56,6 +57,7 @@ export function WoTableRowServiceCells({
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<VendorCell
|
<VendorCell
|
||||||
|
vendorId={row.vendorId}
|
||||||
company={row.company}
|
company={row.company}
|
||||||
tech={row.tech}
|
tech={row.tech}
|
||||||
techPhone={row.techPhone}
|
techPhone={row.techPhone}
|
||||||
|
|
@ -94,6 +96,7 @@ export function WoTableRowServiceCells({
|
||||||
<CompDocCell
|
<CompDocCell
|
||||||
status={row.docStatus}
|
status={row.docStatus}
|
||||||
woType={row.type}
|
woType={row.type}
|
||||||
|
pm={row.pm}
|
||||||
mediaCount={row.mediaCount}
|
mediaCount={row.mediaCount}
|
||||||
onOpen={handlers.onOpenCompDoc ? () => handlers.onOpenCompDoc!(row) : undefined}
|
onOpen={handlers.onOpenCompDoc ? () => handlers.onOpenCompDoc!(row) : undefined}
|
||||||
onOpenMedia={() => handlers.onOpenMedia(row)}
|
onOpenMedia={() => handlers.onOpenMedia(row)}
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ type WoTableRowProps = {
|
||||||
pmTypes: string[];
|
pmTypes: string[];
|
||||||
search?: string;
|
search?: string;
|
||||||
forceScheduleHighlight?: boolean;
|
forceScheduleHighlight?: boolean;
|
||||||
|
forceDueDateHighlight?: boolean;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
isFlashing: boolean;
|
isFlashing: boolean;
|
||||||
dragViz: DragVizState;
|
dragViz: DragVizState;
|
||||||
|
|
@ -61,6 +62,7 @@ export function WoTableRow({
|
||||||
pmTypes,
|
pmTypes,
|
||||||
search,
|
search,
|
||||||
forceScheduleHighlight,
|
forceScheduleHighlight,
|
||||||
|
forceDueDateHighlight,
|
||||||
isSelected,
|
isSelected,
|
||||||
isFlashing,
|
isFlashing,
|
||||||
dragViz,
|
dragViz,
|
||||||
|
|
@ -154,6 +156,7 @@ export function WoTableRow({
|
||||||
sitePlace={sitePlace}
|
sitePlace={sitePlace}
|
||||||
search={search}
|
search={search}
|
||||||
forceScheduleHighlight={forceScheduleHighlight}
|
forceScheduleHighlight={forceScheduleHighlight}
|
||||||
|
forceDueDateHighlight={forceDueDateHighlight}
|
||||||
mutationCellStyle={mutationCellStyle}
|
mutationCellStyle={mutationCellStyle}
|
||||||
handlers={handlers}
|
handlers={handlers}
|
||||||
onPatchRow={patchRow}
|
onPatchRow={patchRow}
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ export function WoTable({
|
||||||
onOpenAdvancedFilters,
|
onOpenAdvancedFilters,
|
||||||
onPatch,
|
onPatch,
|
||||||
}: WoTableProps) {
|
}: WoTableProps) {
|
||||||
const [scheduleHighlightId, setScheduleHighlightId] = useState<string | number | null>(null);
|
const [dueDateHighlightId, setDueDateHighlightId] = useState<string | number | null>(null);
|
||||||
const [duplicateRow, setDuplicateRow] = useState<WorkOrderTableRow | null>(null);
|
const [duplicateRow, setDuplicateRow] = useState<WorkOrderTableRow | null>(null);
|
||||||
const { data: locationSites = [] } = useLocationSites();
|
const { data: locationSites = [] } = useLocationSites();
|
||||||
const sitePlaceLookups = useMemo(() => buildSitePlaceLookups(locationSites), [locationSites]);
|
const sitePlaceLookups = useMemo(() => buildSitePlaceLookups(locationSites), [locationSites]);
|
||||||
|
|
@ -73,7 +73,7 @@ export function WoTable({
|
||||||
onOpenCompDoc,
|
onOpenCompDoc,
|
||||||
onOpenMedia,
|
onOpenMedia,
|
||||||
onPatch,
|
onPatch,
|
||||||
onScheduleHighlight: setScheduleHighlightId,
|
onScheduleHighlight: setDueDateHighlightId,
|
||||||
onDuplicateFound: setDuplicateRow,
|
onDuplicateFound: setDuplicateRow,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -88,7 +88,8 @@ export function WoTable({
|
||||||
vendors={vendors}
|
vendors={vendors}
|
||||||
pmTypes={pmTypes}
|
pmTypes={pmTypes}
|
||||||
search={tableData.search}
|
search={tableData.search}
|
||||||
forceScheduleHighlight={forceScheduleHighlight || scheduleHighlightId === row.id}
|
forceScheduleHighlight={forceScheduleHighlight}
|
||||||
|
forceDueDateHighlight={dueDateHighlightId === row.id}
|
||||||
isSelected={tableData.selectedId === row.id}
|
isSelected={tableData.selectedId === row.id}
|
||||||
isFlashing={tableData.flashId === row.id}
|
isFlashing={tableData.flashId === row.id}
|
||||||
dragViz={tableData.dragViz}
|
dragViz={tableData.dragViz}
|
||||||
|
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal";
|
|
||||||
import { EMPTY_MEDIA, type MediaFile } from "@/domain/work-orders/types/work-order-media";
|
|
||||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
|
||||||
|
|
||||||
type WorkOrdersListMediaOverlayProps = {
|
|
||||||
activeMediaRow: WorkOrderTableRow | null;
|
|
||||||
onCloseMedia: () => void;
|
|
||||||
mediaFiles: Record<string, MediaFile[]>;
|
|
||||||
onMediaChange: (rowId: string | number, files: MediaFile[]) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function WorkOrdersListMediaOverlay({
|
|
||||||
activeMediaRow,
|
|
||||||
onCloseMedia,
|
|
||||||
mediaFiles,
|
|
||||||
onMediaChange,
|
|
||||||
}: WorkOrdersListMediaOverlayProps) {
|
|
||||||
if (activeMediaRow == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MediaUploadModal
|
|
||||||
row={activeMediaRow}
|
|
||||||
open={Boolean(activeMediaRow)}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) onCloseMedia();
|
|
||||||
}}
|
|
||||||
uploads={mediaFiles[String(activeMediaRow.id)] ?? EMPTY_MEDIA}
|
|
||||||
onChange={(files) => onMediaChange(activeMediaRow.id, files)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
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 { WorkOrdersListWizard } from "@/app/(protected)/workorders/_components/list/work-orders-list-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[];
|
||||||
|
existingRows: WorkOrderTableRow[];
|
||||||
|
onSelectWizardRow: (rowId: string | number) => void;
|
||||||
|
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,
|
||||||
|
existingRows,
|
||||||
|
onSelectWizardRow,
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<WorkOrdersListWizard
|
||||||
|
open={wizardOpen}
|
||||||
|
onOpenChange={onWizardOpenChange}
|
||||||
|
users={users}
|
||||||
|
rows={existingRows}
|
||||||
|
onSelectRow={onSelectWizardRow}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{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,32 +1,65 @@
|
||||||
import { type MouseEvent } from "react";
|
import { type MouseEvent } from "react";
|
||||||
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
|
import { Dialog, DialogContent, DialogTitle, Skeleton, Stack } from "@mui/material";
|
||||||
import { Image as ImageIcon } from "lucide-react";
|
import { Image as ImageIcon } from "lucide-react";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
||||||
|
import { LoadingRegion } from "@/components/ui/loading-region";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
|
import { MediaUploader } from "@/app/(protected)/workorders/_components/media/media-uploader";
|
||||||
|
import { useMediaUploadSession } from "@/app/(protected)/workorders/_hooks/use-media-upload-session";
|
||||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
import { MediaUploader } from "./media-uploader";
|
import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||||
|
import {
|
||||||
|
useDeleteWorkOrderMedia,
|
||||||
|
useUpdateWorkOrderMediaCategory,
|
||||||
|
useUploadWorkOrderMedia,
|
||||||
|
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
||||||
|
import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits";
|
||||||
|
import { guardDialogClose } from "@/lib/dialog-guard";
|
||||||
|
|
||||||
type MediaUploadModalProps = {
|
type MediaUploadModalProps = {
|
||||||
row: WorkOrderTableRow;
|
row: WorkOrderTableRow;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
uploads: MediaFile[];
|
|
||||||
onChange: (next: MediaFile[]) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function MediaUploadModal({
|
function resolveWorkOrderVersion(
|
||||||
row,
|
detailRowVersion: string | null | undefined,
|
||||||
open,
|
rowVersion: string | null | undefined,
|
||||||
onOpenChange,
|
): string | null {
|
||||||
uploads,
|
const version = detailRowVersion?.trim() || rowVersion?.trim() || "";
|
||||||
onChange,
|
return version || null;
|
||||||
}: MediaUploadModalProps) {
|
}
|
||||||
|
|
||||||
|
export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalProps) {
|
||||||
|
const { data: detail, isLoading } = useWorkOrderBoardDetail(row.id, open);
|
||||||
|
const uploadMutation = useUploadWorkOrderMedia(row.id);
|
||||||
|
const categorizeMutation = useUpdateWorkOrderMediaCategory(row.id);
|
||||||
|
const deleteMutation = useDeleteWorkOrderMedia(row.id);
|
||||||
|
const isReadOnly = isWorkOrderDocumentsReadOnly(detail?.info.status ?? row.status);
|
||||||
|
const { uploads, startUploads, retryUpload, removeLocal, isLocal } = useMediaUploadSession(
|
||||||
|
detail,
|
||||||
|
(input) => uploadMutation.mutateAsync(input),
|
||||||
|
);
|
||||||
|
|
||||||
|
const pending =
|
||||||
|
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 (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open={open}
|
open={open}
|
||||||
onClose={() => onOpenChange(false)}
|
onClose={handleClose}
|
||||||
maxWidth={false}
|
maxWidth={false}
|
||||||
slotProps={{
|
slotProps={{
|
||||||
...dialogBlurBackdropSlotProps,
|
...dialogBlurBackdropSlotProps,
|
||||||
|
|
@ -45,11 +78,42 @@ export function MediaUploadModal({
|
||||||
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>#{row.woNumber}</span>
|
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>#{row.woNumber}</span>
|
||||||
</div>
|
</div>
|
||||||
<Text className="sr-only" variant="description">
|
<Text className="sr-only" variant="description">
|
||||||
Upload photos and videos for this work order.
|
Upload photos and videos for this work order, then assign Before, After, or Extra.
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<MediaUploader uploads={uploads} onChange={onChange} />
|
{isLoading && !detail ? (
|
||||||
|
<LoadingRegion label="Loading media">
|
||||||
|
<Stack spacing={1.5}>
|
||||||
|
<Skeleton variant="rounded" height={100} animation="wave" />
|
||||||
|
<Skeleton variant="rounded" height={40} animation="wave" />
|
||||||
|
<Skeleton variant="rounded" height={40} animation="wave" />
|
||||||
|
</Stack>
|
||||||
|
</LoadingRegion>
|
||||||
|
) : (
|
||||||
|
<MediaUploader
|
||||||
|
uploads={uploads}
|
||||||
|
onUpload={startUploads}
|
||||||
|
onRemove={(id) => {
|
||||||
|
if (isLocal(id)) {
|
||||||
|
removeLocal(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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 });
|
||||||
|
}}
|
||||||
|
onRetry={retryUpload}
|
||||||
|
onCancel={removeLocal}
|
||||||
|
readOnly={isReadOnly}
|
||||||
|
pending={pending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-2 flex justify-end">
|
<div className="mt-2 flex justify-end">
|
||||||
|
|
@ -57,6 +121,7 @@ export function MediaUploadModal({
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="default"
|
size="default"
|
||||||
className="text-[12px]"
|
className="text-[12px]"
|
||||||
|
disabled={pending}
|
||||||
onClick={() => onOpenChange(false)}
|
onClick={() => onOpenChange(false)}
|
||||||
>
|
>
|
||||||
Close
|
Close
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
import type { RefObject } from "react";
|
||||||
|
import { Upload } from "lucide-react";
|
||||||
|
import {
|
||||||
|
formatMediaSizeLimit,
|
||||||
|
workOrderMediaAcceptAttribute,
|
||||||
|
} from "@/domain/work-orders/utils/work-order-media-limits";
|
||||||
|
|
||||||
|
type MediaUploaderDropzoneProps = {
|
||||||
|
inputRef: RefObject<HTMLInputElement | null>;
|
||||||
|
pending: boolean;
|
||||||
|
onFilesSelected: (files: FileList | null) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MediaUploaderDropzone({
|
||||||
|
inputRef,
|
||||||
|
pending,
|
||||||
|
onFilesSelected,
|
||||||
|
}: MediaUploaderDropzoneProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onFilesSelected(e.dataTransfer.files);
|
||||||
|
}}
|
||||||
|
className="flex w-full flex-col items-center justify-center gap-1.5 transition-colors hover:bg-[var(--color-bg-muted)] disabled:opacity-60"
|
||||||
|
style={{
|
||||||
|
minHeight: 120,
|
||||||
|
border: "1px dashed var(--color-border)",
|
||||||
|
borderRadius: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Upload size={20} style={{ color: "var(--color-text-muted)" }} />
|
||||||
|
<span style={{ fontSize: 13, color: "var(--color-text-muted)" }}>
|
||||||
|
{pending ? "Uploading…" : "Drag files here or click to browse"}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||||
|
Accepted: JPG, PNG, MP4, MOV · Max {formatMediaSizeLimit()} per file · categorize after
|
||||||
|
upload
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept={workOrderMediaAcceptAttribute()}
|
||||||
|
multiple
|
||||||
|
hidden
|
||||||
|
disabled={pending}
|
||||||
|
onChange={(e) => {
|
||||||
|
onFilesSelected(e.target.files);
|
||||||
|
e.target.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,113 @@
|
||||||
|
import { RotateCcw, X } from "lucide-react";
|
||||||
|
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||||
|
import { canDeleteWorkOrderMedia } from "@/domain/work-orders/utils/work-order-media-limits";
|
||||||
|
import { MediaUploaderGridItemPreview } from "./media-uploader-grid-item-preview";
|
||||||
|
|
||||||
|
type MediaUploaderGridItemChromeProps = {
|
||||||
|
file: MediaFile;
|
||||||
|
readOnly: boolean;
|
||||||
|
pending: boolean;
|
||||||
|
onRemove: (id: string) => void;
|
||||||
|
onRetry?: (id: string) => void;
|
||||||
|
onCancel?: (id: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MediaUploaderGridItemChrome({
|
||||||
|
file,
|
||||||
|
readOnly,
|
||||||
|
pending,
|
||||||
|
onRemove,
|
||||||
|
onRetry,
|
||||||
|
onCancel,
|
||||||
|
}: MediaUploaderGridItemChromeProps) {
|
||||||
|
const uploading = file.uploadStatus === "uploading";
|
||||||
|
const failed = file.uploadStatus === "failed";
|
||||||
|
const showDelete = !readOnly && canDeleteWorkOrderMedia(file);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative"
|
||||||
|
style={{
|
||||||
|
aspectRatio: "1 / 1",
|
||||||
|
background: "var(--color-bg-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MediaUploaderGridItemPreview file={file} />
|
||||||
|
{Boolean(uploading) && (
|
||||||
|
<div
|
||||||
|
className="absolute inset-x-0 bottom-0"
|
||||||
|
style={{ height: 4, background: "rgba(0,0,0,0.25)" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: "100%",
|
||||||
|
width: `${file.progress ?? 0}%`,
|
||||||
|
background: "var(--color-brand-primary, #2563eb)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{Boolean(uploading && onCancel) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onCancel?.(file.id)}
|
||||||
|
title="Cancel upload"
|
||||||
|
className="absolute flex items-center justify-center"
|
||||||
|
style={{
|
||||||
|
top: 4,
|
||||||
|
right: 4,
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
borderRadius: 4,
|
||||||
|
background: "var(--color-bg-surface)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={11} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{Boolean(failed && onRetry) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRetry?.(file.id)}
|
||||||
|
title="Retry upload"
|
||||||
|
className="absolute flex items-center justify-center"
|
||||||
|
style={{
|
||||||
|
top: 4,
|
||||||
|
left: 4,
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
borderRadius: 4,
|
||||||
|
background: "var(--color-bg-surface)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RotateCcw size={11} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{Boolean(showDelete && !uploading) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRemove(file.id)}
|
||||||
|
title="Remove"
|
||||||
|
disabled={pending}
|
||||||
|
className="absolute flex items-center justify-center transition-opacity hover:opacity-80 disabled:opacity-50"
|
||||||
|
style={{
|
||||||
|
top: 4,
|
||||||
|
right: 4,
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
borderRadius: 4,
|
||||||
|
background: "var(--color-bg-surface)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={11} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||||
|
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||||
|
|
||||||
|
const PURPOSE_OPTIONS: UploadableMediaCategory[] = ["Before", "After", "Extra"];
|
||||||
|
|
||||||
|
type MediaUploaderGridItemMetaProps = {
|
||||||
|
file: MediaFile;
|
||||||
|
readOnly: boolean;
|
||||||
|
pending: boolean;
|
||||||
|
onCategorize?: (id: string, category: UploadableMediaCategory) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MediaUploaderGridItemMeta({
|
||||||
|
file,
|
||||||
|
readOnly,
|
||||||
|
pending,
|
||||||
|
onCategorize,
|
||||||
|
}: MediaUploaderGridItemMetaProps) {
|
||||||
|
const failed = file.uploadStatus === "failed";
|
||||||
|
const needsCategory =
|
||||||
|
!readOnly &&
|
||||||
|
!file.isLegacy &&
|
||||||
|
file.uploadStatus !== "uploading" &&
|
||||||
|
file.uploadStatus !== "failed" &&
|
||||||
|
Boolean(onCategorize);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1 px-1 py-1">
|
||||||
|
<div className="truncate" style={{ fontSize: 10, color: "var(--color-text-muted)" }}>
|
||||||
|
{file.name}
|
||||||
|
</div>
|
||||||
|
{Boolean(failed) && (
|
||||||
|
<div style={{ fontSize: 10, color: "var(--color-danger, #b91c1c)" }}>
|
||||||
|
{file.errorMessage || "Upload failed"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{needsCategory ? (
|
||||||
|
<select
|
||||||
|
aria-label={`Category for ${file.name}`}
|
||||||
|
value={file.purpose ?? ""}
|
||||||
|
disabled={pending}
|
||||||
|
onChange={(event) => {
|
||||||
|
const value = event.target.value as UploadableMediaCategory;
|
||||||
|
if (value) onCategorize?.(file.id, value);
|
||||||
|
}}
|
||||||
|
className="w-full rounded border px-1 py-0.5 text-[10px]"
|
||||||
|
style={{
|
||||||
|
borderColor: "var(--color-border)",
|
||||||
|
color: "var(--color-text-primary)",
|
||||||
|
background: "var(--color-bg-surface)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">Assign purpose…</option>
|
||||||
|
{PURPOSE_OPTIONS.map((value) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{value}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<div style={{ fontSize: 10, color: "var(--color-text-muted)" }}>
|
||||||
|
{file.purpose || (file.isLegacy ? "Legacy" : "")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
import {
|
||||||
|
File as FileIcon,
|
||||||
|
FileText,
|
||||||
|
Film,
|
||||||
|
Image as ImageIcon,
|
||||||
|
Paperclip,
|
||||||
|
Play,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { FileCategory, MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||||
|
|
||||||
|
const FILETYPE_ICON: Record<FileCategory, typeof ImageIcon> = {
|
||||||
|
image: ImageIcon,
|
||||||
|
video: Film,
|
||||||
|
pdf: FileText,
|
||||||
|
doc: FileIcon,
|
||||||
|
other: Paperclip,
|
||||||
|
};
|
||||||
|
|
||||||
|
type MediaUploaderGridItemPreviewProps = {
|
||||||
|
file: MediaFile;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MediaUploaderGridItemPreview({ file }: MediaUploaderGridItemPreviewProps) {
|
||||||
|
const TypeIcon = FILETYPE_ICON[file.fileType];
|
||||||
|
|
||||||
|
if (file.fileType === "image") {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={file.url}
|
||||||
|
alt={file.name}
|
||||||
|
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.fileType === "video") {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<video src={file.url} style={{ width: "100%", height: "100%", objectFit: "cover" }} />
|
||||||
|
<span className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<Play size={18} style={{ color: "#fff" }} />
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<TypeIcon size={18} style={{ color: "var(--color-text-muted)" }} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||||
|
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||||
|
import { MediaUploaderGridItemChrome } from "./media-uploader-grid-item-chrome";
|
||||||
|
import { MediaUploaderGridItemMeta } from "./media-uploader-grid-item-meta";
|
||||||
|
|
||||||
|
type MediaUploaderGridItemProps = {
|
||||||
|
file: MediaFile;
|
||||||
|
readOnly: boolean;
|
||||||
|
pending: boolean;
|
||||||
|
onRemove: (id: string) => void;
|
||||||
|
onCategorize?: (id: string, category: UploadableMediaCategory) => void;
|
||||||
|
onRetry?: (id: string) => void;
|
||||||
|
onCancel?: (id: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MediaUploaderGridItem({
|
||||||
|
file,
|
||||||
|
readOnly,
|
||||||
|
pending,
|
||||||
|
onRemove,
|
||||||
|
onCategorize,
|
||||||
|
onRetry,
|
||||||
|
onCancel,
|
||||||
|
}: MediaUploaderGridItemProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="overflow-hidden rounded-lg border"
|
||||||
|
style={{ borderColor: "var(--color-border)" }}
|
||||||
|
>
|
||||||
|
<MediaUploaderGridItemChrome
|
||||||
|
file={file}
|
||||||
|
readOnly={readOnly}
|
||||||
|
pending={pending}
|
||||||
|
onRemove={onRemove}
|
||||||
|
onRetry={onRetry}
|
||||||
|
onCancel={onCancel}
|
||||||
|
/>
|
||||||
|
<MediaUploaderGridItemMeta
|
||||||
|
file={file}
|
||||||
|
readOnly={readOnly}
|
||||||
|
pending={pending}
|
||||||
|
onCategorize={onCategorize}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||||
|
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||||
|
import { MediaUploaderGridItem } from "./media-uploader-grid-item";
|
||||||
|
|
||||||
|
type MediaUploaderGridProps = {
|
||||||
|
uploads: MediaFile[];
|
||||||
|
readOnly: boolean;
|
||||||
|
pending: boolean;
|
||||||
|
onRemove: (id: string) => void;
|
||||||
|
onCategorize?: (id: string, category: UploadableMediaCategory) => void;
|
||||||
|
onRetry?: (id: string) => void;
|
||||||
|
onCancel?: (id: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MediaUploaderGrid({
|
||||||
|
uploads,
|
||||||
|
readOnly,
|
||||||
|
pending,
|
||||||
|
onRemove,
|
||||||
|
onCategorize,
|
||||||
|
onRetry,
|
||||||
|
onCancel,
|
||||||
|
}: MediaUploaderGridProps) {
|
||||||
|
if (uploads.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-3 grid grid-cols-3 gap-2">
|
||||||
|
{uploads.map((file) => (
|
||||||
|
<MediaUploaderGridItem
|
||||||
|
key={file.id}
|
||||||
|
file={file}
|
||||||
|
readOnly={readOnly}
|
||||||
|
pending={pending}
|
||||||
|
onRemove={onRemove}
|
||||||
|
onCategorize={onCategorize}
|
||||||
|
onRetry={onRetry}
|
||||||
|
onCancel={onCancel}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,143 +1,129 @@
|
||||||
import { useRef } from "react";
|
import { useRef, useState } from "react";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||||
|
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||||
import {
|
import {
|
||||||
File as FileIcon,
|
canDeleteWorkOrderMedia,
|
||||||
FileText,
|
formatMediaSizeLimit,
|
||||||
Film,
|
isWorkOrderFileTooLarge,
|
||||||
Image as ImageIcon,
|
isWorkOrderMediaTypeAllowed,
|
||||||
Paperclip,
|
} from "@/domain/work-orders/utils/work-order-media-limits";
|
||||||
Play,
|
|
||||||
Upload,
|
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
|
||||||
import type { FileCategory, MediaFile } from "@/domain/work-orders/types/work-order-media";
|
|
||||||
import { makeMediaFile } from "@/domain/work-orders/utils/work-order-media-utils";
|
import { makeMediaFile } from "@/domain/work-orders/utils/work-order-media-utils";
|
||||||
|
import { MediaUploaderDropzone } from "./media-uploader-dropzone";
|
||||||
|
import { MediaUploaderGrid } from "./media-uploader-grid";
|
||||||
|
|
||||||
const FILETYPE_ICON: Record<FileCategory, typeof ImageIcon> = {
|
export type MediaUploadRequest = {
|
||||||
image: ImageIcon,
|
localId: string;
|
||||||
video: Film,
|
file: File;
|
||||||
pdf: FileText,
|
signal: AbortSignal;
|
||||||
doc: FileIcon,
|
onProgress: (percent: number) => void;
|
||||||
other: Paperclip,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type MediaUploaderProps = {
|
type MediaUploaderProps = {
|
||||||
uploads: MediaFile[];
|
uploads: MediaFile[];
|
||||||
onChange: (next: MediaFile[]) => void;
|
/** Upload-then-categorize: files only — category assigned after persist. */
|
||||||
|
onUpload?: (requests: MediaUploadRequest[]) => void;
|
||||||
|
onRemove?: (id: string) => void;
|
||||||
|
onCategorize?: (id: string, category: UploadableMediaCategory) => void;
|
||||||
|
onRetry?: (localId: string, signal: AbortSignal) => void;
|
||||||
|
onCancel?: (localId: string) => void;
|
||||||
|
/** Legacy local-only mode (table modal fallback). */
|
||||||
|
onChange?: (next: MediaFile[]) => void;
|
||||||
|
readOnly?: boolean;
|
||||||
|
pending?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function MediaUploader({ uploads, onChange }: MediaUploaderProps) {
|
export function MediaUploader({
|
||||||
|
uploads,
|
||||||
|
onUpload,
|
||||||
|
onRemove,
|
||||||
|
onCategorize,
|
||||||
|
onRetry,
|
||||||
|
onCancel,
|
||||||
|
onChange,
|
||||||
|
readOnly = false,
|
||||||
|
pending = false,
|
||||||
|
}: MediaUploaderProps) {
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [controllers, setControllers] = useState<Record<string, AbortController>>({});
|
||||||
|
|
||||||
const addFiles = (files: FileList | null) => {
|
const addFiles = (files: FileList | null) => {
|
||||||
if (!files || files.length === 0) return;
|
if (readOnly || pending || !files || files.length === 0) return;
|
||||||
onChange([...uploads, ...Array.from(files).map(makeMediaFile)]);
|
|
||||||
|
const accepted: File[] = [];
|
||||||
|
for (const file of Array.from(files)) {
|
||||||
|
if (!isWorkOrderMediaTypeAllowed(file)) {
|
||||||
|
toast.error(`"${file.name}" is not a supported type (JPG, PNG, MP4, MOV).`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isWorkOrderFileTooLarge(file)) {
|
||||||
|
toast.error(`"${file.name}" exceeds the ${formatMediaSizeLimit()} limit.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
accepted.push(file);
|
||||||
|
}
|
||||||
|
if (accepted.length === 0) return;
|
||||||
|
|
||||||
|
if (onUpload) {
|
||||||
|
const nextControllers: Record<string, AbortController> = { ...controllers };
|
||||||
|
const requests: MediaUploadRequest[] = accepted.map((file) => {
|
||||||
|
const localId = `local-${crypto.randomUUID()}`;
|
||||||
|
const controller = new AbortController();
|
||||||
|
nextControllers[localId] = controller;
|
||||||
|
return {
|
||||||
|
localId,
|
||||||
|
file,
|
||||||
|
signal: controller.signal,
|
||||||
|
onProgress: () => undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setControllers(nextControllers);
|
||||||
|
onUpload(requests);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onChange) {
|
||||||
|
onChange([...uploads, ...accepted.map(makeMediaFile)]);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const remove = (id: string) => onChange(uploads.filter((u) => u.id !== id));
|
const remove = (id: string) => {
|
||||||
|
if (readOnly || pending) return;
|
||||||
|
const target = uploads.find((u) => u.id === id);
|
||||||
|
if (target && !canDeleteWorkOrderMedia(target) && target.uploadStatus !== "failed") return;
|
||||||
|
if (onRemove) {
|
||||||
|
onRemove(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onChange?.(uploads.filter((u) => u.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancel = (id: string) => {
|
||||||
|
controllers[id]?.abort();
|
||||||
|
onCancel?.(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const retry = (id: string) => {
|
||||||
|
controllers[id]?.abort();
|
||||||
|
const controller = new AbortController();
|
||||||
|
setControllers((prev) => ({ ...prev, [id]: controller }));
|
||||||
|
onRetry?.(id, controller.signal);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<button
|
{!readOnly && (
|
||||||
type="button"
|
<MediaUploaderDropzone inputRef={inputRef} pending={pending} onFilesSelected={addFiles} />
|
||||||
onClick={() => inputRef.current?.click()}
|
|
||||||
onDragOver={(e) => e.preventDefault()}
|
|
||||||
onDrop={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
addFiles(e.dataTransfer.files);
|
|
||||||
}}
|
|
||||||
className="flex w-full flex-col items-center justify-center gap-1.5 transition-colors hover:bg-[var(--color-bg-muted)]"
|
|
||||||
style={{
|
|
||||||
minHeight: 120,
|
|
||||||
border: "1px dashed var(--color-border)",
|
|
||||||
borderRadius: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Upload size={20} style={{ color: "var(--color-text-muted)" }} />
|
|
||||||
<span style={{ fontSize: 13, color: "var(--color-text-muted)" }}>
|
|
||||||
Drag files here or click to browse
|
|
||||||
</span>
|
|
||||||
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
|
|
||||||
Accepted: JPG, PNG, MP4, MOV · Max 50MB per file
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/*,video/*"
|
|
||||||
multiple
|
|
||||||
hidden
|
|
||||||
onChange={(e) => addFiles(e.target.files)}
|
|
||||||
/>
|
|
||||||
{uploads.length > 0 && (
|
|
||||||
<div className="mt-3 grid grid-cols-3 gap-2">
|
|
||||||
{uploads.map((u) => {
|
|
||||||
const TypeIcon = FILETYPE_ICON[u.fileType];
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={u.id}
|
|
||||||
className="overflow-hidden rounded-lg border"
|
|
||||||
style={{ borderColor: "var(--color-border)" }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="relative"
|
|
||||||
style={{
|
|
||||||
aspectRatio: "1 / 1",
|
|
||||||
background: "var(--color-bg-muted)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{u.fileType === "image" ? (
|
|
||||||
<img
|
|
||||||
src={u.url}
|
|
||||||
alt={u.name}
|
|
||||||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
|
||||||
/>
|
|
||||||
) : u.fileType === "video" ? (
|
|
||||||
<>
|
|
||||||
<video
|
|
||||||
src={u.url}
|
|
||||||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
|
||||||
/>
|
|
||||||
<span className="absolute inset-0 flex items-center justify-center">
|
|
||||||
<Play size={18} style={{ color: "#fff" }} />
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<span className="absolute inset-0 flex items-center justify-center">
|
|
||||||
<TypeIcon size={18} style={{ color: "var(--color-text-muted)" }} />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => remove(u.id)}
|
|
||||||
title="Remove"
|
|
||||||
className="absolute flex items-center justify-center transition-opacity hover:opacity-80"
|
|
||||||
style={{
|
|
||||||
top: 4,
|
|
||||||
right: 4,
|
|
||||||
width: 18,
|
|
||||||
height: 18,
|
|
||||||
borderRadius: 4,
|
|
||||||
background: "var(--color-bg-surface)",
|
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
color: "var(--color-text-muted)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<X size={11} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="px-1 py-1">
|
|
||||||
<div
|
|
||||||
className="truncate"
|
|
||||||
style={{ fontSize: 10, color: "var(--color-text-muted)" }}
|
|
||||||
>
|
|
||||||
{u.name}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
<MediaUploaderGrid
|
||||||
|
uploads={uploads}
|
||||||
|
readOnly={readOnly}
|
||||||
|
pending={pending}
|
||||||
|
onRemove={remove}
|
||||||
|
onCategorize={onCategorize}
|
||||||
|
onRetry={onRetry ? retry : undefined}
|
||||||
|
onCancel={cancel}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,107 @@
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Box, Button, Paper, Tab, Tabs } from "@mui/material";
|
||||||
|
import { CommentMentionComposer } from "@/components/common/comment-mention-composer";
|
||||||
|
import { CommentTimeline } from "@/components/common/comment-timeline";
|
||||||
|
import { mapWorkOrderCommentsToTimeline } from "@/components/common/comment-timeline-mappers";
|
||||||
|
import type {
|
||||||
|
WorkOrderAuditEntry,
|
||||||
|
WorkOrderComment,
|
||||||
|
} from "@/domain/work-orders/types/work-order-detail";
|
||||||
|
import { WorkOrderAuditTimeline } from "@/app/(protected)/workorders/_components/view/work-order-audit-timeline";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
|
||||||
|
export type WorkOrderActivityTab = "customer" | "internal" | "audit";
|
||||||
|
|
||||||
|
type MentionUser = { id: string | number; name: string };
|
||||||
|
|
||||||
|
type WorkOrderActivityPanelProps = {
|
||||||
|
customerComments: WorkOrderComment[];
|
||||||
|
internalComments: WorkOrderComment[];
|
||||||
|
auditLog: WorkOrderAuditEntry[];
|
||||||
|
mentionUsers: MentionUser[];
|
||||||
|
isSubmitting: boolean;
|
||||||
|
onSubmitComment: (text: string) => void | Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WorkOrderActivityPanel({
|
||||||
|
customerComments,
|
||||||
|
internalComments,
|
||||||
|
auditLog,
|
||||||
|
mentionUsers,
|
||||||
|
isSubmitting,
|
||||||
|
onSubmitComment,
|
||||||
|
}: WorkOrderActivityPanelProps) {
|
||||||
|
const [activeTab, setActiveTab] = useState<WorkOrderActivityTab>("customer");
|
||||||
|
const [commentText, setCommentText] = useState("");
|
||||||
|
|
||||||
|
const customerTimelineItems = useMemo(
|
||||||
|
() => mapWorkOrderCommentsToTimeline(customerComments),
|
||||||
|
[customerComments],
|
||||||
|
);
|
||||||
|
const internalTimelineItems = useMemo(
|
||||||
|
() => mapWorkOrderCommentsToTimeline(internalComments),
|
||||||
|
[internalComments],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper variant="outlined" className="p-4">
|
||||||
|
<Text as="p" variant="heading" className="mb-3 font-semibold">
|
||||||
|
Activity
|
||||||
|
</Text>
|
||||||
|
<Tabs
|
||||||
|
value={activeTab}
|
||||||
|
onChange={(_event, value: WorkOrderActivityTab) => setActiveTab(value)}
|
||||||
|
className="mb-3"
|
||||||
|
>
|
||||||
|
<Tab value="customer" label={`Customer (${customerComments.length})`} />
|
||||||
|
<Tab value="internal" label={`Internal (${internalComments.length})`} />
|
||||||
|
<Tab value="audit" label={`Audit Log (${auditLog.length})`} />
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{activeTab === "customer" && (
|
||||||
|
<CommentTimeline items={customerTimelineItems} mentionUsers={mentionUsers} />
|
||||||
|
)}
|
||||||
|
{activeTab === "internal" && (
|
||||||
|
<CommentTimeline items={internalTimelineItems} mentionUsers={mentionUsers} />
|
||||||
|
)}
|
||||||
|
{activeTab === "audit" && <WorkOrderAuditTimeline entries={auditLog} />}
|
||||||
|
|
||||||
|
{activeTab === "internal" && (
|
||||||
|
<Box
|
||||||
|
component="form"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const trimmed = commentText.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
await onSubmitComment(trimmed);
|
||||||
|
setCommentText("");
|
||||||
|
} catch {
|
||||||
|
// Mutation toast handles failure; keep the draft for retry.
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}}
|
||||||
|
className="mt-4 flex flex-col gap-2"
|
||||||
|
>
|
||||||
|
<CommentMentionComposer
|
||||||
|
value={commentText}
|
||||||
|
onChange={setCommentText}
|
||||||
|
candidates={mentionUsers}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
className="self-end"
|
||||||
|
disabled={isSubmitting || !commentText.trim()}
|
||||||
|
>
|
||||||
|
{isSubmitting ? "Sending..." : "Send"}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,113 @@
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionDetails,
|
||||||
|
AccordionSummary,
|
||||||
|
Button,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||||
|
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail";
|
||||||
|
import { formatDateTime, timeSince, waitTimeColor } from "@/lib/time-utils";
|
||||||
|
|
||||||
|
type WorkOrderDispatchesAccordionProps = {
|
||||||
|
wo: WorkOrderDetail;
|
||||||
|
onOpenDispatchDetail: (dispatchId: string | number) => void;
|
||||||
|
onOpenVendorEditor: (
|
||||||
|
event: React.MouseEvent,
|
||||||
|
vendorId: string | number,
|
||||||
|
vendorName: string,
|
||||||
|
) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WorkOrderDispatchesAccordion({
|
||||||
|
wo,
|
||||||
|
onOpenDispatchDetail,
|
||||||
|
onOpenVendorEditor,
|
||||||
|
}: WorkOrderDispatchesAccordionProps) {
|
||||||
|
return (
|
||||||
|
<Accordion defaultExpanded={Boolean(wo.dispatches.length)}>
|
||||||
|
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||||
|
<Typography sx={{ fontWeight: 600 }}>Dispatches ({wo.dispatches.length})</Typography>
|
||||||
|
</AccordionSummary>
|
||||||
|
<AccordionDetails>
|
||||||
|
{wo.dispatches.length ? (
|
||||||
|
<TableContainer>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Dispatch #</TableCell>
|
||||||
|
<TableCell>Vendor</TableCell>
|
||||||
|
<TableCell>NTE</TableCell>
|
||||||
|
<TableCell>Status</TableCell>
|
||||||
|
<TableCell>Date</TableCell>
|
||||||
|
<TableCell>Wait</TableCell>
|
||||||
|
<TableCell align="right">Actions</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{wo.dispatches.map((dispatch) => {
|
||||||
|
const waitColor = waitTimeColor(dispatch.dispatchedAt);
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={String(dispatch.id)}
|
||||||
|
hover
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => onOpenDispatchDetail(dispatch.id)}
|
||||||
|
>
|
||||||
|
<TableCell>{dispatch.dispatchNumber || dispatch.poNumber}</TableCell>
|
||||||
|
<TableCell>{dispatch.vendorName}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{dispatch.nteAmount != null ? `$${dispatch.nteAmount.toFixed(2)}` : "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{dispatch.status}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{dispatch.dispatchedAt ? formatDateTime(dispatch.dispatchedAt) : "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{dispatch.status === "Sent" ? (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
className={
|
||||||
|
waitColor === "green"
|
||||||
|
? "text-green-600"
|
||||||
|
: waitColor === "yellow"
|
||||||
|
? "text-yellow-600"
|
||||||
|
: "text-red-600"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{timeSince(dispatch.dispatchedAt)}
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={(event) =>
|
||||||
|
void onOpenVendorEditor(event, dispatch.vendorId, dispatch.vendorName)
|
||||||
|
}
|
||||||
|
disabled={dispatch.vendorId === ""}
|
||||||
|
>
|
||||||
|
Edit vendor
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
) : (
|
||||||
|
<Typography sx={{ color: "text.secondary" }}>No dispatches yet.</Typography>
|
||||||
|
)}
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,114 @@
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
Stack,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import type { VendorEditorState } from "./work-order-vendor-editor-types";
|
||||||
|
import { formatPhoneMask, isVendorEditorValid } from "./work-order-vendor-editor-utils";
|
||||||
|
|
||||||
|
type WorkOrderVendorEditorDialogProps = {
|
||||||
|
editor: VendorEditorState | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string;
|
||||||
|
isSaving: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: () => void;
|
||||||
|
onEditorChange: (
|
||||||
|
updater: (current: VendorEditorState | null) => VendorEditorState | null,
|
||||||
|
) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WorkOrderVendorEditorDialog({
|
||||||
|
editor,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
isSaving,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
onEditorChange,
|
||||||
|
}: WorkOrderVendorEditorDialogProps) {
|
||||||
|
return (
|
||||||
|
<Dialog open={editor !== null} onClose={onClose} fullWidth maxWidth="sm">
|
||||||
|
<DialogTitle>Edit assigned vendor</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Stack spacing={2} className="pt-2">
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{editor?.vendorName}
|
||||||
|
</Typography>
|
||||||
|
{Boolean(error) && <Alert severity="error">{error}</Alert>}
|
||||||
|
{loading ? (
|
||||||
|
<Box className="flex justify-center p-6">
|
||||||
|
<CircularProgress size={28} />
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<TextField
|
||||||
|
label="Technician name"
|
||||||
|
value={editor?.contactName ?? ""}
|
||||||
|
onChange={(event) =>
|
||||||
|
onEditorChange((current) =>
|
||||||
|
current ? { ...current, contactName: event.target.value } : current,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
slotProps={{ htmlInput: { maxLength: 200 } }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Phone"
|
||||||
|
value={editor?.phone ?? ""}
|
||||||
|
onChange={(event) =>
|
||||||
|
onEditorChange((current) =>
|
||||||
|
current ? { ...current, phone: formatPhoneMask(event.target.value) } : current,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
error={Boolean(editor?.phone && !/^\(\d{3}\) \d{3}-\d{4}$/.test(editor.phone))}
|
||||||
|
helperText="Use (XXX) XXX-XXXX"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
value={editor?.email ?? ""}
|
||||||
|
onChange={(event) =>
|
||||||
|
onEditorChange((current) =>
|
||||||
|
current ? { ...current, email: event.target.value } : current,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Notes"
|
||||||
|
multiline
|
||||||
|
minRows={3}
|
||||||
|
value={editor?.notes ?? ""}
|
||||||
|
onChange={(event) =>
|
||||||
|
onEditorChange((current) =>
|
||||||
|
current ? { ...current, notes: event.target.value } : current,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
slotProps={{ htmlInput: { maxLength: 2000 } }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={onClose} disabled={isSaving}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={!editor || loading || isSaving || !isVendorEditorValid(editor)}
|
||||||
|
>
|
||||||
|
{isSaving ? "Saving..." : "Save"}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||