Merge branch 'dev' into feature/wo-comment-mentions
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
|
||||||
|
|
||||||
|
|
|
||||||
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import type { WorkOrderTableDraft } from "@/domain/work-orders/types/work-order-
|
||||||
import { AssignedToCell } from "./cells/assigned-to-cell";
|
import { AssignedToCell } from "./cells/assigned-to-cell";
|
||||||
import { ApptTimeCell } from "./cells/appt-time-cell";
|
import { ApptTimeCell } from "./cells/appt-time-cell";
|
||||||
import { DateCell } from "./cells/date-cell";
|
import { DateCell } from "./cells/date-cell";
|
||||||
|
import { DueDateCell } from "./cells/due-date-cell";
|
||||||
import { EditableWONumberCell } from "./cells/editable-wo-number-cell";
|
import { EditableWONumberCell } from "./cells/editable-wo-number-cell";
|
||||||
import { PMTypeCell } from "./cells/pm-type-cell";
|
import { PMTypeCell } from "./cells/pm-type-cell";
|
||||||
import { SiteCell } from "./cells/site-cell";
|
import { SiteCell } from "./cells/site-cell";
|
||||||
|
|
@ -106,7 +107,11 @@ export function WoTableInlineRowCells({
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||||
<DateCell value={draft.dueDate} onSave={(date) => onPatch({ dueDate: date })} />
|
<DueDateCell
|
||||||
|
type={draft.type}
|
||||||
|
value={draft.dueDate}
|
||||||
|
onSave={(date) => onPatch({ dueDate: date })}
|
||||||
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||||
|
|
@ -129,6 +134,7 @@ export function WoTableInlineRowCells({
|
||||||
|
|
||||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||||
<VendorCell
|
<VendorCell
|
||||||
|
vendorId={draft.vendorId}
|
||||||
company={draft.company}
|
company={draft.company}
|
||||||
tech={draft.tech}
|
tech={draft.tech}
|
||||||
techPhone={draft.techPhone}
|
techPhone={draft.techPhone}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,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}
|
||||||
|
|
|
||||||
|
|
@ -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 { data: locationSites = [] } = useLocationSites();
|
const { data: locationSites = [] } = useLocationSites();
|
||||||
const sitePlaceLookups = useMemo(() => buildSitePlaceLookups(locationSites), [locationSites]);
|
const sitePlaceLookups = useMemo(() => buildSitePlaceLookups(locationSites), [locationSites]);
|
||||||
const createMutation = useCreateWorkOrderFromWizard(() => {
|
const createMutation = useCreateWorkOrderFromWizard(() => {
|
||||||
|
|
@ -69,7 +69,7 @@ export function WoTable({
|
||||||
onOpenCompDoc,
|
onOpenCompDoc,
|
||||||
onOpenMedia,
|
onOpenMedia,
|
||||||
onPatch,
|
onPatch,
|
||||||
onScheduleHighlight: setScheduleHighlightId,
|
onScheduleHighlight: setDueDateHighlightId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const renderRow = (row: WorkOrderTableRow, forceScheduleHighlight = false) => (
|
const renderRow = (row: WorkOrderTableRow, forceScheduleHighlight = false) => (
|
||||||
|
|
@ -83,7 +83,8 @@ export function WoTable({
|
||||||
vendors={vendors}
|
vendors={vendors}
|
||||||
pmTypes={pmTypes}
|
pmTypes={pmTypes}
|
||||||
search={tableData.search}
|
search={tableData.search}
|
||||||
forceScheduleHighlight={forceScheduleHighlight || scheduleHighlightId === row.id}
|
forceScheduleHighlight={forceScheduleHighlight}
|
||||||
|
forceDueDateHighlight={dueDateHighlightId === row.id}
|
||||||
isSelected={tableData.selectedId === row.id}
|
isSelected={tableData.selectedId === row.id}
|
||||||
isFlashing={tableData.flashId === row.id}
|
isFlashing={tableData.flashId === row.id}
|
||||||
dragViz={tableData.dragViz}
|
dragViz={tableData.dragViz}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { CalendarIcon, ChevronDown } from "lucide-react";
|
||||||
|
import { DueDateCalendarDialog } from "@/app/(protected)/workorders/_components/list/table/cells/due-date-calendar-dialog";
|
||||||
|
import { WizardDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-date-field";
|
||||||
|
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
import { fmtDate } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||||
|
|
||||||
|
type WizardDueDateFieldProps = {
|
||||||
|
type: WOType | "";
|
||||||
|
value: string;
|
||||||
|
placeholder: string;
|
||||||
|
onChange: (iso: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wizard Due Date editor.
|
||||||
|
* Emergency → dedicated calendar Dialog; other types → WizardDateField popover.
|
||||||
|
*/
|
||||||
|
export function WizardDueDateField({
|
||||||
|
type,
|
||||||
|
value,
|
||||||
|
placeholder,
|
||||||
|
onChange,
|
||||||
|
}: WizardDueDateFieldProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
if (type !== "Emergency") {
|
||||||
|
return <WizardDateField value={value} placeholder={placeholder} onChange={onChange} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className="flex w-full items-center gap-2 transition-colors"
|
||||||
|
style={{
|
||||||
|
height: 36,
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
borderRadius: 6,
|
||||||
|
background: "var(--color-bg-surface)",
|
||||||
|
padding: "0 10px",
|
||||||
|
outline: "none",
|
||||||
|
}}
|
||||||
|
onFocus={(e) => {
|
||||||
|
e.currentTarget.style.boxShadow = "0 0 0 2px var(--color-primary)";
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
e.currentTarget.style.boxShadow = "none";
|
||||||
|
}}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
>
|
||||||
|
<CalendarIcon size={16} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
|
||||||
|
<span
|
||||||
|
className="flex-1 truncate text-left"
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
fontSize: 13,
|
||||||
|
color: value ? "var(--foreground)" : "var(--muted-foreground)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value ? fmtDate(value) : placeholder}
|
||||||
|
</span>
|
||||||
|
<ChevronDown size={13} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
|
||||||
|
</button>
|
||||||
|
<DueDateCalendarDialog open={open} onOpenChange={setOpen} value={value} onSave={onChange} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ import { Text } from "@/components/ui/text";
|
||||||
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
import { WO_TYPE_META, WO_TYPES, type WOType } from "@/domain/work-orders/types/work-order-wizard";
|
import { WO_TYPE_META, WO_TYPES, type WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
import { WizardDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-date-field";
|
import { WizardDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-date-field";
|
||||||
|
import { WizardDueDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-due-date-field";
|
||||||
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
|
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
|
||||||
import {
|
import {
|
||||||
DLG_INPUT_CLS,
|
DLG_INPUT_CLS,
|
||||||
|
|
@ -197,7 +198,8 @@ export function WizardStepTypeSchedule({
|
||||||
>
|
>
|
||||||
Due Date
|
Due Date
|
||||||
</WizLabelRow>
|
</WizLabelRow>
|
||||||
<WizardDateField
|
<WizardDueDateField
|
||||||
|
type={draft.type}
|
||||||
value={draft.dueDate}
|
value={draft.dueDate}
|
||||||
placeholder="No due date"
|
placeholder="No due date"
|
||||||
onChange={(iso) => onUpdate("dueDate", iso)}
|
onChange={(iso) => onUpdate("dueDate", iso)}
|
||||||
|
|
|
||||||
|
|
@ -8,36 +8,18 @@ import {
|
||||||
formatVendorPortalDateTime,
|
formatVendorPortalDateTime,
|
||||||
vendorPortalStatusClass,
|
vendorPortalStatusClass,
|
||||||
} from "@/domain/vendor-portal/lib/status-helpers";
|
} from "@/domain/vendor-portal/lib/status-helpers";
|
||||||
import type { VendorPortalDispatchSummary } from "@/domain/vendor-portal/types/vendor-portal";
|
import {
|
||||||
|
VENDOR_PORTAL_DISPATCH_FILTERS,
|
||||||
|
applyVendorPortalDispatchFilter,
|
||||||
|
getVendorPortalDispatchFilter,
|
||||||
|
type VendorPortalDispatchFilterKey,
|
||||||
|
} from "@/domain/vendor-portal/lib/dispatch-filters";
|
||||||
import { queryKeys } from "@/infra/query-key/query-key";
|
import { queryKeys } from "@/infra/query-key/query-key";
|
||||||
|
|
||||||
const FILTERS = [
|
|
||||||
{
|
|
||||||
key: "open",
|
|
||||||
label: "Open",
|
|
||||||
match: (dispatch: VendorPortalDispatchSummary) =>
|
|
||||||
dispatch.status !== "Completed" &&
|
|
||||||
dispatch.status !== "Verified" &&
|
|
||||||
dispatch.status !== "Cancelled",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "completed",
|
|
||||||
label: "Completed",
|
|
||||||
match: (dispatch: VendorPortalDispatchSummary) =>
|
|
||||||
dispatch.status === "Completed" || dispatch.status === "Verified",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "cancelled",
|
|
||||||
label: "Cancelled",
|
|
||||||
match: (dispatch: VendorPortalDispatchSummary) => dispatch.status === "Cancelled",
|
|
||||||
},
|
|
||||||
{ key: "all", label: "All", match: () => true },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export default function VendorPortalDashboardPage() {
|
export default function VendorPortalDashboardPage() {
|
||||||
const { token } = useVendorPortal();
|
const { token } = useVendorPortal();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [filter, setFilter] = useState<(typeof FILTERS)[number]["key"]>("open");
|
const [filter, setFilter] = useState<VendorPortalDispatchFilterKey>("open");
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: dispatches = [],
|
data: dispatches = [],
|
||||||
|
|
@ -49,8 +31,11 @@ export default function VendorPortalDashboardPage() {
|
||||||
enabled: Boolean(token),
|
enabled: Boolean(token),
|
||||||
});
|
});
|
||||||
|
|
||||||
const activeFilter = FILTERS.find((item) => item.key === filter) ?? FILTERS[0];
|
const activeFilter = getVendorPortalDispatchFilter(filter);
|
||||||
const filtered = useMemo(() => dispatches.filter(activeFilter.match), [dispatches, activeFilter]);
|
const filtered = useMemo(
|
||||||
|
() => applyVendorPortalDispatchFilter(dispatches, activeFilter),
|
||||||
|
[dispatches, activeFilter],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -60,7 +45,7 @@ export default function VendorPortalDashboardPage() {
|
||||||
Active dispatches and work orders assigned to your company.
|
Active dispatches and work orders assigned to your company.
|
||||||
</div>
|
</div>
|
||||||
<div className="vp-actions" style={{ marginTop: 0 }}>
|
<div className="vp-actions" style={{ marginTop: 0 }}>
|
||||||
{FILTERS.map((item) => (
|
{VENDOR_PORTAL_DISPATCH_FILTERS.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.key}
|
key={item.key}
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,10 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import { DispatchDetailSections } from "@/app/v/[token]/dispatch/_components/dispatch-detail-sections";
|
import { DispatchDetailSections } from "@/app/v/[token]/dispatch/_components/dispatch-detail-sections";
|
||||||
import { DispatchSummaryCard } from "@/app/v/[token]/dispatch/_components/dispatch-summary-card";
|
import { DispatchSummaryCard } from "@/app/v/[token]/dispatch/_components/dispatch-summary-card";
|
||||||
|
import { RefuseDispatchDialog } from "@/app/v/[token]/dispatch/_components/refuse-dispatch-dialog";
|
||||||
import { useVendorPortal } from "@/app/v/_components/vendor-portal-context";
|
import { useVendorPortal } from "@/app/v/_components/vendor-portal-context";
|
||||||
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
|
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
|
||||||
|
import { isVendorPortalLockedStatus } from "@/domain/vendor-portal/lib/status-helpers";
|
||||||
import type { VendorPortalDispatchDetail } from "@/domain/vendor-portal/types/vendor-portal";
|
import type { VendorPortalDispatchDetail } from "@/domain/vendor-portal/types/vendor-portal";
|
||||||
import { queryKeys } from "@/infra/query-key/query-key";
|
import { queryKeys } from "@/infra/query-key/query-key";
|
||||||
|
|
||||||
|
|
@ -16,6 +18,7 @@ export default function VendorPortalDispatchPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [actionError, setActionError] = useState("");
|
const [actionError, setActionError] = useState("");
|
||||||
|
const [refuseOpen, setRefuseOpen] = useState(false);
|
||||||
|
|
||||||
const { data, isLoading, error, refetch } = useQuery({
|
const { data, isLoading, error, refetch } = useQuery({
|
||||||
queryKey: queryKeys.vendorPortal.dispatch(token, dispatchId),
|
queryKey: queryKeys.vendorPortal.dispatch(token, dispatchId),
|
||||||
|
|
@ -74,7 +77,7 @@ export default function VendorPortalDispatchPage() {
|
||||||
|
|
||||||
if (!data) return null;
|
if (!data) return null;
|
||||||
|
|
||||||
const locked = data.status === "Verified" || data.status === "Cancelled";
|
const locked = isVendorPortalLockedStatus(data.status);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -84,6 +87,7 @@ export default function VendorPortalDispatchPage() {
|
||||||
locked={locked}
|
locked={locked}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
onAccept={() => void runAction(() => vendorPortalApi.accept(token, dispatchId))}
|
onAccept={() => void runAction(() => vendorPortalApi.accept(token, dispatchId))}
|
||||||
|
onRefuse={() => setRefuseOpen(true)}
|
||||||
onStart={() =>
|
onStart={() =>
|
||||||
void runAction(() => vendorPortalApi.changeStatus(token, dispatchId, "In Progress"))
|
void runAction(() => vendorPortalApi.changeStatus(token, dispatchId, "In Progress"))
|
||||||
}
|
}
|
||||||
|
|
@ -107,6 +111,13 @@ export default function VendorPortalDispatchPage() {
|
||||||
refresh={refresh}
|
refresh={refresh}
|
||||||
updateLocal={updateLocal}
|
updateLocal={updateLocal}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<RefuseDispatchDialog
|
||||||
|
open={refuseOpen}
|
||||||
|
dispatchId={dispatchId}
|
||||||
|
onClose={() => setRefuseOpen(false)}
|
||||||
|
onRefused={() => setActionError("")}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,7 @@ export function DispatchDetailSections({
|
||||||
existing={data.signoffs ?? []}
|
existing={data.signoffs ?? []}
|
||||||
signoffType="customer"
|
signoffType="customer"
|
||||||
nameLabel="Customer name"
|
nameLabel="Customer name"
|
||||||
|
submissionLocked={locked}
|
||||||
onAdded={() => void refresh()}
|
onAdded={() => void refresh()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -88,6 +89,7 @@ export function DispatchDetailSections({
|
||||||
existing={data.signoffs ?? []}
|
existing={data.signoffs ?? []}
|
||||||
signoffType="vendor"
|
signoffType="vendor"
|
||||||
nameLabel="Your name"
|
nameLabel="Your name"
|
||||||
|
submissionLocked={locked}
|
||||||
onAdded={() => void refresh()}
|
onAdded={() => void refresh()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,166 @@
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { formatVendorPortalDateTime } from "@/domain/vendor-portal/lib/status-helpers";
|
||||||
|
import type { VendorPortalDispatchDetail } from "@/domain/vendor-portal/types/vendor-portal";
|
||||||
|
|
||||||
|
type DispatchSnapshotDetailsProps = {
|
||||||
|
data: VendorPortalDispatchDetail;
|
||||||
|
locked: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveCanonicalService(workOrder: VendorPortalDispatchDetail["workOrder"]): string {
|
||||||
|
if (workOrder?.service) {
|
||||||
|
return workOrder.service;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [workOrder?.trade, workOrder?.subTrade].filter(Boolean).join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function DispatchCommercialDetails({ data }: Pick<DispatchSnapshotDetailsProps, "data">) {
|
||||||
|
const service = resolveCanonicalService(data.workOrder);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
{data.workOrder != null && Boolean(data.workOrder.internalWONumber) && (
|
||||||
|
<div className="vp-row">
|
||||||
|
<strong>WO #</strong>
|
||||||
|
<span>{data.workOrder.internalWONumber}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{Boolean(service) && (
|
||||||
|
<div className="vp-row">
|
||||||
|
<strong>Service</strong>
|
||||||
|
<span>{service}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{Boolean(data.poNumber) && (
|
||||||
|
<div className="vp-row">
|
||||||
|
<strong>PO #</strong>
|
||||||
|
<span>{data.poNumber}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{data.nteAmount != null && (
|
||||||
|
<div className="vp-row">
|
||||||
|
<strong>NTE</strong>
|
||||||
|
<span>${Number(data.nteAmount).toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="vp-row">
|
||||||
|
<strong>Invoice</strong>
|
||||||
|
<span>
|
||||||
|
{data.invoiceStatus || "Not Submitted"}
|
||||||
|
{data.invoiceNumber ? ` · ${data.invoiceNumber}` : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="vp-row">
|
||||||
|
<strong>Payment</strong>
|
||||||
|
<span>{data.paymentStatus || "Unavailable"}</span>
|
||||||
|
</div>
|
||||||
|
{Boolean(data.scheduledDate) && (
|
||||||
|
<div className="vp-row">
|
||||||
|
<strong>Scheduled</strong>
|
||||||
|
<span>{formatVendorPortalDateTime(data.scheduledDate)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{Boolean(data.statusUpdatedAt) && (
|
||||||
|
<div className="vp-row">
|
||||||
|
<strong>Status updated</strong>
|
||||||
|
<span>{formatVendorPortalDateTime(data.statusUpdatedAt)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DispatchLocationDetails({ data }: Pick<DispatchSnapshotDetailsProps, "data">) {
|
||||||
|
if (data.location == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const address = [
|
||||||
|
data.location.address,
|
||||||
|
data.location.city,
|
||||||
|
data.location.state,
|
||||||
|
data.location.zipCode,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Text variant="heading" sx={{ marginTop: 2 }}>
|
||||||
|
Location
|
||||||
|
</Text>
|
||||||
|
{Boolean(data.workOrder?.siteCode) && (
|
||||||
|
<div className="vp-row">
|
||||||
|
<strong>Site code</strong>
|
||||||
|
<span>{data.workOrder?.siteCode}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>{data.location.name}</div>
|
||||||
|
<div className="vp-muted">{address}</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DispatcherContactDetails({ data }: Pick<DispatchSnapshotDetailsProps, "data">) {
|
||||||
|
const contact = data.dispatcherContact;
|
||||||
|
if (!contact || (!contact.name && !contact.email && !contact.phone)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Text variant="heading" sx={{ marginTop: 2 }}>
|
||||||
|
Dispatcher
|
||||||
|
</Text>
|
||||||
|
{Boolean(contact.name) && (
|
||||||
|
<div className="vp-row">
|
||||||
|
<strong>Name</strong>
|
||||||
|
<span>{contact.name}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{Boolean(contact.email) && (
|
||||||
|
<div className="vp-row">
|
||||||
|
<strong>Email</strong>
|
||||||
|
<span>
|
||||||
|
<a href={`mailto:${contact.email}`}>{contact.email}</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{Boolean(contact.phone) && (
|
||||||
|
<div className="vp-row">
|
||||||
|
<strong>Phone</strong>
|
||||||
|
<span>
|
||||||
|
<a href={`tel:${contact.phone}`}>{contact.phone}</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DispatchSnapshotDetails({ data, locked }: DispatchSnapshotDetailsProps) {
|
||||||
|
const description = data.description || data.workOrder?.description;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DispatchCommercialDetails data={data} />
|
||||||
|
<DispatchLocationDetails data={data} />
|
||||||
|
<DispatcherContactDetails data={data} />
|
||||||
|
{locked && (
|
||||||
|
<Text variant="feedback" className="vp-muted" sx={{ marginTop: 2 }}>
|
||||||
|
This dispatch is {data.status.toLowerCase()} and locked to further changes. Comments
|
||||||
|
remain open.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{Boolean(description) && (
|
||||||
|
<>
|
||||||
|
<Text variant="heading" sx={{ marginTop: 2 }}>
|
||||||
|
Description
|
||||||
|
</Text>
|
||||||
|
<div>{description}</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ type DispatchStatusActionsProps = {
|
||||||
locked: boolean;
|
locked: boolean;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
onAccept: () => void;
|
onAccept: () => void;
|
||||||
|
onRefuse: () => void;
|
||||||
onStart: () => void;
|
onStart: () => void;
|
||||||
onComplete: () => void;
|
onComplete: () => void;
|
||||||
onRequestCancel: () => void;
|
onRequestCancel: () => void;
|
||||||
|
|
@ -13,6 +14,7 @@ export function DispatchStatusActions({
|
||||||
locked,
|
locked,
|
||||||
busy,
|
busy,
|
||||||
onAccept,
|
onAccept,
|
||||||
|
onRefuse,
|
||||||
onStart,
|
onStart,
|
||||||
onComplete,
|
onComplete,
|
||||||
onRequestCancel,
|
onRequestCancel,
|
||||||
|
|
@ -20,9 +22,19 @@ export function DispatchStatusActions({
|
||||||
return (
|
return (
|
||||||
<div className="vp-actions">
|
<div className="vp-actions">
|
||||||
{status === "Sent" && (
|
{status === "Sent" && (
|
||||||
<button type="button" className="vp-btn vp-btn-success" disabled={busy} onClick={onAccept}>
|
<>
|
||||||
Accept Dispatch
|
<button
|
||||||
</button>
|
type="button"
|
||||||
|
className="vp-btn vp-btn-success"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={onAccept}
|
||||||
|
>
|
||||||
|
Accept Dispatch
|
||||||
|
</button>
|
||||||
|
<button type="button" className="vp-btn vp-btn-warn" disabled={busy} onClick={onRefuse}>
|
||||||
|
Refuse Dispatch
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{status === "Acknowledged" && (
|
{status === "Acknowledged" && (
|
||||||
<button type="button" className="vp-btn vp-btn-primary" disabled={busy} onClick={onStart}>
|
<button type="button" className="vp-btn vp-btn-primary" disabled={busy} onClick={onStart}>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import {
|
import { vendorPortalStatusClass } from "@/domain/vendor-portal/lib/status-helpers";
|
||||||
formatVendorPortalDateTime,
|
|
||||||
vendorPortalStatusClass,
|
|
||||||
} from "@/domain/vendor-portal/lib/status-helpers";
|
|
||||||
import type { VendorPortalDispatchDetail } from "@/domain/vendor-portal/types/vendor-portal";
|
import type { VendorPortalDispatchDetail } from "@/domain/vendor-portal/types/vendor-portal";
|
||||||
|
import { DispatchSnapshotDetails } from "@/app/v/[token]/dispatch/_components/dispatch-snapshot-details";
|
||||||
import { DispatchStatusActions } from "@/app/v/[token]/dispatch/_components/dispatch-status-actions";
|
import { DispatchStatusActions } from "@/app/v/[token]/dispatch/_components/dispatch-status-actions";
|
||||||
|
|
||||||
type DispatchSummaryCardProps = {
|
type DispatchSummaryCardProps = {
|
||||||
|
|
@ -13,6 +11,7 @@ type DispatchSummaryCardProps = {
|
||||||
locked: boolean;
|
locked: boolean;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
onAccept: () => void;
|
onAccept: () => void;
|
||||||
|
onRefuse: () => void;
|
||||||
onStart: () => void;
|
onStart: () => void;
|
||||||
onComplete: () => void;
|
onComplete: () => void;
|
||||||
onRequestCancel: () => void;
|
onRequestCancel: () => void;
|
||||||
|
|
@ -24,6 +23,7 @@ export function DispatchSummaryCard({
|
||||||
locked,
|
locked,
|
||||||
busy,
|
busy,
|
||||||
onAccept,
|
onAccept,
|
||||||
|
onRefuse,
|
||||||
onStart,
|
onStart,
|
||||||
onComplete,
|
onComplete,
|
||||||
onRequestCancel,
|
onRequestCancel,
|
||||||
|
|
@ -43,77 +43,14 @@ export function DispatchSummaryCard({
|
||||||
<span className={vendorPortalStatusClass(data.status)}>{data.status}</span>
|
<span className={vendorPortalStatusClass(data.status)}>{data.status}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginTop: 12 }}>
|
<DispatchSnapshotDetails data={data} locked={locked} />
|
||||||
{data.workOrder != null && Boolean(data.workOrder.internalWONumber) && (
|
|
||||||
<div className="vp-row">
|
|
||||||
<strong>WO #</strong>
|
|
||||||
<span>{data.workOrder.internalWONumber}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{Boolean(data.poNumber) && (
|
|
||||||
<div className="vp-row">
|
|
||||||
<strong>PO #</strong>
|
|
||||||
<span>{data.poNumber}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{data.nteAmount != null && (
|
|
||||||
<div className="vp-row">
|
|
||||||
<strong>NTE</strong>
|
|
||||||
<span>${Number(data.nteAmount).toFixed(2)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="vp-row">
|
|
||||||
<strong>Invoice</strong>
|
|
||||||
<span>
|
|
||||||
{data.invoiceStatus || "Not Submitted"}
|
|
||||||
{data.invoiceNumber ? ` · ${data.invoiceNumber}` : ""}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="vp-row">
|
|
||||||
<strong>Payment</strong>
|
|
||||||
<span>{data.paymentStatus || "Unavailable"}</span>
|
|
||||||
</div>
|
|
||||||
{Boolean(data.scheduledDate) && (
|
|
||||||
<div className="vp-row">
|
|
||||||
<strong>Scheduled</strong>
|
|
||||||
<span>{formatVendorPortalDateTime(data.scheduledDate)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{data.location != null && (
|
|
||||||
<>
|
|
||||||
<Text variant="heading" sx={{ marginTop: 2 }}>
|
|
||||||
Location
|
|
||||||
</Text>
|
|
||||||
<div>{data.location.name}</div>
|
|
||||||
<div className="vp-muted">
|
|
||||||
{[
|
|
||||||
data.location.address,
|
|
||||||
data.location.city,
|
|
||||||
data.location.state,
|
|
||||||
data.location.zipCode,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(", ")}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{Boolean(data.description || data.workOrder?.description) && (
|
|
||||||
<>
|
|
||||||
<Text variant="heading" sx={{ marginTop: 2 }}>
|
|
||||||
Description
|
|
||||||
</Text>
|
|
||||||
<div>{data.description || data.workOrder?.description}</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DispatchStatusActions
|
<DispatchStatusActions
|
||||||
status={data.status}
|
status={data.status}
|
||||||
locked={locked}
|
locked={locked}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
onAccept={onAccept}
|
onAccept={onAccept}
|
||||||
|
onRefuse={onRefuse}
|
||||||
onStart={onStart}
|
onStart={onStart}
|
||||||
onComplete={onComplete}
|
onComplete={onComplete}
|
||||||
onRequestCancel={onRequestCancel}
|
onRequestCancel={onRequestCancel}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,141 @@
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useForm, useWatch } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { useVendorPortal } from "@/app/v/_components/vendor-portal-context";
|
||||||
|
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
|
||||||
|
import {
|
||||||
|
REFUSE_REASON_MAX_LENGTH,
|
||||||
|
refuseDispatchSchema,
|
||||||
|
type RefuseDispatchValues,
|
||||||
|
} from "@/domain/vendor-portal/schemas/refuse-dispatch-schema";
|
||||||
|
import { queryKeys } from "@/infra/query-key/query-key";
|
||||||
|
import { guardDialogClose } from "@/lib/dialog-guard";
|
||||||
|
|
||||||
|
type RefuseDispatchDialogProps = {
|
||||||
|
open: boolean;
|
||||||
|
dispatchId: number;
|
||||||
|
onClose: () => void;
|
||||||
|
onRefused: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RefuseDispatchDialog({
|
||||||
|
open,
|
||||||
|
dispatchId,
|
||||||
|
onClose,
|
||||||
|
onRefused,
|
||||||
|
}: RefuseDispatchDialogProps) {
|
||||||
|
const { token } = useVendorPortal();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
control,
|
||||||
|
reset,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<RefuseDispatchValues>({
|
||||||
|
resolver: zodResolver(refuseDispatchSchema),
|
||||||
|
mode: "onChange",
|
||||||
|
defaultValues: { reason: "" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const reasonValue = useWatch({ control, name: "reason" }) ?? "";
|
||||||
|
const isOverLimit = reasonValue.length > REFUSE_REASON_MAX_LENGTH;
|
||||||
|
|
||||||
|
const refuseMutation = useMutation({
|
||||||
|
mutationFn: (values: RefuseDispatchValues) => {
|
||||||
|
const trimmed = values.reason.trim();
|
||||||
|
return vendorPortalApi.refuse(token, dispatchId, trimmed || undefined);
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.vendorPortal.dispatch(token, dispatchId),
|
||||||
|
}),
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.vendorPortal.dispatches(token),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
onRefused();
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { reset: resetMutation } = refuseMutation;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
reset({ reason: "" });
|
||||||
|
resetMutation();
|
||||||
|
}
|
||||||
|
}, [open, reset, resetMutation]);
|
||||||
|
|
||||||
|
const submit = handleSubmit((values) => refuseMutation.mutate(values));
|
||||||
|
const errorMessage = refuseMutation.error
|
||||||
|
? "Unable to refuse this dispatch. Please try again."
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={guardDialogClose(refuseMutation.isPending, onClose)}
|
||||||
|
aria-labelledby="refuse-dispatch-title"
|
||||||
|
>
|
||||||
|
<DialogTitle id="refuse-dispatch-title">Refuse dispatch</DialogTitle>
|
||||||
|
<form onSubmit={(event) => void submit(event)}>
|
||||||
|
<DialogContent>
|
||||||
|
<Text variant="body" className="vp-muted" sx={{ marginBottom: 2 }}>
|
||||||
|
Refusing ends your assignment for this dispatch. A reason is optional but helps your
|
||||||
|
dispatcher reassign the work.
|
||||||
|
</Text>
|
||||||
|
<label htmlFor="refuse-reason" className="vp-muted" style={{ fontSize: 13 }}>
|
||||||
|
Reason (optional)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="refuse-reason"
|
||||||
|
className="vp-textarea"
|
||||||
|
placeholder="Tell the dispatcher why you are refusing…"
|
||||||
|
aria-invalid={Boolean(errors.reason)}
|
||||||
|
aria-describedby={errors.reason ? "refuse-reason-error" : "refuse-reason-counter"}
|
||||||
|
maxLength={REFUSE_REASON_MAX_LENGTH}
|
||||||
|
{...register("reason")}
|
||||||
|
style={{ marginTop: 6 }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
id="refuse-reason-counter"
|
||||||
|
className="vp-muted"
|
||||||
|
style={{ fontSize: 12, textAlign: "right" }}
|
||||||
|
>
|
||||||
|
{reasonValue.length}/{REFUSE_REASON_MAX_LENGTH}
|
||||||
|
</div>
|
||||||
|
<Text variant="error" when={isOverLimit} id="refuse-reason-error">
|
||||||
|
{errors.reason?.message ?? "Reason must be 500 characters or fewer."}
|
||||||
|
</Text>
|
||||||
|
<Text variant="error" when={Boolean(errorMessage)}>
|
||||||
|
{errorMessage}
|
||||||
|
</Text>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="vp-btn vp-btn-ghost"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={refuseMutation.isPending}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="vp-btn vp-btn-warn"
|
||||||
|
disabled={refuseMutation.isPending || isOverLimit}
|
||||||
|
>
|
||||||
|
{refuseMutation.isPending ? "Refusing…" : "Refuse dispatch"}
|
||||||
|
</button>
|
||||||
|
</DialogActions>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,7 @@ type SignoffSectionProps = {
|
||||||
existing: VendorPortalSignoff[];
|
existing: VendorPortalSignoff[];
|
||||||
signoffType: string;
|
signoffType: string;
|
||||||
nameLabel: string;
|
nameLabel: string;
|
||||||
|
submissionLocked?: boolean;
|
||||||
onAdded: () => void;
|
onAdded: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -20,6 +21,7 @@ export function SignoffSection({
|
||||||
existing,
|
existing,
|
||||||
signoffType,
|
signoffType,
|
||||||
nameLabel,
|
nameLabel,
|
||||||
|
submissionLocked = false,
|
||||||
onAdded,
|
onAdded,
|
||||||
}: SignoffSectionProps) {
|
}: SignoffSectionProps) {
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
|
|
@ -50,6 +52,10 @@ export function SignoffSection({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (submissionLocked) {
|
||||||
|
return <Text variant="feedback">Signoff is unavailable for this dispatch.</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
const submit = async (event: React.FormEvent) => {
|
const submit = async (event: React.FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const signature = method === "drawn" ? drawnSig : typedSig;
|
const signature = method === "drawn" ? drawnSig : typedSig;
|
||||||
|
|
|
||||||
|
|
@ -8,32 +8,14 @@ import {
|
||||||
formatVendorPortalDateTime,
|
formatVendorPortalDateTime,
|
||||||
vendorPortalStatusClass,
|
vendorPortalStatusClass,
|
||||||
} from "@/domain/vendor-portal/lib/status-helpers";
|
} from "@/domain/vendor-portal/lib/status-helpers";
|
||||||
import type { VendorPortalDispatchSummary } from "@/domain/vendor-portal/types/vendor-portal";
|
import {
|
||||||
|
VENDOR_PORTAL_DISPATCH_FILTERS,
|
||||||
|
applyVendorPortalDispatchFilter,
|
||||||
|
getVendorPortalDispatchFilter,
|
||||||
|
type VendorPortalDispatchFilterKey,
|
||||||
|
} from "@/domain/vendor-portal/lib/dispatch-filters";
|
||||||
import { queryKeys } from "@/infra/query-key/query-key";
|
import { queryKeys } from "@/infra/query-key/query-key";
|
||||||
|
|
||||||
const FILTERS = [
|
|
||||||
{
|
|
||||||
key: "open",
|
|
||||||
label: "Open",
|
|
||||||
match: (dispatch: VendorPortalDispatchSummary) =>
|
|
||||||
dispatch.status !== "Completed" &&
|
|
||||||
dispatch.status !== "Verified" &&
|
|
||||||
dispatch.status !== "Cancelled",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "completed",
|
|
||||||
label: "Completed",
|
|
||||||
match: (dispatch: VendorPortalDispatchSummary) =>
|
|
||||||
dispatch.status === "Completed" || dispatch.status === "Verified",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "cancelled",
|
|
||||||
label: "Cancelled",
|
|
||||||
match: (dispatch: VendorPortalDispatchSummary) => dispatch.status === "Cancelled",
|
|
||||||
},
|
|
||||||
{ key: "all", label: "All", match: () => true },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
function formatDate(value?: string) {
|
function formatDate(value?: string) {
|
||||||
if (!value) return "—";
|
if (!value) return "—";
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
|
|
@ -49,7 +31,7 @@ function formatMoney(value?: number) {
|
||||||
export default function VendorPortalPosPage() {
|
export default function VendorPortalPosPage() {
|
||||||
const { token } = useVendorPortal();
|
const { token } = useVendorPortal();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [filter, setFilter] = useState<(typeof FILTERS)[number]["key"]>("open");
|
const [filter, setFilter] = useState<VendorPortalDispatchFilterKey>("open");
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|
@ -62,12 +44,11 @@ export default function VendorPortalPosPage() {
|
||||||
enabled: Boolean(token),
|
enabled: Boolean(token),
|
||||||
});
|
});
|
||||||
|
|
||||||
const activeFilter = FILTERS.find((item) => item.key === filter) ?? FILTERS[0];
|
const activeFilter = getVendorPortalDispatchFilter(filter);
|
||||||
|
|
||||||
const visible = useMemo(() => {
|
const visible = useMemo(() => {
|
||||||
const needle = search.trim().toLowerCase();
|
const needle = search.trim().toLowerCase();
|
||||||
return dispatches.filter((dispatch) => {
|
return applyVendorPortalDispatchFilter(dispatches, activeFilter).filter((dispatch) => {
|
||||||
if (!activeFilter.match(dispatch)) return false;
|
|
||||||
if (!needle) return true;
|
if (!needle) return true;
|
||||||
return (
|
return (
|
||||||
(dispatch.poNumber || "").toLowerCase().includes(needle) ||
|
(dispatch.poNumber || "").toLowerCase().includes(needle) ||
|
||||||
|
|
@ -90,7 +71,7 @@ export default function VendorPortalPosPage() {
|
||||||
className="vp-actions"
|
className="vp-actions"
|
||||||
style={{ marginTop: 0, gap: 12, alignItems: "center", flexWrap: "wrap" }}
|
style={{ marginTop: 0, gap: 12, alignItems: "center", flexWrap: "wrap" }}
|
||||||
>
|
>
|
||||||
{FILTERS.map((item) => (
|
{VENDOR_PORTAL_DISPATCH_FILTERS.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.key}
|
key={item.key}
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -138,6 +138,10 @@
|
||||||
background: var(--doc-no-bg);
|
background: var(--doc-no-bg);
|
||||||
color: var(--doc-no-text);
|
color: var(--doc-no-text);
|
||||||
}
|
}
|
||||||
|
.vp-status-refused {
|
||||||
|
background: var(--doc-no-bg);
|
||||||
|
color: var(--doc-no-text);
|
||||||
|
}
|
||||||
|
|
||||||
.vp-btn {
|
.vp-btn {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|
|
||||||
|
|
@ -32,9 +32,10 @@ function getNavItemAppearance(isActive: boolean, isHover: boolean) {
|
||||||
type NavItemProps = NavItemDef & {
|
type NavItemProps = NavItemDef & {
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
|
onNavigate?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function NavItem({ label, icon: Icon, path, collapsed, active, badge }: NavItemProps) {
|
function NavItem({ label, icon: Icon, path, collapsed, active, badge, onNavigate }: NavItemProps) {
|
||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
const isHover = hovered && !active;
|
const isHover = hovered && !active;
|
||||||
const { itemColor, iconColor, itemBg } = getNavItemAppearance(active, isHover);
|
const { itemColor, iconColor, itemBg } = getNavItemAppearance(active, isHover);
|
||||||
|
|
@ -45,6 +46,7 @@ function NavItem({ label, icon: Icon, path, collapsed, active, badge }: NavItemP
|
||||||
aria-current={active ? "page" : undefined}
|
aria-current={active ? "page" : undefined}
|
||||||
className="no-underline"
|
className="no-underline"
|
||||||
title={collapsed ? label : undefined}
|
title={collapsed ? label : undefined}
|
||||||
|
onClick={onNavigate}
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
onMouseEnter={() => setHovered(true)}
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
|
@ -118,18 +120,24 @@ function NavItem({ label, icon: Icon, path, collapsed, active, badge }: NavItemP
|
||||||
|
|
||||||
type AppSidebarProps = {
|
type AppSidebarProps = {
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
|
mobile?: boolean;
|
||||||
|
open?: boolean;
|
||||||
|
onClose?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AppSidebar({ collapsed }: AppSidebarProps) {
|
export function AppSidebar({ collapsed, mobile = false, open = true, onClose }: AppSidebarProps) {
|
||||||
const width = getSidebarWidth(collapsed);
|
const width = getSidebarWidth(collapsed);
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const activePath = getActiveNavPath(pathname);
|
const activePath = getActiveNavPath(pathname);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer
|
<Drawer
|
||||||
variant="permanent"
|
variant={mobile ? "temporary" : "permanent"}
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
ModalProps={{ keepMounted: true }}
|
||||||
sx={{
|
sx={{
|
||||||
width,
|
width: mobile ? 0 : width,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
"& .MuiDrawer-paper": {
|
"& .MuiDrawer-paper": {
|
||||||
width,
|
width,
|
||||||
|
|
@ -157,7 +165,13 @@ export function AppSidebar({ collapsed }: AppSidebarProps) {
|
||||||
{collapsed ? <BrandMark size="compact" /> : <BrandLockup size="compact" />}
|
{collapsed ? <BrandMark size="compact" /> : <BrandLockup size="compact" />}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box component="nav" className="flex-1 overflow-y-auto overflow-x-hidden" sx={{ py: 1 }}>
|
<Box
|
||||||
|
component="nav"
|
||||||
|
id="primary-navigation"
|
||||||
|
aria-label="Primary navigation"
|
||||||
|
className="flex-1 overflow-y-auto overflow-x-hidden"
|
||||||
|
sx={{ py: 1 }}
|
||||||
|
>
|
||||||
{navGroups.map((group, groupIndex) => (
|
{navGroups.map((group, groupIndex) => (
|
||||||
<Box key={group.title || `group-${groupIndex}`}>
|
<Box key={group.title || `group-${groupIndex}`}>
|
||||||
{collapsed ? (
|
{collapsed ? (
|
||||||
|
|
@ -198,6 +212,7 @@ export function AppSidebar({ collapsed }: AppSidebarProps) {
|
||||||
badge={badge}
|
badge={badge}
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
active={path === activePath}
|
active={path === activePath}
|
||||||
|
onNavigate={mobile ? onClose : undefined}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -24,15 +24,16 @@ export function PageHeader({
|
||||||
}: PageHeaderProps) {
|
}: PageHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<Stack
|
<Stack
|
||||||
direction="row"
|
direction={{ xs: "column", md: "row" }}
|
||||||
className={cn(
|
className={cn(
|
||||||
surface &&
|
surface &&
|
||||||
"-mx-2.5 -mt-2.5 shrink-0 border-b border-(--color-border) bg-(--color-bg-surface) px-5 py-4",
|
"-mx-2.5 -mt-2.5 shrink-0 border-b border-(--color-border) bg-(--color-bg-surface) px-5 py-4",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
sx={{
|
sx={{
|
||||||
alignItems: "center",
|
alignItems: { xs: "stretch", md: "center" },
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
|
gap: { xs: 2, md: 0 },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stack spacing={0}>
|
<Stack spacing={0}>
|
||||||
|
|
@ -49,15 +50,31 @@ export function PageHeader({
|
||||||
"m-0 font-display mt-1! text-[30px]! font-extrabold leading-[1.1] tracking-[-0.02em] text-foreground",
|
"m-0 font-display mt-1! text-[30px]! font-extrabold leading-[1.1] tracking-[-0.02em] text-foreground",
|
||||||
eyebrow && "mt-1",
|
eyebrow && "mt-1",
|
||||||
)}
|
)}
|
||||||
|
sx={{ fontSize: 30, fontWeight: 800, lineHeight: 1.1, letterSpacing: "-0.02em" }}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
<Text variant="description" tone="muted" when={Boolean(subtitle)}>
|
<Text
|
||||||
|
variant="description"
|
||||||
|
as="span"
|
||||||
|
tone="muted"
|
||||||
|
when={Boolean(subtitle)}
|
||||||
|
sx={{ mt: 0.75, fontWeight: 500 }}
|
||||||
|
>
|
||||||
{subtitle}
|
{subtitle}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
{Boolean(actions) && (
|
{Boolean(actions) && (
|
||||||
<Stack direction="row" spacing={1} sx={{ alignItems: "center", flexWrap: "nowrap" }}>
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
spacing={1}
|
||||||
|
useFlexGap
|
||||||
|
sx={{
|
||||||
|
alignItems: "center",
|
||||||
|
flexWrap: { xs: "wrap", md: "nowrap" },
|
||||||
|
width: { xs: "100%", md: "auto" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
{actions}
|
{actions}
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
FileText,
|
FileText,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
|
MapPin,
|
||||||
Bell,
|
Bell,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Star,
|
Star,
|
||||||
|
|
@ -52,7 +53,10 @@ export const navGroups: NavGroup[] = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "CUSTOMERS & SITES",
|
title: "CUSTOMERS & SITES",
|
||||||
items: [{ label: "Accounts", icon: BriefcaseBusiness, path: "/accounts" }],
|
items: [
|
||||||
|
{ label: "Accounts", icon: BriefcaseBusiness, path: "/accounts" },
|
||||||
|
{ label: "Sites", icon: MapPin, path: "/locations" },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
// {
|
// {
|
||||||
// title: "INTERNAL",
|
// title: "INTERNAL",
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,16 @@ export const vendorPortalApi = {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
refuse: (token: string, id: string | number, reason?: string) =>
|
||||||
|
vendorPortalCall<{ id: number; status: string; refusedAt?: string }>(
|
||||||
|
token,
|
||||||
|
`${API_PATHS.vendorPortal.dispatches}/${id}/refuse`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: reason ? { reason } : {},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
changeStatus: (token: string, id: string | number, status: string) =>
|
changeStatus: (token: string, id: string | number, status: string) =>
|
||||||
vendorPortalCall<void>(token, `${API_PATHS.vendorPortal.dispatches}/${id}/status`, {
|
vendorPortalCall<void>(token, `${API_PATHS.vendorPortal.dispatches}/${id}/status`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
|
||||||
71
src/domain/vendor-portal/lib/dispatch-filters.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
import type { VendorPortalDispatchSummary } from "@/domain/vendor-portal/types/vendor-portal";
|
||||||
|
import {
|
||||||
|
isVendorPortalTerminalStatus,
|
||||||
|
isVendorPortalUpcomingDispatch,
|
||||||
|
} from "@/domain/vendor-portal/lib/status-helpers";
|
||||||
|
|
||||||
|
export interface VendorPortalDispatchFilter {
|
||||||
|
key: VendorPortalDispatchFilterKey;
|
||||||
|
label: string;
|
||||||
|
match: (dispatch: VendorPortalDispatchSummary) => boolean;
|
||||||
|
sort?: (dispatches: VendorPortalDispatchSummary[]) => VendorPortalDispatchSummary[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VendorPortalDispatchFilterKey =
|
||||||
|
"open" | "upcoming" | "completed" | "refused" | "cancelled" | "all";
|
||||||
|
|
||||||
|
const sortUpcomingByScheduledDateAscending = (
|
||||||
|
dispatches: VendorPortalDispatchSummary[],
|
||||||
|
): VendorPortalDispatchSummary[] =>
|
||||||
|
[...dispatches].sort((left, right) => {
|
||||||
|
const leftTime = left.scheduledDate ? new Date(left.scheduledDate).getTime() : 0;
|
||||||
|
const rightTime = right.scheduledDate ? new Date(right.scheduledDate).getTime() : 0;
|
||||||
|
return leftTime - rightTime;
|
||||||
|
});
|
||||||
|
|
||||||
|
export const VENDOR_PORTAL_DISPATCH_FILTERS: readonly VendorPortalDispatchFilter[] = [
|
||||||
|
{
|
||||||
|
key: "open",
|
||||||
|
label: "Open",
|
||||||
|
match: (dispatch) => !isVendorPortalTerminalStatus(dispatch.status),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "upcoming",
|
||||||
|
label: "Upcoming",
|
||||||
|
match: (dispatch) => isVendorPortalUpcomingDispatch(dispatch),
|
||||||
|
sort: sortUpcomingByScheduledDateAscending,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "completed",
|
||||||
|
label: "Completed",
|
||||||
|
match: (dispatch) => dispatch.status === "Completed" || dispatch.status === "Verified",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "refused",
|
||||||
|
label: "Refused",
|
||||||
|
match: (dispatch) => dispatch.status === "Refused",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "cancelled",
|
||||||
|
label: "Cancelled",
|
||||||
|
match: (dispatch) => dispatch.status === "Cancelled",
|
||||||
|
},
|
||||||
|
{ key: "all", label: "All", match: () => true },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function getVendorPortalDispatchFilter(
|
||||||
|
key: VendorPortalDispatchFilterKey,
|
||||||
|
): VendorPortalDispatchFilter {
|
||||||
|
return (
|
||||||
|
VENDOR_PORTAL_DISPATCH_FILTERS.find((filter) => filter.key === key) ??
|
||||||
|
VENDOR_PORTAL_DISPATCH_FILTERS[0]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyVendorPortalDispatchFilter(
|
||||||
|
dispatches: VendorPortalDispatchSummary[],
|
||||||
|
filter: VendorPortalDispatchFilter,
|
||||||
|
): VendorPortalDispatchSummary[] {
|
||||||
|
const matched = dispatches.filter(filter.match);
|
||||||
|
return filter.sort ? filter.sort(matched) : matched;
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ export function vendorPortalStatusClass(status: string): string {
|
||||||
Completed: "vp-status-completed",
|
Completed: "vp-status-completed",
|
||||||
Verified: "vp-status-verified",
|
Verified: "vp-status-verified",
|
||||||
Cancelled: "vp-status-cancelled",
|
Cancelled: "vp-status-cancelled",
|
||||||
|
Refused: "vp-status-refused",
|
||||||
};
|
};
|
||||||
|
|
||||||
return `vp-status-pill ${map[status] ?? ""}`;
|
return `vp-status-pill ${map[status] ?? ""}`;
|
||||||
|
|
@ -23,3 +24,34 @@ export function formatVendorPortalDateTime(value?: string): string {
|
||||||
|
|
||||||
return date.toLocaleString();
|
return date.toLocaleString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TERMINAL_STATUSES = new Set(["Completed", "Verified", "Cancelled", "Refused"]);
|
||||||
|
const LOCKED_STATUSES = new Set(["Verified", "Cancelled", "Refused"]);
|
||||||
|
|
||||||
|
export function isVendorPortalTerminalStatus(status: string): boolean {
|
||||||
|
return TERMINAL_STATUSES.has(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isVendorPortalLockedStatus(status: string): boolean {
|
||||||
|
return LOCKED_STATUSES.has(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isVendorPortalUpcomingDispatch(
|
||||||
|
dispatch: { status: string; scheduledDate?: string },
|
||||||
|
now: Date = new Date(),
|
||||||
|
): boolean {
|
||||||
|
if (isVendorPortalTerminalStatus(dispatch.status)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dispatch.scheduledDate) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduled = new Date(dispatch.scheduledDate);
|
||||||
|
if (Number.isNaN(scheduled.getTime())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return scheduled.getTime() >= now.getTime();
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const REFUSE_REASON_MAX_LENGTH = 500;
|
||||||
|
|
||||||
|
export const refuseDispatchSchema = z.object({
|
||||||
|
reason: z.string().max(REFUSE_REASON_MAX_LENGTH, "Reason must be 500 characters or fewer."),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type RefuseDispatchValues = z.infer<typeof refuseDispatchSchema>;
|
||||||
|
|
@ -20,6 +20,7 @@ export interface VendorPortalDispatchSummary {
|
||||||
nteAmount?: number;
|
nteAmount?: number;
|
||||||
dispatchedAt?: string;
|
dispatchedAt?: string;
|
||||||
completedDate?: string;
|
completedDate?: string;
|
||||||
|
refusedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VendorPortalDocument {
|
export interface VendorPortalDocument {
|
||||||
|
|
@ -73,15 +74,25 @@ export interface VendorPortalSignoff {
|
||||||
signedAt?: string;
|
signedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VendorPortalDispatcherContact {
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface VendorPortalDispatchDetail extends VendorPortalDispatchSummary {
|
export interface VendorPortalDispatchDetail extends VendorPortalDispatchSummary {
|
||||||
description?: string;
|
description?: string;
|
||||||
acknowledgedAt?: string;
|
acknowledgedAt?: string;
|
||||||
|
statusUpdatedAt?: string;
|
||||||
|
dispatcherContact?: VendorPortalDispatcherContact;
|
||||||
workOrder?: {
|
workOrder?: {
|
||||||
workerOrderTitle?: string;
|
workerOrderTitle?: string;
|
||||||
internalWONumber?: string;
|
internalWONumber?: string;
|
||||||
priority?: string;
|
priority?: string;
|
||||||
trade?: string;
|
trade?: string;
|
||||||
subTrade?: string;
|
subTrade?: string;
|
||||||
|
siteCode?: string;
|
||||||
|
service?: string;
|
||||||
problem?: string;
|
problem?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
36
src/domain/vendors/utils/build-technician-create-payload.ts
vendored
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||||
|
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
||||||
|
|
||||||
|
type BuildTechnicianCreatePayloadInput = {
|
||||||
|
companyVendor: Pick<VendorDropdownItem, "companyName" | "email" | "address" | "tradeSpecialties">;
|
||||||
|
contactName: string;
|
||||||
|
phone?: string;
|
||||||
|
companyId?: string | number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Builds a vendor create payload for a new technician under an existing company. */
|
||||||
|
export function buildTechnicianCreatePayload({
|
||||||
|
companyVendor,
|
||||||
|
contactName,
|
||||||
|
phone = "",
|
||||||
|
companyId = null,
|
||||||
|
}: BuildTechnicianCreatePayloadInput): VendorFormValues {
|
||||||
|
return {
|
||||||
|
companyName: companyVendor.companyName,
|
||||||
|
contactName: contactName.trim(),
|
||||||
|
email: companyVendor.email ?? "",
|
||||||
|
phone: phone.trim(),
|
||||||
|
companyPhone: "",
|
||||||
|
googleMapsUrl: "",
|
||||||
|
notes: "",
|
||||||
|
totalJobs: 0,
|
||||||
|
address: companyVendor.address ?? "",
|
||||||
|
city: "",
|
||||||
|
state: "",
|
||||||
|
zip: "",
|
||||||
|
tradeSpecialties: companyVendor.tradeSpecialties ?? "",
|
||||||
|
isActive: true,
|
||||||
|
companyId,
|
||||||
|
preferredContact: "Phone",
|
||||||
|
};
|
||||||
|
}
|
||||||
21
src/domain/vendors/utils/draft-vendor.ts
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||||
|
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
||||||
|
|
||||||
|
export const DRAFT_VENDOR_ID_PREFIX = "draft:";
|
||||||
|
|
||||||
|
export function isDraftVendorId(id: string | number | null | undefined): boolean {
|
||||||
|
return String(id ?? "").startsWith(DRAFT_VENDOR_ID_PREFIX);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDraftVendorDropdownItem(payload: VendorFormValues): VendorDropdownItem {
|
||||||
|
const email = (payload.email ?? "").trim();
|
||||||
|
return {
|
||||||
|
id: `${DRAFT_VENDOR_ID_PREFIX}${crypto.randomUUID()}`,
|
||||||
|
companyName: payload.companyName,
|
||||||
|
contactName: payload.contactName,
|
||||||
|
tradeSpecialties: payload.tradeSpecialties ?? "",
|
||||||
|
address: payload.address ?? "",
|
||||||
|
distanceMiles: null,
|
||||||
|
email: email || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
15
src/domain/vendors/utils/vendor-to-dropdown-item.ts
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||||
|
import type { Vendor } from "@/domain/vendors/types/vendor";
|
||||||
|
|
||||||
|
export function vendorToDropdownItem(vendor: Vendor): VendorDropdownItem {
|
||||||
|
const email = vendor.email.trim();
|
||||||
|
return {
|
||||||
|
id: vendor.id ?? "",
|
||||||
|
companyName: vendor.companyName,
|
||||||
|
contactName: vendor.contactName,
|
||||||
|
tradeSpecialties: vendor.tradeSpecialties,
|
||||||
|
address: vendor.address,
|
||||||
|
distanceMiles: null,
|
||||||
|
email: email || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -87,7 +87,7 @@ function tryPastDueRule({ fieldKey, actionKey }: AuditActionRuleContext): string
|
||||||
fieldKey.includes("pastdue") || actionKey.includes("pastdue") || actionKey === "markedpastdue";
|
fieldKey.includes("pastdue") || actionKey.includes("pastdue") || actionKey === "markedpastdue";
|
||||||
if (!matches) return null;
|
if (!matches) return null;
|
||||||
|
|
||||||
return "WO marked as Past Due. Scheduled date has passed without completion.";
|
return "WO marked as Past Due. Due date has passed without completion.";
|
||||||
}
|
}
|
||||||
|
|
||||||
function tryCreatedRule({ fieldKey, actionKey }: AuditActionRuleContext): string | null {
|
function tryCreatedRule({ fieldKey, actionKey }: AuditActionRuleContext): string | null {
|
||||||
|
|
|
||||||
|
|
@ -12,22 +12,19 @@ import { weekRange } from "@/domain/work-orders/utils/list-week-utils";
|
||||||
const TERMINAL_STATUSES = new Set(["Completed", "Canceled", "Done", "Cancelled"]);
|
const TERMINAL_STATUSES = new Set(["Completed", "Canceled", "Done", "Cancelled"]);
|
||||||
|
|
||||||
export function computeIsPastDue(row: WorkOrderTableRow): boolean {
|
export function computeIsPastDue(row: WorkOrderTableRow): boolean {
|
||||||
if (TERMINAL_STATUSES.has(row.status)) {
|
if (!row.dueDate || TERMINAL_STATUSES.has(row.status)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
const date = row.scheduledOn || row.dueDate;
|
return row.dueDate < today;
|
||||||
if (!date) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return date < today;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function enrichTableRow(row: WorkOrderTableRow): WorkOrderTableRow {
|
export function enrichTableRow(row: WorkOrderTableRow): WorkOrderTableRow {
|
||||||
return {
|
return {
|
||||||
...row,
|
...row,
|
||||||
// Prefer API board flag when true; otherwise recompute from scheduledOn/dueDate.
|
// Recompute from dueDate (same date-only rule as BE). Avoid sticky
|
||||||
isPastDue: row.isPastDue || computeIsPastDue(row),
|
// `apiTrue || compute` so a future Due Date clears Past Due immediately.
|
||||||
|
isPastDue: computeIsPastDue(row),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { useController } from "react-hook-form";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const rosterGet = vi.fn();
|
const rosterGet = vi.fn();
|
||||||
|
const saveMutate = vi.fn();
|
||||||
|
|
||||||
vi.mock("@/domain/vendors/api/vendor-company-roster-api", () => ({
|
vi.mock("@/domain/vendors/api/vendor-company-roster-api", () => ({
|
||||||
vendorCompanyRosterApi: { get: (...args: unknown[]) => rosterGet(...args) },
|
vendorCompanyRosterApi: { get: (...args: unknown[]) => rosterGet(...args) },
|
||||||
|
|
@ -28,7 +29,10 @@ vi.mock("@/domain/vendors/use-cases/use-save-vendor-company-roster", async () =>
|
||||||
const actual = await vi.importActual<
|
const actual = await vi.importActual<
|
||||||
typeof import("@/domain/vendors/use-cases/use-save-vendor-company-roster")
|
typeof import("@/domain/vendors/use-cases/use-save-vendor-company-roster")
|
||||||
>("@/domain/vendors/use-cases/use-save-vendor-company-roster");
|
>("@/domain/vendors/use-cases/use-save-vendor-company-roster");
|
||||||
return { ...actual, useSaveVendorCompanyRoster: () => ({ mutate: vi.fn(), isPending: false }) };
|
return {
|
||||||
|
...actual,
|
||||||
|
useSaveVendorCompanyRoster: () => ({ mutate: saveMutate, isPending: false }),
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
import { useVendorRosterForm } from "@/app/(protected)/vendors/_components/use-vendor-roster-form";
|
import { useVendorRosterForm } from "@/app/(protected)/vendors/_components/use-vendor-roster-form";
|
||||||
|
|
@ -101,6 +105,7 @@ function deferRoster(): {
|
||||||
describe("useVendorRosterForm load-error handling", () => {
|
describe("useVendorRosterForm load-error handling", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
rosterGet.mockReset();
|
rosterGet.mockReset();
|
||||||
|
saveMutate.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows a safe retryable error when selecting an existing company fetch is rejected", async () => {
|
it("shows a safe retryable error when selecting an existing company fetch is rejected", async () => {
|
||||||
|
|
@ -196,6 +201,44 @@ describe("useVendorRosterForm load-error handling", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("useVendorRosterForm prototype defaults", () => {
|
||||||
|
it("does not persist the prototype's untouched optional technician card", () => {
|
||||||
|
const { result } = renderHook(
|
||||||
|
() => useVendorRosterForm({ mode: "create", startWithTechnician: true }),
|
||||||
|
{ wrapper: makeWrapper(createClient()) },
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.submit({
|
||||||
|
name: "Independent Vendor LLC",
|
||||||
|
companyPhone: "(314) 555-0199",
|
||||||
|
email: "",
|
||||||
|
address: "",
|
||||||
|
city: "",
|
||||||
|
state: "",
|
||||||
|
zip: "",
|
||||||
|
googleMapsUrl: "",
|
||||||
|
notes: "",
|
||||||
|
technicians: [
|
||||||
|
{
|
||||||
|
contactName: "",
|
||||||
|
phone: "",
|
||||||
|
email: "",
|
||||||
|
preferredContact: "Phone",
|
||||||
|
tradeSpecialties: "",
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(saveMutate).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ values: expect.objectContaining({ technicians: [] }) }),
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("useVendorRosterForm stale-selection handling", () => {
|
describe("useVendorRosterForm stale-selection handling", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
rosterGet.mockReset();
|
rosterGet.mockReset();
|
||||||
|
|
|
||||||
|
|
@ -49,11 +49,11 @@ function rosterWith(technicians: Array<Record<string, unknown>>) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("VendorDetailDrawer preferred-contact display", () => {
|
describe("VendorDetailDrawer selected-technician display", () => {
|
||||||
it("renders no preference label when preferredContact is absent", () => {
|
it("renders no preference label when preferredContact is absent", () => {
|
||||||
useVendorCompanyRoster.mockReturnValue(
|
useVendorCompanyRoster.mockReturnValue(
|
||||||
rosterWith([
|
rosterWith([
|
||||||
{ id: 7, contactName: "Adam", phone: "314-555-0198", isActive: true, totalJobs: 5 },
|
{ id: 1, contactName: "Adam", phone: "314-555-0198", isActive: true, totalJobs: 5 },
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -62,15 +62,15 @@ describe("VendorDetailDrawer preferred-contact display", () => {
|
||||||
withAuth: false,
|
withAuth: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.getByText("Adam")).toBeInTheDocument();
|
expect(screen.getByRole("heading", { level: 2, name: "Adam" })).toBeInTheDocument();
|
||||||
expect(screen.queryByText(/^Prefers/)).not.toBeInTheDocument();
|
expect(screen.queryByText(/^Prefers/)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders the preference label when an explicit preferredContact is present", () => {
|
it("keeps the retired preferred-contact value out of the detail view", () => {
|
||||||
useVendorCompanyRoster.mockReturnValue(
|
useVendorCompanyRoster.mockReturnValue(
|
||||||
rosterWith([
|
rosterWith([
|
||||||
{
|
{
|
||||||
id: 7,
|
id: 1,
|
||||||
contactName: "Adam",
|
contactName: "Adam",
|
||||||
phone: "314-555-0198",
|
phone: "314-555-0198",
|
||||||
preferredContact: "Email",
|
preferredContact: "Email",
|
||||||
|
|
@ -85,6 +85,52 @@ describe("VendorDetailDrawer preferred-contact display", () => {
|
||||||
withAuth: false,
|
withAuth: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.getByText("Prefers Email")).toBeInTheDocument();
|
expect(screen.getByRole("heading", { level: 2, name: "Adam" })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/^Prefers/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the clicked row when the roster has no matching stable id", () => {
|
||||||
|
useVendorCompanyRoster.mockReturnValue(
|
||||||
|
rosterWith([
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
contactName: "Adam Whyte",
|
||||||
|
phone: "999-999-9999",
|
||||||
|
isActive: true,
|
||||||
|
totalJobs: 99,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderWithProviders(<VendorDetailDrawer open={true} vendor={vendor} onClose={vi.fn()} />, {
|
||||||
|
route: "/vendors",
|
||||||
|
withAuth: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText("0")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("999-999-9999")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not expose a status control for a different technician", () => {
|
||||||
|
useVendorCompanyRoster.mockReturnValue(
|
||||||
|
rosterWith([
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
contactName: "Different Technician",
|
||||||
|
phone: "314-555-0101",
|
||||||
|
email: "",
|
||||||
|
tradeSpecialties: "Plumbing",
|
||||||
|
isActive: true,
|
||||||
|
totalJobs: 10,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<VendorDetailDrawer open={true} vendor={vendor} startInEdit={true} onClose={vi.fn()} />,
|
||||||
|
{ route: "/vendors", withAuth: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByRole("switch", { name: "Active status" })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,14 @@
|
||||||
import { screen } from "@testing-library/react";
|
import { screen, within } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import VendorsListPage from "@/app/(protected)/vendors/index";
|
import VendorsListPage from "@/app/(protected)/vendors/index";
|
||||||
import type { VendorListItem } from "@/domain/vendors/types/vendor";
|
import type { VendorCompanyRoster, VendorListItem } from "@/domain/vendors/types/vendor";
|
||||||
import { renderWithProviders } from "@/test/test-utils";
|
import { renderWithProviders } from "@/test/test-utils";
|
||||||
|
|
||||||
const useVendorsList = vi.fn();
|
const useVendorsList = vi.fn();
|
||||||
const useVendorCompanyRoster = vi.fn();
|
const useVendorCompanyRoster = vi.fn();
|
||||||
const useVendorDeactivationImpact = vi.fn();
|
const useVendorDeactivationImpact = vi.fn();
|
||||||
const mutate = vi.fn();
|
const mutate = vi.fn();
|
||||||
const navigate = vi.fn();
|
|
||||||
|
|
||||||
vi.mock("react-router", async () => {
|
|
||||||
const actual = await vi.importActual("react-router");
|
|
||||||
return { ...actual, useNavigate: () => navigate };
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("@/domain/vendors/use-cases/use-vendors-list", () => ({
|
vi.mock("@/domain/vendors/use-cases/use-vendors-list", () => ({
|
||||||
useVendorsList: (...args: unknown[]) => useVendorsList(...args),
|
useVendorsList: (...args: unknown[]) => useVendorsList(...args),
|
||||||
}));
|
}));
|
||||||
|
|
@ -61,6 +54,32 @@ const inactiveVendor = {
|
||||||
isActive: false,
|
isActive: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const activeRoster: VendorCompanyRoster = {
|
||||||
|
companyId: "co-1",
|
||||||
|
rowVersion: "rv-1",
|
||||||
|
name: "Gateway Plumbing",
|
||||||
|
companyPhone: "(314) 555-0100",
|
||||||
|
email: "dispatch@gatewayplumbing.com",
|
||||||
|
address: "",
|
||||||
|
city: "St. Louis",
|
||||||
|
state: "MO",
|
||||||
|
zip: "",
|
||||||
|
googleMapsUrl: "",
|
||||||
|
notes: "",
|
||||||
|
technicians: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
contactName: "Adam Whyte",
|
||||||
|
phone: "(314) 555-0198",
|
||||||
|
email: "dispatch@gatewayplumbing.com",
|
||||||
|
preferredContact: "Email",
|
||||||
|
tradeSpecialties: "Backflow Preventers",
|
||||||
|
isActive: true,
|
||||||
|
totalJobs: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
function result(items: VendorListItem[], totalCount: number) {
|
function result(items: VendorListItem[], totalCount: number) {
|
||||||
return {
|
return {
|
||||||
data: { items, totalCount, page: 1, pageSize: 12 },
|
data: { items, totalCount, page: 1, pageSize: 12 },
|
||||||
|
|
@ -71,7 +90,13 @@ function result(items: VendorListItem[], totalCount: number) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupDefaults() {
|
function setupDefaults() {
|
||||||
useVendorCompanyRoster.mockReturnValue({ data: undefined, isLoading: false, error: null });
|
useVendorCompanyRoster.mockReturnValue({
|
||||||
|
data: undefined,
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
error: null,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
});
|
||||||
useVendorDeactivationImpact.mockReturnValue({
|
useVendorDeactivationImpact.mockReturnValue({
|
||||||
data: undefined,
|
data: undefined,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
|
@ -80,7 +105,7 @@ function setupDefaults() {
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("VendorsListPage", () => {
|
describe("VendorsListPage", () => {
|
||||||
it("renders prototype-aligned status tabs and location, then hides deactivation for inactive rows", async () => {
|
it("renders prototype-aligned status and two row actions in both tabs", async () => {
|
||||||
setupDefaults();
|
setupDefaults();
|
||||||
useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
|
useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
|
||||||
if (params.pageSize === 1) return result([], params.isActive ? 1 : 1);
|
if (params.pageSize === 1) return result([], params.isActive ? 1 : 1);
|
||||||
|
|
@ -91,11 +116,20 @@ describe("VendorsListPage", () => {
|
||||||
|
|
||||||
expect(screen.getByRole("heading", { name: "Vendors / Technicians" })).toBeInTheDocument();
|
expect(screen.getByRole("heading", { name: "Vendors / Technicians" })).toBeInTheDocument();
|
||||||
expect(screen.getByText("St. Louis, MO")).toBeInTheDocument();
|
expect(screen.getByText("St. Louis, MO")).toBeInTheDocument();
|
||||||
expect(screen.getByRole("button", { name: "Deactivate vendor" })).toBeInTheDocument();
|
expect(screen.getByRole("columnheader", { name: "Status" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("cell", { name: "Active" })).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "View vendor Gateway Plumbing" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: "Deactivate vendor" })).not.toBeInTheDocument();
|
||||||
|
|
||||||
await userEvent.click(screen.getByRole("tab", { name: "Inactive (1)" }));
|
await userEvent.click(screen.getByRole("tab", { name: "Inactive (1)" }));
|
||||||
|
|
||||||
expect(screen.getByText("Drain Cleaning Bros")).toBeInTheDocument();
|
expect(screen.getByText("Drain Cleaning Bros")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("cell", { name: "Inactive" })).toBeInTheDocument();
|
||||||
expect(screen.queryByRole("button", { name: "Deactivate vendor" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: "Deactivate vendor" })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -114,39 +148,18 @@ describe("VendorsListPage", () => {
|
||||||
|
|
||||||
renderWithProviders(<VendorsListPage />, { route: "/vendors", withAuth: false });
|
renderWithProviders(<VendorsListPage />, { route: "/vendors", withAuth: false });
|
||||||
|
|
||||||
expect(screen.getByText(/25 active vendor entries/)).toBeInTheDocument();
|
expect(screen.getByText(/25 active technicians/)).toBeInTheDocument();
|
||||||
expect(screen.queryByText(/3 active vendor entries/)).not.toBeInTheDocument();
|
expect(screen.queryByText(/3 active technicians/)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("opens the detail drawer when a row is clicked or activated by keyboard", async () => {
|
it("opens the detail drawer when a row is clicked or activated by keyboard", async () => {
|
||||||
setupDefaults();
|
setupDefaults();
|
||||||
useVendorCompanyRoster.mockReturnValue({
|
useVendorCompanyRoster.mockReturnValue({
|
||||||
data: {
|
data: activeRoster,
|
||||||
companyId: "co-1",
|
|
||||||
rowVersion: "rv-1",
|
|
||||||
name: "Gateway Plumbing",
|
|
||||||
companyPhone: "(314) 555-0198",
|
|
||||||
email: "dispatch@gatewayplumbing.com",
|
|
||||||
address: "",
|
|
||||||
city: "St. Louis",
|
|
||||||
state: "MO",
|
|
||||||
zip: "",
|
|
||||||
googleMapsUrl: "",
|
|
||||||
notes: "",
|
|
||||||
technicians: [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
contactName: "Adam Whyte",
|
|
||||||
phone: "(314) 555-0198",
|
|
||||||
email: "dispatch@gatewayplumbing.com",
|
|
||||||
preferredContact: "Email",
|
|
||||||
tradeSpecialties: "Backflow Preventers",
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
refetch: vi.fn(),
|
||||||
});
|
});
|
||||||
useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
|
useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
|
||||||
if (params.pageSize === 1) return result([], 1);
|
if (params.pageSize === 1) return result([], 1);
|
||||||
|
|
@ -161,11 +174,18 @@ describe("VendorsListPage", () => {
|
||||||
row.focus();
|
row.focus();
|
||||||
await userEvent.keyboard("{Enter}");
|
await userEvent.keyboard("{Enter}");
|
||||||
|
|
||||||
expect(screen.getByRole("heading", { level: 2, name: "Gateway Plumbing" })).toBeInTheDocument();
|
expect(screen.getByRole("heading", { level: 2, name: "Adam Whyte" })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("blocks deactivation when the preflight reports open work orders", async () => {
|
it("blocks deactivation when the preflight reports open work orders", async () => {
|
||||||
setupDefaults();
|
setupDefaults();
|
||||||
|
useVendorCompanyRoster.mockReturnValue({
|
||||||
|
data: activeRoster,
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
error: null,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
});
|
||||||
useVendorDeactivationImpact.mockReturnValue({
|
useVendorDeactivationImpact.mockReturnValue({
|
||||||
data: {
|
data: {
|
||||||
vendorId: 1,
|
vendorId: 1,
|
||||||
|
|
@ -189,7 +209,8 @@ describe("VendorsListPage", () => {
|
||||||
|
|
||||||
renderWithProviders(<VendorsListPage />, { route: "/vendors", withAuth: false });
|
renderWithProviders(<VendorsListPage />, { route: "/vendors", withAuth: false });
|
||||||
|
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Deactivate vendor" }));
|
await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" }));
|
||||||
|
await userEvent.click(screen.getByRole("switch", { name: "Active status" }));
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByText(/cannot be deactivated because it still has open work orders/),
|
screen.getByText(/cannot be deactivated because it still has open work orders/),
|
||||||
|
|
@ -199,9 +220,51 @@ describe("VendorsListPage", () => {
|
||||||
expect(mutate).not.toHaveBeenCalled();
|
expect(mutate).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("navigates to the vendor edit route when the table Edit control is clicked", async () => {
|
it("preserves inline edits when deactivation is cancelled", async () => {
|
||||||
setupDefaults();
|
setupDefaults();
|
||||||
navigate.mockClear();
|
useVendorCompanyRoster.mockReturnValue({
|
||||||
|
data: activeRoster,
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
error: null,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
});
|
||||||
|
useVendorDeactivationImpact.mockReturnValue({
|
||||||
|
data: { vendorId: 1, canDeactivate: true, openWorkOrders: [] },
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
|
||||||
|
if (params.pageSize === 1) return result([], 1);
|
||||||
|
return params.isActive ? result([activeVendor], 1) : result([], 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProviders(<VendorsListPage />, { route: "/vendors", withAuth: false });
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" }));
|
||||||
|
const company = screen.getByRole("textbox", { name: "Company" });
|
||||||
|
await userEvent.clear(company);
|
||||||
|
await userEvent.type(company, "Draft Company Name");
|
||||||
|
await userEvent.click(screen.getByRole("switch", { name: "Active status" }));
|
||||||
|
await userEvent.click(
|
||||||
|
within(screen.getByRole("dialog", { name: "Deactivate Vendor" })).getByRole("button", {
|
||||||
|
name: "Cancel",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(company).toBeInTheDocument();
|
||||||
|
expect(company).toHaveValue("Draft Company Name");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens inline edit when the table Edit control is clicked", async () => {
|
||||||
|
setupDefaults();
|
||||||
|
useVendorCompanyRoster.mockReturnValue({
|
||||||
|
data: activeRoster,
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
error: null,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
});
|
||||||
useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
|
useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
|
||||||
if (params.pageSize === 1) return result([], 1);
|
if (params.pageSize === 1) return result([], 1);
|
||||||
return params.isActive ? result([activeVendor], 1) : result([], 0);
|
return params.isActive ? result([activeVendor], 1) : result([], 0);
|
||||||
|
|
@ -211,16 +274,20 @@ describe("VendorsListPage", () => {
|
||||||
|
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" }));
|
await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" }));
|
||||||
|
|
||||||
expect(navigate).toHaveBeenCalledWith("/vendors/edit/1");
|
expect(screen.getByRole("button", { name: "Save changes" })).toBeInTheDocument();
|
||||||
expect(
|
expect(screen.getByRole("textbox", { name: "Company" })).toHaveValue("Gateway Plumbing");
|
||||||
screen.queryByRole("heading", { level: 2, name: "Gateway Plumbing" }),
|
|
||||||
).not.toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("navigates to the company route when editing a vendor without a vendor id", async () => {
|
it("opens inline company edit when the row has no vendor id", async () => {
|
||||||
setupDefaults();
|
setupDefaults();
|
||||||
navigate.mockClear();
|
|
||||||
const companyOnlyVendor = { ...activeVendor, id: null, companyId: "co-9" };
|
const companyOnlyVendor = { ...activeVendor, id: null, companyId: "co-9" };
|
||||||
|
useVendorCompanyRoster.mockReturnValue({
|
||||||
|
data: { ...activeRoster, companyId: "co-9", technicians: [] },
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
error: null,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
});
|
||||||
useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
|
useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
|
||||||
if (params.pageSize === 1) return result([], 1);
|
if (params.pageSize === 1) return result([], 1);
|
||||||
return params.isActive ? result([companyOnlyVendor], 1) : result([], 0);
|
return params.isActive ? result([companyOnlyVendor], 1) : result([], 0);
|
||||||
|
|
@ -230,6 +297,7 @@ describe("VendorsListPage", () => {
|
||||||
|
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" }));
|
await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" }));
|
||||||
|
|
||||||
expect(navigate).toHaveBeenCalledWith("/vendors/company/co-9");
|
expect(screen.getByRole("button", { name: "Save changes" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("textbox", { name: "Company" })).toHaveValue("Gateway Plumbing");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
import { ThemeProvider } from "@mui/material";
|
||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { DueDateCalendarDialog } from "@/app/(protected)/workorders/_components/list/table/cells/due-date-calendar-dialog";
|
||||||
|
import { DueDateCell } from "@/app/(protected)/workorders/_components/list/table/cells/due-date-cell";
|
||||||
|
import { WizardDueDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-due-date-field";
|
||||||
|
import { muiTheme } from "@/lib/theme/mui-theme";
|
||||||
|
|
||||||
|
function renderWithTheme(ui: React.ReactElement) {
|
||||||
|
return render(<ThemeProvider theme={muiTheme}>{ui}</ThemeProvider>);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("DueDateCalendarDialog", () => {
|
||||||
|
it("saves the selected day as ISO and closes", () => {
|
||||||
|
const onSave = vi.fn();
|
||||||
|
const onOpenChange = vi.fn();
|
||||||
|
|
||||||
|
renderWithTheme(
|
||||||
|
<DueDateCalendarDialog open onOpenChange={onOpenChange} value="2026-07-15" onSave={onSave} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Due Date")).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "20" }));
|
||||||
|
|
||||||
|
expect(onSave).toHaveBeenCalledWith("2026-07-20");
|
||||||
|
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("DueDateCell", () => {
|
||||||
|
it("opens a dialog for Emergency and keeps popover for PM", () => {
|
||||||
|
const onSave = vi.fn();
|
||||||
|
|
||||||
|
const { rerender } = renderWithTheme(
|
||||||
|
<DueDateCell type="Emergency" value="2026-07-15" onSave={onSave} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /07-15|7\/15|15/i }));
|
||||||
|
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<ThemeProvider theme={muiTheme}>
|
||||||
|
<DueDateCell type="PM" value="2026-07-15" onSave={onSave} />
|
||||||
|
</ThemeProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("WizardDueDateField", () => {
|
||||||
|
it("opens a dialog when type is Emergency", () => {
|
||||||
|
renderWithTheme(
|
||||||
|
<WizardDueDateField type="Emergency" value="" placeholder="No due date" onChange={vi.fn()} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /No due date/i }));
|
||||||
|
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { resolveTechnicianKey } from "@/app/(protected)/workorders/_components/list/table/cells/resolve-technician-key";
|
||||||
|
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||||
|
|
||||||
|
const vendors: VendorDropdownItem[] = [
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
companyName: "Gateway Plumbing",
|
||||||
|
contactName: "Jordan Lee",
|
||||||
|
tradeSpecialties: "Plumbing",
|
||||||
|
address: "1 Main St",
|
||||||
|
distanceMiles: 4.2,
|
||||||
|
email: "dispatch@gateway.test",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 11,
|
||||||
|
companyName: "Gateway Plumbing",
|
||||||
|
contactName: "Adam Whyte",
|
||||||
|
tradeSpecialties: "Plumbing",
|
||||||
|
address: "1 Main St",
|
||||||
|
distanceMiles: 4.2,
|
||||||
|
email: "dispatch@gateway.test",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("resolveTechnicianKey", () => {
|
||||||
|
it("prefers a stable vendorId when it exists in the list", () => {
|
||||||
|
expect(resolveTechnicianKey(11, "Jordan Lee", "Gateway Plumbing", vendors)).toBe("11");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to name+company when vendorId is missing", () => {
|
||||||
|
expect(resolveTechnicianKey("", "Jordan Lee", "Gateway Plumbing", vendors)).toBe("10");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps orphan tech string as key when no vendor matches", () => {
|
||||||
|
expect(resolveTechnicianKey("", "Unknown Tech", "Gateway Plumbing", vendors)).toBe(
|
||||||
|
"Unknown Tech",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps vendorId when the vendors list is still empty", () => {
|
||||||
|
expect(resolveTechnicianKey(42, "Jordan Lee", "Gateway Plumbing", [])).toBe("42");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { fireEvent, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { VendorDialogAddTechnician } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-add-technician";
|
||||||
|
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||||
|
import { isDraftVendorId } from "@/domain/vendors/utils/draft-vendor";
|
||||||
|
import { renderWithProviders } from "@/test/test-utils";
|
||||||
|
|
||||||
|
const companyVendor: VendorDropdownItem = {
|
||||||
|
id: 10,
|
||||||
|
companyName: "Gateway Plumbing",
|
||||||
|
contactName: "Adam Whyte",
|
||||||
|
tradeSpecialties: "Plumbing",
|
||||||
|
address: "1 Main St",
|
||||||
|
distanceMiles: 4.2,
|
||||||
|
email: "dispatch@gateway.test",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("VendorDialogAddTechnician", () => {
|
||||||
|
it("stages a draft technician locally without calling the create API", () => {
|
||||||
|
const onCreated = vi.fn();
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<VendorDialogAddTechnician
|
||||||
|
companyName="Gateway Plumbing"
|
||||||
|
companyVendor={companyVendor}
|
||||||
|
onCreated={onCreated}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /\+ add technician/i }));
|
||||||
|
fireEvent.change(screen.getByPlaceholderText("Full name"), {
|
||||||
|
target: { value: "Jordan Lee" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByPlaceholderText("(000) 000-0000"), {
|
||||||
|
target: { value: "3145550199" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /^add$/i }));
|
||||||
|
|
||||||
|
expect(onCreated).toHaveBeenCalledTimes(1);
|
||||||
|
const [draftVendor, phone, payload] = onCreated.mock.calls[0] ?? [];
|
||||||
|
expect(isDraftVendorId(draftVendor.id)).toBe(true);
|
||||||
|
expect(draftVendor.contactName).toBe("Jordan Lee");
|
||||||
|
expect(phone).toBeTruthy();
|
||||||
|
expect(payload).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
companyName: "Gateway Plumbing",
|
||||||
|
contactName: "Jordan Lee",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,178 @@
|
||||||
|
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { VendorDialog } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog";
|
||||||
|
import type { Vendor } from "@/domain/vendors/types/vendor";
|
||||||
|
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||||
|
import { renderWithProviders } from "@/test/test-utils";
|
||||||
|
|
||||||
|
const mutateAsync = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("@/domain/vendors/use-cases/use-create-vendor", () => ({
|
||||||
|
useCreateVendor: () => ({
|
||||||
|
mutateAsync,
|
||||||
|
isPending: false,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const existingVendor: VendorDropdownItem = {
|
||||||
|
id: 10,
|
||||||
|
companyName: "Gateway Plumbing",
|
||||||
|
contactName: "Jordan Lee",
|
||||||
|
tradeSpecialties: "Plumbing",
|
||||||
|
address: "1 Main St",
|
||||||
|
distanceMiles: 4.2,
|
||||||
|
email: "dispatch@gateway.test",
|
||||||
|
};
|
||||||
|
|
||||||
|
const createdVendor: Vendor = {
|
||||||
|
id: 99,
|
||||||
|
companyId: 10,
|
||||||
|
companyName: "Gateway Plumbing",
|
||||||
|
contactName: "Jordan Lee",
|
||||||
|
email: "dispatch@gateway.test",
|
||||||
|
phone: "(314) 555-9999",
|
||||||
|
companyPhone: "",
|
||||||
|
googleMapsUrl: "",
|
||||||
|
notes: "",
|
||||||
|
totalJobs: 0,
|
||||||
|
address: "1 Main St",
|
||||||
|
city: "",
|
||||||
|
state: "",
|
||||||
|
zip: "",
|
||||||
|
tradeSpecialties: "Plumbing",
|
||||||
|
isActive: true,
|
||||||
|
preferredContact: "Phone",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("VendorDialog stable technician identity", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mutateAsync.mockReset();
|
||||||
|
mutateAsync.mockResolvedValue(createdVendor);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps draft identity on same-name collision and posts live phone on save", async () => {
|
||||||
|
const onSave = vi.fn();
|
||||||
|
const onOpenChange = vi.fn();
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<VendorDialog
|
||||||
|
open={true}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
vendorId={10}
|
||||||
|
company="Gateway Plumbing"
|
||||||
|
tech="Jordan Lee"
|
||||||
|
techPhone="(314) 555-0100"
|
||||||
|
vendors={[existingVendor]}
|
||||||
|
onSave={onSave}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /\+ add technician/i }));
|
||||||
|
fireEvent.change(screen.getByPlaceholderText("Full name"), {
|
||||||
|
target: { value: "Jordan Lee" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByPlaceholderText("(000) 000-0000"), {
|
||||||
|
target: { value: "3145550199" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /^add$/i }));
|
||||||
|
|
||||||
|
// Draft stays staged: add form collapses and select still shows the colliding name.
|
||||||
|
expect(screen.getByRole("button", { name: /\+ add technician/i })).toBeTruthy();
|
||||||
|
expect(screen.getByText("Jordan Lee · Gateway Plumbing")).toBeTruthy();
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByDisplayValue("3145550199"), {
|
||||||
|
target: { value: "(314) 555-9999" },
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mutateAsync).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mutateAsync).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
contactName: "Jordan Lee",
|
||||||
|
companyName: "Gateway Plumbing",
|
||||||
|
phone: "(314) 555-9999",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onSave).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
vendorId: "99",
|
||||||
|
tech: "Jordan Lee",
|
||||||
|
company: "Gateway Plumbing",
|
||||||
|
techPhone: "(314) 555-9999",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Must not persist the pre-existing colliding vendor id.
|
||||||
|
expect(onSave.mock.calls[0]?.[0].vendorId).not.toBe("10");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not clear staged draft when technicianOptions rebuild after add", async () => {
|
||||||
|
const onSave = vi.fn();
|
||||||
|
mutateAsync.mockResolvedValue({
|
||||||
|
...createdVendor,
|
||||||
|
id: 88,
|
||||||
|
contactName: "Casey New",
|
||||||
|
phone: "3145550188",
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<VendorDialog
|
||||||
|
open={true}
|
||||||
|
onOpenChange={vi.fn()}
|
||||||
|
vendorId={11}
|
||||||
|
company="Gateway Plumbing"
|
||||||
|
tech="Adam Whyte"
|
||||||
|
techPhone=""
|
||||||
|
vendors={[
|
||||||
|
existingVendor,
|
||||||
|
{
|
||||||
|
...existingVendor,
|
||||||
|
id: 11,
|
||||||
|
contactName: "Adam Whyte",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onSave={onSave}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /\+ add technician/i }));
|
||||||
|
fireEvent.change(screen.getByPlaceholderText("Full name"), {
|
||||||
|
target: { value: "Casey New" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByPlaceholderText("(000) 000-0000"), {
|
||||||
|
target: { value: "3145550188" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /^add$/i }));
|
||||||
|
|
||||||
|
expect(screen.getByText("Casey New · Gateway Plumbing")).toBeTruthy();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mutateAsync).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mutateAsync).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
contactName: "Casey New",
|
||||||
|
phone: "3145550188",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onSave).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
vendorId: "88",
|
||||||
|
tech: "Casey New",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,118 @@
|
||||||
|
import { renderWithProviders } from "@/test/test-utils";
|
||||||
|
import { screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { WoTableBody } from "@/app/(protected)/workorders/_components/list/table/wo-table-body";
|
||||||
|
import { WoTableRowCells } from "@/app/(protected)/workorders/_components/list/table/wo-table-row-cells";
|
||||||
|
import type { WoTableRowHandlers } from "@/app/(protected)/workorders/_components/list/table/wo-table-row";
|
||||||
|
import type { ReturnTypeOfUseWorkOrdersTableData } from "@/app/(protected)/workorders/_components/list/table/wo-table-types";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
|
||||||
|
const HIGHLIGHT_BOX_SHADOW = "0 0 0 2px var(--color-accent)";
|
||||||
|
|
||||||
|
const unscheduledRow: WorkOrderTableRow = {
|
||||||
|
id: 42,
|
||||||
|
woNumber: "WO-42",
|
||||||
|
site: "CHI-1042",
|
||||||
|
type: "PM",
|
||||||
|
dispatcherId: "u1",
|
||||||
|
dispatcherName: "Test Dispatcher",
|
||||||
|
scheduledOn: "",
|
||||||
|
targetWeek: "",
|
||||||
|
dayGroup: "",
|
||||||
|
dayLabel: "",
|
||||||
|
location: "Chicago Loop",
|
||||||
|
locationId: "mock-loc-1",
|
||||||
|
pm: "HVAC — Quarterly PM",
|
||||||
|
serviceNotes: "",
|
||||||
|
extraServices: [],
|
||||||
|
vendorId: "",
|
||||||
|
company: "",
|
||||||
|
tech: "",
|
||||||
|
techPhone: "",
|
||||||
|
apptStart: "",
|
||||||
|
apptEnd: "",
|
||||||
|
apptTime: "",
|
||||||
|
status: "Pending",
|
||||||
|
docStatus: "No",
|
||||||
|
pocName: "",
|
||||||
|
pocPhone: "",
|
||||||
|
pocNotes: "",
|
||||||
|
dueDate: "2026-07-15",
|
||||||
|
rescheduleCount: 0,
|
||||||
|
carriedOver: 0,
|
||||||
|
originalWeek: "",
|
||||||
|
originalDate: "",
|
||||||
|
isPastDue: false,
|
||||||
|
scheduleWeekOnly: false,
|
||||||
|
mediaCount: 0,
|
||||||
|
title: "",
|
||||||
|
priority: "",
|
||||||
|
primaryDispatchId: null,
|
||||||
|
rowVersion: "",
|
||||||
|
dispatchRowVersion: "",
|
||||||
|
flagColor: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlers: WoTableRowHandlers = {
|
||||||
|
onRowClick: vi.fn(),
|
||||||
|
onView: vi.fn(),
|
||||||
|
onEdit: vi.fn(),
|
||||||
|
onOpenMedia: vi.fn(),
|
||||||
|
onPatch: vi.fn(),
|
||||||
|
onScheduleDate: vi.fn(),
|
||||||
|
onScheduleWeek: vi.fn(),
|
||||||
|
onOpenSlideOver: vi.fn(),
|
||||||
|
onReorder: vi.fn(),
|
||||||
|
onDragStart: vi.fn(),
|
||||||
|
onDragOver: vi.fn(),
|
||||||
|
onDrop: vi.fn(),
|
||||||
|
onDragEnd: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Mirrors WoTable.renderRow: body `true` → Schedule On only. */
|
||||||
|
function renderUnscheduledHarness() {
|
||||||
|
const tableData = {
|
||||||
|
advActive: false,
|
||||||
|
unscheduled: [unscheduledRow],
|
||||||
|
unschedOpen: true,
|
||||||
|
draftDayKey: null,
|
||||||
|
displayGroups: [],
|
||||||
|
todayKey: null,
|
||||||
|
startDayDraft: vi.fn(),
|
||||||
|
setUnschedOpen: vi.fn(),
|
||||||
|
} as unknown as ReturnTypeOfUseWorkOrdersTableData;
|
||||||
|
|
||||||
|
const renderRow = (row: WorkOrderTableRow, forceScheduleHighlight = false) => (
|
||||||
|
<tr key={String(row.id)}>
|
||||||
|
<WoTableRowCells
|
||||||
|
row={row}
|
||||||
|
allRows={[row]}
|
||||||
|
users={[]}
|
||||||
|
sites={[]}
|
||||||
|
forceScheduleHighlight={forceScheduleHighlight}
|
||||||
|
forceDueDateHighlight={false}
|
||||||
|
handlers={handlers}
|
||||||
|
onPatchRow={vi.fn()}
|
||||||
|
/>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
|
||||||
|
return renderWithProviders(
|
||||||
|
<table>
|
||||||
|
<WoTableBody tableData={tableData} renderRow={renderRow} renderInlineRow={() => null} />
|
||||||
|
</table>,
|
||||||
|
{ withAuth: false },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("WoTableBody unscheduled Schedule On highlight (SH-165)", () => {
|
||||||
|
it("force-highlights Schedule On for Unscheduled rows, not Due Date", () => {
|
||||||
|
renderUnscheduledHarness();
|
||||||
|
|
||||||
|
const scheduleOn = screen.getByRole("button", { name: /^—$/ });
|
||||||
|
const dueDate = screen.getByRole("button", { name: /07-15/ });
|
||||||
|
|
||||||
|
expect(scheduleOn).toHaveStyle({ boxShadow: HIGHLIGHT_BOX_SHADOW });
|
||||||
|
expect(dueDate).not.toHaveStyle({ boxShadow: HIGHLIGHT_BOX_SHADOW });
|
||||||
|
});
|
||||||
|
});
|
||||||
99
src/test/app/v/dispatch-detail-sections.test.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
import { screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { DispatchDetailSections } from "@/app/v/[token]/dispatch/_components/dispatch-detail-sections";
|
||||||
|
import { isVendorPortalLockedStatus } from "@/domain/vendor-portal/lib/status-helpers";
|
||||||
|
import type {
|
||||||
|
VendorPortalDispatchDetail,
|
||||||
|
VendorPortalSignoff,
|
||||||
|
} from "@/domain/vendor-portal/types/vendor-portal";
|
||||||
|
import { renderWithProviders } from "@/test/test-utils";
|
||||||
|
|
||||||
|
vi.mock("@/app/v/[token]/dispatch/_components/checklist-section", () => ({
|
||||||
|
ChecklistSection: () => <div data-testid="checklist-mock" />,
|
||||||
|
}));
|
||||||
|
vi.mock("@/app/v/_components/vendor-portal-documents", () => ({
|
||||||
|
VendorPortalDocuments: () => <div data-testid="documents-mock" />,
|
||||||
|
}));
|
||||||
|
vi.mock("@/app/v/[token]/dispatch/_components/uplift-requests-section", () => ({
|
||||||
|
UpliftRequestsSection: () => <div data-testid="uplift-mock" />,
|
||||||
|
}));
|
||||||
|
vi.mock("@/app/v/[token]/dispatch/_components/comments-section", () => ({
|
||||||
|
CommentsSection: () => <div data-testid="comments-mock" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function buildData(
|
||||||
|
overrides: Partial<VendorPortalDispatchDetail> = {},
|
||||||
|
): VendorPortalDispatchDetail {
|
||||||
|
return {
|
||||||
|
id: 9,
|
||||||
|
dispatchNumber: "DSP-9",
|
||||||
|
status: "In Progress",
|
||||||
|
nteAmount: 500,
|
||||||
|
checklist: [],
|
||||||
|
comments: [],
|
||||||
|
upliftRequests: [],
|
||||||
|
signoffs: [],
|
||||||
|
documents: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSections({
|
||||||
|
status,
|
||||||
|
signoffs = [],
|
||||||
|
}: {
|
||||||
|
status: string;
|
||||||
|
signoffs?: VendorPortalSignoff[];
|
||||||
|
}) {
|
||||||
|
const data = buildData({ status, signoffs });
|
||||||
|
const locked = isVendorPortalLockedStatus(status);
|
||||||
|
|
||||||
|
return renderWithProviders(
|
||||||
|
<DispatchDetailSections
|
||||||
|
token="portal-token"
|
||||||
|
dispatchId={9}
|
||||||
|
data={data}
|
||||||
|
locked={locked}
|
||||||
|
refresh={vi.fn()}
|
||||||
|
updateLocal={vi.fn()}
|
||||||
|
/>,
|
||||||
|
{ withAuth: false },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("DispatchDetailSections signoff locking", () => {
|
||||||
|
it.each(["Verified", "Cancelled", "Refused"])(
|
||||||
|
"locks both customer and vendor signoff forms when status is %s",
|
||||||
|
(status) => {
|
||||||
|
renderSections({ status });
|
||||||
|
|
||||||
|
expect(screen.getAllByText(/Signoff is unavailable for this dispatch/i)).toHaveLength(2);
|
||||||
|
expect(screen.queryAllByRole("button", { name: "Submit Signoff" })).toHaveLength(0);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(["Completed", "In Progress"])(
|
||||||
|
"keeps both signoff forms available when status is %s",
|
||||||
|
(status) => {
|
||||||
|
renderSections({ status });
|
||||||
|
|
||||||
|
expect(screen.getAllByRole("button", { name: "Submit Signoff" })).toHaveLength(2);
|
||||||
|
expect(screen.queryByText(/Signoff is unavailable/i)).not.toBeInTheDocument();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("keeps an existing signoff visible while the dispatch is locked", () => {
|
||||||
|
const customerSignoff: VendorPortalSignoff = {
|
||||||
|
signoffType: "customer",
|
||||||
|
name: "Ada Lovelace",
|
||||||
|
signatureMethod: "drawn",
|
||||||
|
signedAt: "2024-01-02T03:04:05Z",
|
||||||
|
};
|
||||||
|
renderSections({ status: "Refused", signoffs: [customerSignoff] });
|
||||||
|
|
||||||
|
expect(screen.getByText("Ada Lovelace")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("drawn")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Signoff is unavailable/i)).toBeInTheDocument();
|
||||||
|
expect(screen.queryAllByRole("button", { name: "Submit Signoff" })).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
210
src/test/app/v/refuse-dispatch-dialog.test.tsx
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { RefuseDispatchDialog } from "@/app/v/[token]/dispatch/_components/refuse-dispatch-dialog";
|
||||||
|
import {
|
||||||
|
VendorPortalContext,
|
||||||
|
type VendorPortalContextValue,
|
||||||
|
} from "@/app/v/_components/vendor-portal-context";
|
||||||
|
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
|
||||||
|
import { REFUSE_REASON_MAX_LENGTH } from "@/domain/vendor-portal/schemas/refuse-dispatch-schema";
|
||||||
|
import { queryKeys } from "@/infra/query-key/query-key";
|
||||||
|
import { createTestQueryClient, renderWithProviders } from "@/test/test-utils";
|
||||||
|
|
||||||
|
function renderDialog({
|
||||||
|
token = "portal-token",
|
||||||
|
dispatchId = 9,
|
||||||
|
onClose = vi.fn(),
|
||||||
|
onRefused = vi.fn(),
|
||||||
|
}: Partial<{
|
||||||
|
token: string;
|
||||||
|
dispatchId: number;
|
||||||
|
onClose: () => void;
|
||||||
|
onRefused: () => void;
|
||||||
|
}> = {}) {
|
||||||
|
const queryClient = createTestQueryClient();
|
||||||
|
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
|
||||||
|
|
||||||
|
const portalValue: VendorPortalContextValue = {
|
||||||
|
token,
|
||||||
|
vendor: { companyName: "Gateway Plumbing", vendorId: 101 },
|
||||||
|
status: "ready",
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const utils = renderWithProviders(
|
||||||
|
<VendorPortalContext.Provider value={portalValue}>
|
||||||
|
<RefuseDispatchDialog open dispatchId={dispatchId} onClose={onClose} onRefused={onRefused} />
|
||||||
|
</VendorPortalContext.Provider>,
|
||||||
|
{ withAuth: false, queryClient },
|
||||||
|
);
|
||||||
|
|
||||||
|
return { ...utils, invalidateSpy, queryClient };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(vendorPortalApi, "refuse").mockResolvedValue({ id: 9, status: "Refused" });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("RefuseDispatchDialog", () => {
|
||||||
|
it("submits without a reason when the field is left empty", async () => {
|
||||||
|
renderDialog();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Refuse dispatch" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(vendorPortalApi.refuse).toHaveBeenCalledWith("portal-token", 9, undefined),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trims a supplied reason before sending", async () => {
|
||||||
|
renderDialog();
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText(/Reason/i), { target: { value: " too busy " } });
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Refuse dispatch" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(vendorPortalApi.refuse).toHaveBeenCalledWith("portal-token", 9, "too busy"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks submission when the reason exceeds the length cap", async () => {
|
||||||
|
renderDialog();
|
||||||
|
|
||||||
|
const overLimit = "a".repeat(REFUSE_REASON_MAX_LENGTH + 1);
|
||||||
|
fireEvent.change(screen.getByLabelText(/Reason/i), { target: { value: overLimit } });
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByRole("button", { name: "Refuse dispatch" })).toBeDisabled(),
|
||||||
|
);
|
||||||
|
expect(vendorPortalApi.refuse).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a live character counter bounded by the limit", () => {
|
||||||
|
renderDialog();
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText(/Reason/i), { target: { value: "abc" } });
|
||||||
|
|
||||||
|
expect(screen.getByText(`3/${REFUSE_REASON_MAX_LENGTH}`)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("invalidates the detail and list cache and closes on success", async () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
const onRefused = vi.fn();
|
||||||
|
const { invalidateSpy } = renderDialog({ onClose, onRefused });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Refuse dispatch" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||||
|
expect(onRefused).toHaveBeenCalled();
|
||||||
|
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||||
|
queryKey: queryKeys.vendorPortal.dispatch("portal-token", 9),
|
||||||
|
});
|
||||||
|
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||||
|
queryKey: queryKeys.vendorPortal.dispatches("portal-token"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces a safe inline error and keeps the dialog open on failure", async () => {
|
||||||
|
vi.spyOn(vendorPortalApi, "refuse").mockRejectedValueOnce(new Error("nope"));
|
||||||
|
const onClose = vi.fn();
|
||||||
|
renderDialog({ onClose });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Refuse dispatch" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByRole("alert")).toHaveTextContent(/Unable to refuse/i));
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes without refusing when cancel is pressed", async () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
renderDialog({ onClose });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||||
|
expect(vendorPortalApi.refuse).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks Escape and backdrop close while a refusal is pending, then closes on success", async () => {
|
||||||
|
let resolveRefusal!: (value: { id: number; status: string }) => void;
|
||||||
|
vi.spyOn(vendorPortalApi, "refuse").mockImplementation(
|
||||||
|
() =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
resolveRefusal = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const onClose = vi.fn();
|
||||||
|
renderDialog({ onClose });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Refuse dispatch" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByRole("button", { name: "Refusing…" })).toBeDisabled());
|
||||||
|
expect(screen.getByRole("button", { name: "Cancel" })).toBeDisabled();
|
||||||
|
|
||||||
|
const backdrop = document.querySelector(".MuiBackdrop-root");
|
||||||
|
expect(backdrop).not.toBeNull();
|
||||||
|
fireEvent.click(backdrop as Element);
|
||||||
|
fireEvent.keyDown(document.body, { key: "Escape" });
|
||||||
|
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
resolveRefusal({ id: 9, status: "Refused" });
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears the stale error and reason after closing and reopening", async () => {
|
||||||
|
vi.spyOn(vendorPortalApi, "refuse").mockRejectedValueOnce(new Error("nope"));
|
||||||
|
const onClose = vi.fn();
|
||||||
|
const onRefused = vi.fn();
|
||||||
|
|
||||||
|
const portalValue: VendorPortalContextValue = {
|
||||||
|
token: "portal-token",
|
||||||
|
vendor: { companyName: "Gateway Plumbing", vendorId: 101 },
|
||||||
|
status: "ready",
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
const renderEl = (open: boolean) => (
|
||||||
|
<VendorPortalContext.Provider value={portalValue}>
|
||||||
|
<RefuseDispatchDialog open={open} dispatchId={9} onClose={onClose} onRefused={onRefused} />
|
||||||
|
</VendorPortalContext.Provider>
|
||||||
|
);
|
||||||
|
|
||||||
|
const { rerender } = renderWithProviders(renderEl(true), { withAuth: false });
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText(/Reason/i), { target: { value: "too busy" } });
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Refuse dispatch" }));
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(screen.getByRole("alert")).toHaveTextContent(/Unable to refuse/i));
|
||||||
|
expect(screen.getByText(`8/${REFUSE_REASON_MAX_LENGTH}`)).toBeInTheDocument();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
rerender(renderEl(false));
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
rerender(renderEl(true));
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.queryByRole("alert")).not.toBeInTheDocument());
|
||||||
|
expect(screen.getByText(`0/${REFUSE_REASON_MAX_LENGTH}`)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
46
src/test/app/v/signoff-section.test.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { SignoffSection } from "@/app/v/[token]/dispatch/_components/signoff-section";
|
||||||
|
|
||||||
|
function renderSection(overrides: Partial<React.ComponentProps<typeof SignoffSection>> = {}) {
|
||||||
|
return render(
|
||||||
|
<SignoffSection
|
||||||
|
token="portal-token"
|
||||||
|
dispatchId={9}
|
||||||
|
existing={[]}
|
||||||
|
signoffType="customer"
|
||||||
|
nameLabel="Customer name"
|
||||||
|
onAdded={vi.fn()}
|
||||||
|
{...overrides}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("SignoffSection", () => {
|
||||||
|
it("shows status-neutral locked copy when submission is locked", () => {
|
||||||
|
renderSection({ submissionLocked: true });
|
||||||
|
|
||||||
|
expect(screen.getByText(/Signoff is unavailable for this dispatch/i)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/refused/i)).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: "Submit Signoff" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the submission form when unlocked", () => {
|
||||||
|
renderSection();
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: "Submit Signoff" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows an existing signoff even while locked", () => {
|
||||||
|
renderSection({
|
||||||
|
signoffType: "vendor",
|
||||||
|
nameLabel: "Your name",
|
||||||
|
submissionLocked: true,
|
||||||
|
existing: [{ signoffType: "vendor", name: "Grace Hopper", signatureMethod: "typed" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText("Grace Hopper")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/Signoff is unavailable/i)).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: "Submit Signoff" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
90
src/test/domain/vendor-portal/api/vendor-portal-api.test.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
|
||||||
|
|
||||||
|
type FetchCall = {
|
||||||
|
url: string;
|
||||||
|
method: string;
|
||||||
|
body: unknown;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function mockFetch(response: unknown, status = 200) {
|
||||||
|
const calls: FetchCall[] = [];
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async (url: string | URL, init?: RequestInit) => {
|
||||||
|
const body = init?.body ? JSON.parse(String(init.body)) : undefined;
|
||||||
|
const headers = (init?.headers ?? {}) as Record<string, string>;
|
||||||
|
calls.push({
|
||||||
|
url: String(url),
|
||||||
|
method: init?.method ?? "GET",
|
||||||
|
body,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
ok: status >= 200 && status < 300,
|
||||||
|
status,
|
||||||
|
json: async () =>
|
||||||
|
response == null
|
||||||
|
? ({} as Record<string, unknown>)
|
||||||
|
: ({ data: response } as Record<string, unknown>),
|
||||||
|
} as Response;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return calls;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubEnv("VITE_API_BASE_URL", "");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("vendorPortalApi.refuse", () => {
|
||||||
|
it("POSTs to the refuse endpoint with a reason when provided", async () => {
|
||||||
|
const calls = mockFetch({ id: 9, status: "Refused", refusedAt: "2026-08-10T12:00:00Z" });
|
||||||
|
|
||||||
|
await vendorPortalApi.refuse("portal-token", 9, "Too far away");
|
||||||
|
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
expect(calls[0].url).toContain("/vendor-portal/dispatches/9/refuse");
|
||||||
|
expect(calls[0].method).toBe("POST");
|
||||||
|
expect(calls[0].headers["X-Vendor-Token"]).toBe("portal-token");
|
||||||
|
expect(calls[0].body).toEqual({ reason: "Too far away" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the reason from the body when none is supplied", async () => {
|
||||||
|
const calls = mockFetch({ id: 9, status: "Refused" });
|
||||||
|
|
||||||
|
await vendorPortalApi.refuse("portal-token", 9);
|
||||||
|
|
||||||
|
expect(calls[0].body).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the refused payload from the API", async () => {
|
||||||
|
mockFetch({ id: 9, status: "Refused", refusedAt: "2026-08-10T12:00:00Z" });
|
||||||
|
|
||||||
|
const result = await vendorPortalApi.refuse("portal-token", 9);
|
||||||
|
|
||||||
|
expect(result).toEqual({ id: 9, status: "Refused", refusedAt: "2026-08-10T12:00:00Z" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when the API rejects the refusal", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async () => ({
|
||||||
|
ok: false,
|
||||||
|
status: 409,
|
||||||
|
json: async () => ({ message: "Dispatch already terminal" }),
|
||||||
|
})) as unknown as typeof fetch,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(vendorPortalApi.refuse("portal-token", 9, "nope")).rejects.toThrow(
|
||||||
|
"Dispatch already terminal",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
45
src/test/domain/vendor-portal/lib/dispatch-filters.test.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
applyVendorPortalDispatchFilter,
|
||||||
|
getVendorPortalDispatchFilter,
|
||||||
|
} from "@/domain/vendor-portal/lib/dispatch-filters";
|
||||||
|
import type { VendorPortalDispatchSummary } from "@/domain/vendor-portal/types/vendor-portal";
|
||||||
|
|
||||||
|
const dispatch = (
|
||||||
|
id: number,
|
||||||
|
status: string,
|
||||||
|
scheduledDate?: string,
|
||||||
|
): VendorPortalDispatchSummary => ({
|
||||||
|
id,
|
||||||
|
dispatchNumber: `DSP-${id}`,
|
||||||
|
status,
|
||||||
|
scheduledDate,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("vendor portal dispatch filters", () => {
|
||||||
|
it("keeps Refused separate from the Open view", () => {
|
||||||
|
const dispatches = [dispatch(1, "Sent"), dispatch(2, "Refused")];
|
||||||
|
|
||||||
|
expect(
|
||||||
|
applyVendorPortalDispatchFilter(dispatches, getVendorPortalDispatchFilter("open")),
|
||||||
|
).toEqual([dispatches[0]]);
|
||||||
|
expect(
|
||||||
|
applyVendorPortalDispatchFilter(dispatches, getVendorPortalDispatchFilter("refused")),
|
||||||
|
).toEqual([dispatches[1]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sorts Upcoming by schedule without mutating the cached list", () => {
|
||||||
|
const later = dispatch(2, "Acknowledged", "2099-08-12T09:00:00Z");
|
||||||
|
const sooner = dispatch(1, "Sent", "2099-08-11T09:00:00Z");
|
||||||
|
const dispatches = [later, sooner];
|
||||||
|
|
||||||
|
const result = applyVendorPortalDispatchFilter(
|
||||||
|
dispatches,
|
||||||
|
getVendorPortalDispatchFilter("upcoming"),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual([sooner, later]);
|
||||||
|
expect(dispatches).toEqual([later, sooner]);
|
||||||
|
});
|
||||||
|
});
|
||||||
87
src/test/domain/vendor-portal/lib/status-helpers.test.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
formatVendorPortalDateTime,
|
||||||
|
isVendorPortalLockedStatus,
|
||||||
|
isVendorPortalTerminalStatus,
|
||||||
|
isVendorPortalUpcomingDispatch,
|
||||||
|
vendorPortalStatusClass,
|
||||||
|
} from "@/domain/vendor-portal/lib/status-helpers";
|
||||||
|
|
||||||
|
describe("vendorPortalStatusClass", () => {
|
||||||
|
it("applies a distinct class for the Refused status", () => {
|
||||||
|
expect(vendorPortalStatusClass("Refused")).toBe("vp-status-pill vp-status-refused");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the established class for existing statuses", () => {
|
||||||
|
expect(vendorPortalStatusClass("Sent")).toBe("vp-status-pill vp-status-sent");
|
||||||
|
expect(vendorPortalStatusClass("Cancelled")).toBe("vp-status-pill vp-status-cancelled");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isVendorPortalLockedStatus", () => {
|
||||||
|
it("adds Refused without regressing the established Completed behavior", () => {
|
||||||
|
expect(isVendorPortalLockedStatus("Refused")).toBe(true);
|
||||||
|
expect(isVendorPortalLockedStatus("Verified")).toBe(true);
|
||||||
|
expect(isVendorPortalLockedStatus("Cancelled")).toBe(true);
|
||||||
|
expect(isVendorPortalLockedStatus("Completed")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isVendorPortalTerminalStatus", () => {
|
||||||
|
it("treats Refused, Verified, Cancelled, and Completed as terminal", () => {
|
||||||
|
expect(isVendorPortalTerminalStatus("Refused")).toBe(true);
|
||||||
|
expect(isVendorPortalTerminalStatus("Verified")).toBe(true);
|
||||||
|
expect(isVendorPortalTerminalStatus("Cancelled")).toBe(true);
|
||||||
|
expect(isVendorPortalTerminalStatus("Completed")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats active statuses as non-terminal", () => {
|
||||||
|
expect(isVendorPortalTerminalStatus("Sent")).toBe(false);
|
||||||
|
expect(isVendorPortalTerminalStatus("Acknowledged")).toBe(false);
|
||||||
|
expect(isVendorPortalTerminalStatus("In Progress")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isVendorPortalUpcomingDispatch", () => {
|
||||||
|
const now = new Date("2026-08-10T12:00:00Z");
|
||||||
|
|
||||||
|
it("includes a non-terminal dispatch scheduled in the future", () => {
|
||||||
|
expect(
|
||||||
|
isVendorPortalUpcomingDispatch(
|
||||||
|
{ status: "Acknowledged", scheduledDate: "2026-08-11T09:00:00Z" },
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes terminal statuses even when scheduled in the future", () => {
|
||||||
|
for (const status of ["Refused", "Verified", "Cancelled", "Completed"]) {
|
||||||
|
expect(
|
||||||
|
isVendorPortalUpcomingDispatch({ status, scheduledDate: "2026-08-11T09:00:00Z" }, now),
|
||||||
|
).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes dispatches without a scheduled date", () => {
|
||||||
|
expect(isVendorPortalUpcomingDispatch({ status: "Sent", scheduledDate: undefined }, now)).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes past scheduled dates", () => {
|
||||||
|
expect(
|
||||||
|
isVendorPortalUpcomingDispatch(
|
||||||
|
{ status: "In Progress", scheduledDate: "2026-08-09T09:00:00Z" },
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatVendorPortalDateTime", () => {
|
||||||
|
it("returns an empty string for missing or invalid values", () => {
|
||||||
|
expect(formatVendorPortalDateTime(undefined)).toBe("");
|
||||||
|
expect(formatVendorPortalDateTime("not-a-date")).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
106
src/test/domain/vendors/utils/build-technician-create-payload.test.ts
vendored
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
mergeVendorDropdownItems,
|
||||||
|
resolveVendorCompanyAnchor,
|
||||||
|
} from "@/app/(protected)/workorders/_components/list/table/cells/resolve-vendor-company-anchor";
|
||||||
|
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||||
|
import { buildTechnicianCreatePayload } from "@/domain/vendors/utils/build-technician-create-payload";
|
||||||
|
import { vendorToDropdownItem } from "@/domain/vendors/utils/vendor-to-dropdown-item";
|
||||||
|
import type { Vendor } from "@/domain/vendors/types/vendor";
|
||||||
|
|
||||||
|
describe("buildTechnicianCreatePayload", () => {
|
||||||
|
it("copies company fields and sets the new technician identity", () => {
|
||||||
|
const payload = buildTechnicianCreatePayload({
|
||||||
|
companyVendor: {
|
||||||
|
companyName: "Gateway Plumbing",
|
||||||
|
email: "dispatch@gateway.test",
|
||||||
|
address: "1 Main St",
|
||||||
|
tradeSpecialties: "Plumbing",
|
||||||
|
},
|
||||||
|
contactName: "Jordan Lee",
|
||||||
|
phone: "(314) 555-0100",
|
||||||
|
companyId: 42,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(payload).toMatchObject({
|
||||||
|
companyName: "Gateway Plumbing",
|
||||||
|
contactName: "Jordan Lee",
|
||||||
|
phone: "(314) 555-0100",
|
||||||
|
email: "dispatch@gateway.test",
|
||||||
|
address: "1 Main St",
|
||||||
|
tradeSpecialties: "Plumbing",
|
||||||
|
companyId: 42,
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveVendorCompanyAnchor", () => {
|
||||||
|
const vendors: VendorDropdownItem[] = [
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
companyName: "Gateway Plumbing",
|
||||||
|
contactName: "Adam Whyte",
|
||||||
|
tradeSpecialties: "Plumbing",
|
||||||
|
address: "1 Main St",
|
||||||
|
distanceMiles: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
it("returns null anchor when no company context exists", () => {
|
||||||
|
expect(
|
||||||
|
resolveVendorCompanyAnchor({
|
||||||
|
company: "",
|
||||||
|
resolvedCompany: "",
|
||||||
|
selectedVendor: undefined,
|
||||||
|
vendors,
|
||||||
|
}),
|
||||||
|
).toEqual({ companyName: "", companyVendor: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges newly created vendors without dropping existing options", () => {
|
||||||
|
const added: VendorDropdownItem = {
|
||||||
|
id: 55,
|
||||||
|
companyName: "Gateway Plumbing",
|
||||||
|
contactName: "Jordan Lee",
|
||||||
|
tradeSpecialties: "Plumbing",
|
||||||
|
address: "1 Main St",
|
||||||
|
distanceMiles: null,
|
||||||
|
};
|
||||||
|
expect(mergeVendorDropdownItems(vendors, [added])).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("vendorToDropdownItem", () => {
|
||||||
|
it("maps a created vendor into the work-order dropdown shape", () => {
|
||||||
|
const vendor: Vendor = {
|
||||||
|
id: 99,
|
||||||
|
companyId: "co-1",
|
||||||
|
companyName: "Gateway Plumbing",
|
||||||
|
contactName: "Jordan Lee",
|
||||||
|
email: "jordan@gateway.test",
|
||||||
|
phone: "(314) 555-0100",
|
||||||
|
companyPhone: "",
|
||||||
|
googleMapsUrl: "",
|
||||||
|
notes: "",
|
||||||
|
totalJobs: 0,
|
||||||
|
address: "1 Main St",
|
||||||
|
city: "St. Louis",
|
||||||
|
state: "MO",
|
||||||
|
zip: "63101",
|
||||||
|
tradeSpecialties: "Plumbing",
|
||||||
|
isActive: true,
|
||||||
|
preferredContact: "Phone",
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(vendorToDropdownItem(vendor)).toEqual({
|
||||||
|
id: 99,
|
||||||
|
companyName: "Gateway Plumbing",
|
||||||
|
contactName: "Jordan Lee",
|
||||||
|
tradeSpecialties: "Plumbing",
|
||||||
|
address: "1 Main St",
|
||||||
|
distanceMiles: null,
|
||||||
|
email: "jordan@gateway.test",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -124,6 +124,24 @@ describe("expandTablePatchToBoardFieldOps", () => {
|
||||||
}),
|
}),
|
||||||
).toEqual([{ field: "siteCode", value: "BK5" }]);
|
).toEqual([{ field: "siteCode", value: "BK5" }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps dueDate and scheduledDate patches independent", () => {
|
||||||
|
expect(expandTablePatchToBoardFieldOps({ dueDate: "2026-08-01" })).toEqual([
|
||||||
|
{ field: "dueDate", value: "2026-08-01" },
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
expandTablePatchToBoardFieldOps({
|
||||||
|
scheduledOn: "2026-07-15",
|
||||||
|
scheduleWeekOnly: false,
|
||||||
|
}),
|
||||||
|
).toEqual([
|
||||||
|
{ field: "scheduleWeekOnly", value: "false" },
|
||||||
|
{ field: "scheduledDate", value: "2026-07-15" },
|
||||||
|
]);
|
||||||
|
expect(expandTablePatchToBoardFieldOps({ dueDate: "2026-08-01", isPastDue: false })).toEqual([
|
||||||
|
{ field: "dueDate", value: "2026-08-01" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("buildBoardPatchPayload", () => {
|
describe("buildBoardPatchPayload", () => {
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@ describe("formatBoardAuditAction", () => {
|
||||||
oldValue: "false",
|
oldValue: "false",
|
||||||
newValue: "true",
|
newValue: "true",
|
||||||
}),
|
}),
|
||||||
).toBe("WO marked as Past Due. Scheduled date has passed without completion.");
|
).toBe("WO marked as Past Due. Due date has passed without completion.");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to from → to for unknown fields", () => {
|
it("falls back to from → to for unknown fields", () => {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-ta
|
||||||
import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters";
|
import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters";
|
||||||
import {
|
import {
|
||||||
buildDayGroups,
|
buildDayGroups,
|
||||||
|
computeIsPastDue,
|
||||||
|
enrichTableRow,
|
||||||
matchesAdvancedFilters,
|
matchesAdvancedFilters,
|
||||||
splitScheduledUnscheduled,
|
splitScheduledUnscheduled,
|
||||||
} from "@/domain/work-orders/utils/table-filter-utils";
|
} from "@/domain/work-orders/utils/table-filter-utils";
|
||||||
|
|
@ -10,6 +12,11 @@ import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
||||||
import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-mapper";
|
import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-mapper";
|
||||||
import { mondayOf, todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
|
import { mondayOf, todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||||
|
|
||||||
|
function shiftIso(days: number): string {
|
||||||
|
const d = new Date(`${todayIso()}T12:00:00.000Z`);
|
||||||
|
d.setUTCDate(d.getUTCDate() + days);
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
function makeRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
|
function makeRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
|
||||||
return {
|
return {
|
||||||
id: "1",
|
id: "1",
|
||||||
|
|
@ -123,6 +130,122 @@ describe("matchesAdvancedFilters", () => {
|
||||||
};
|
};
|
||||||
expect(matchesAdvancedFilters(row, noMatch)).toBe(false);
|
expect(matchesAdvancedFilters(row, noMatch)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("matches Overdue when isPastDue is true", () => {
|
||||||
|
const weekMonday = mondayOf(todayIso());
|
||||||
|
const pastDue = makeRow({
|
||||||
|
scheduledOn: weekMonday,
|
||||||
|
...deriveDayFields(weekMonday),
|
||||||
|
isPastDue: true,
|
||||||
|
});
|
||||||
|
const filters = {
|
||||||
|
...EMPTY_ADVANCED_FILTERS,
|
||||||
|
dateRange: "custom" as const,
|
||||||
|
customFrom: "0000-01-01",
|
||||||
|
customTo: "9999-12-31",
|
||||||
|
types: new Set(["Overdue" as const]),
|
||||||
|
};
|
||||||
|
expect(matchesAdvancedFilters(pastDue, filters)).toBe(true);
|
||||||
|
expect(
|
||||||
|
matchesAdvancedFilters(makeRow({ isPastDue: false, scheduledOn: weekMonday }), filters),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("computeIsPastDue", () => {
|
||||||
|
it("is true when dueDate is before today even if scheduledOn is future", () => {
|
||||||
|
expect(
|
||||||
|
computeIsPastDue(
|
||||||
|
makeRow({
|
||||||
|
dueDate: shiftIso(-1),
|
||||||
|
scheduledOn: shiftIso(5),
|
||||||
|
status: "Scheduled",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is false when dueDate is future even if scheduledOn is past", () => {
|
||||||
|
expect(
|
||||||
|
computeIsPastDue(
|
||||||
|
makeRow({
|
||||||
|
dueDate: shiftIso(5),
|
||||||
|
scheduledOn: shiftIso(-1),
|
||||||
|
status: "Incomplete",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is false when dueDate is empty", () => {
|
||||||
|
expect(
|
||||||
|
computeIsPastDue(
|
||||||
|
makeRow({
|
||||||
|
dueDate: "",
|
||||||
|
scheduledOn: shiftIso(-3),
|
||||||
|
status: "Scheduled",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is false for terminal statuses even with past dueDate", () => {
|
||||||
|
expect(
|
||||||
|
computeIsPastDue(
|
||||||
|
makeRow({
|
||||||
|
dueDate: shiftIso(-2),
|
||||||
|
scheduledOn: shiftIso(-2),
|
||||||
|
status: "Completed",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
computeIsPastDue(
|
||||||
|
makeRow({
|
||||||
|
dueDate: shiftIso(-2),
|
||||||
|
status: "Canceled",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is false when dueDate is today", () => {
|
||||||
|
expect(
|
||||||
|
computeIsPastDue(
|
||||||
|
makeRow({
|
||||||
|
dueDate: todayIso(),
|
||||||
|
scheduledOn: shiftIso(-1),
|
||||||
|
status: "Scheduled",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("enrichTableRow", () => {
|
||||||
|
it("recomputes isPastDue from dueDate and ignores sticky API true", () => {
|
||||||
|
const enriched = enrichTableRow(
|
||||||
|
makeRow({
|
||||||
|
dueDate: shiftIso(5),
|
||||||
|
scheduledOn: shiftIso(-1),
|
||||||
|
isPastDue: true,
|
||||||
|
status: "Scheduled",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(enriched.isPastDue).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets isPastDue true from past dueDate even when API flag is false", () => {
|
||||||
|
const enriched = enrichTableRow(
|
||||||
|
makeRow({
|
||||||
|
dueDate: shiftIso(-1),
|
||||||
|
scheduledOn: shiftIso(5),
|
||||||
|
isPastDue: false,
|
||||||
|
status: "Incomplete",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(enriched.isPastDue).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("mapWorkOrderTableRow", () => {
|
describe("mapWorkOrderTableRow", () => {
|
||||||
|
|
|
||||||
27
tmp/pr-descriptions/pr7-add-technician.md
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
## Jira
|
||||||
|
|
||||||
|
- [SH-193](https://luby-us.atlassian.net/browse/SH-193)
|
||||||
|
|
||||||
|
## QA residual
|
||||||
|
|
||||||
|
- P0 done: technician is staged as a **local draft** until the vendor dialog is saved / wizard submitted — Cancel does not orphan a vendor row.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
- Adds **+ Add technician** to the WO board vendor dialog when a company context is known (from the row or the current selection).
|
||||||
|
- Stages a draft technician locally (`draft:` id) without calling `POST vendors` until the dialog save commits the selection.
|
||||||
|
- On save, create runs via the existing **`POST vendors`** REST endpoint (`vendorsApi.create` / `useCreateVendor`) using the company profile + new `contactName`.
|
||||||
|
|
||||||
|
## Backend
|
||||||
|
|
||||||
|
Uses the established vendor create API (`API_PATHS.rest.vendors` → `POST /vendors`). Each vendor record represents one technician; linking to an existing company is done by sending the same `companyName` (and optional `companyId` when available). **No new endpoint**.
|
||||||
|
|
||||||
|
## Test plan
|
||||||
|
|
||||||
|
- [ ] Open vendor dialog → **+ Add technician** → fill name → Cancel dialog → no vendor create API call.
|
||||||
|
- [ ] Add technician → Save dialog → `POST /vendors` once; row shows new tech.
|
||||||
|
- [ ] `npm run verify`
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Multi-POC, multi-tech arrays on WO, vendors directory redesign.
|
||||||