mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 05:43:12 +00:00
feat(vendors): add company roster workflows (SH-198) (#64)
* feat(vendors): support company roster workflows * fix(vendors): address roster review feedback * test(vendors): preserve draft on roster load failure * fix(vendors): preserve current roster selection
This commit is contained in:
parent
297efa8f73
commit
ca7829036f
45 changed files with 4109 additions and 2044 deletions
196
e2e/vendors/vendors.spec.ts
vendored
196
e2e/vendors/vendors.spec.ts
vendored
|
|
@ -147,7 +147,7 @@ async function mockVendorApi(
|
||||||
const filtered = source.filter(
|
const filtered = source.filter(
|
||||||
(vendor) =>
|
(vendor) =>
|
||||||
(!search ||
|
(!search ||
|
||||||
`${vendor.CompanyName} ${vendor.ContactName} ${vendor.TradeSpecialties}`
|
`${vendor.CompanyName} ${vendor.ContactName} ${vendor.TradeSpecialties} ${vendor.City} ${vendor.State}`
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.includes(search)) &&
|
.includes(search)) &&
|
||||||
(!company || vendor.CompanyName === company),
|
(!company || vendor.CompanyName === company),
|
||||||
|
|
@ -160,6 +160,75 @@ async function mockVendorApi(
|
||||||
PageSize: Number(url.searchParams.get("pageSize") ?? 12),
|
PageSize: Number(url.searchParams.get("pageSize") ?? 12),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
await page.route(/\/api\/vendor-company-roster(?:\/\d+)?(?:\?.*)?$/, async (route) => {
|
||||||
|
const request = route.request();
|
||||||
|
const url = new URL(request.url());
|
||||||
|
const pathCompanyId = url.pathname.match(/vendor-company-roster\/(\d+)$/)?.[1];
|
||||||
|
|
||||||
|
if (request.method() === "POST") {
|
||||||
|
state.createdBody = request.postDataJSON();
|
||||||
|
await fulfillJson(route, {
|
||||||
|
...state.createdBody,
|
||||||
|
companyId: 999,
|
||||||
|
rowVersion: "rv-created",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method() === "PUT" && pathCompanyId) {
|
||||||
|
state.updatedBody = request.postDataJSON();
|
||||||
|
const technicians = Array.isArray(state.updatedBody.technicians)
|
||||||
|
? (state.updatedBody.technicians as Array<Record<string, unknown>>)
|
||||||
|
: [];
|
||||||
|
for (const technician of technicians) {
|
||||||
|
const vendor = vendorRecords.find((item) => String(item.Id) === String(technician.id));
|
||||||
|
if (vendor && typeof technician.isActive === "boolean") {
|
||||||
|
vendor.IsActive = technician.isActive;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await fulfillJson(route, {
|
||||||
|
...state.updatedBody,
|
||||||
|
companyId: Number(pathCompanyId),
|
||||||
|
rowVersion: "rv-updated",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vendorId = url.searchParams.get("vendorId");
|
||||||
|
const requestedCompanyId = url.searchParams.get("companyId") ?? pathCompanyId;
|
||||||
|
const anchor = vendorId
|
||||||
|
? vendorRecords.find((vendor) => String(vendor.Id) === vendorId)
|
||||||
|
: vendorRecords.find((vendor) => String(vendor.CompanyId) === requestedCompanyId);
|
||||||
|
if (!anchor) {
|
||||||
|
await fulfillJson(route, { message: "Vendor roster not found" }, 404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await fulfillJson(route, {
|
||||||
|
companyId: anchor.CompanyId,
|
||||||
|
rowVersion: "rv-1",
|
||||||
|
name: anchor.CompanyName,
|
||||||
|
companyPhone: anchor.CompanyPhone,
|
||||||
|
email: anchor.Email,
|
||||||
|
address: anchor.Address,
|
||||||
|
city: anchor.City,
|
||||||
|
state: anchor.State,
|
||||||
|
zip: anchor.Zip,
|
||||||
|
googleMapsUrl: anchor.GoogleMapsUrl,
|
||||||
|
notes: anchor.Notes,
|
||||||
|
technicians: vendorRecords
|
||||||
|
.filter((vendor) => vendor.CompanyId === anchor.CompanyId)
|
||||||
|
.map((vendor) => ({
|
||||||
|
id: vendor.Id,
|
||||||
|
contactName: vendor.ContactName,
|
||||||
|
phone: vendor.Phone,
|
||||||
|
email: vendor.Email,
|
||||||
|
preferredContact: vendor.PreferredContact ?? "Phone",
|
||||||
|
tradeSpecialties: vendor.TradeSpecialties,
|
||||||
|
isActive: vendor.IsActive,
|
||||||
|
totalJobs: vendor.TotalJobs,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
await page.route(/\/api\/vendors\/\d+\/deactivation-impact$/, async (route) => {
|
await page.route(/\/api\/vendors\/\d+\/deactivation-impact$/, async (route) => {
|
||||||
const vendorId = Number(route.request().url().split("/").at(-2));
|
const vendorId = Number(route.request().url().split("/").at(-2));
|
||||||
await fulfillJson(route, {
|
await fulfillJson(route, {
|
||||||
|
|
@ -181,6 +250,15 @@ async function mockVendorApi(
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
await page.route(/\/api\/vendors\/\d+\/portal-token$/, (route) =>
|
||||||
|
fulfillJson(route, {
|
||||||
|
data: {
|
||||||
|
portalUrl: "https://vendor.example.test/token",
|
||||||
|
issuedAt: "2026-07-31T12:00:00Z",
|
||||||
|
expiresAt: "2026-08-07T12:00:00Z",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
await page.route(/\/api\/vendors\/\d+$/, async (route) => {
|
await page.route(/\/api\/vendors\/\d+$/, async (route) => {
|
||||||
const id = route.request().url().split("/").pop() ?? "";
|
const id = route.request().url().split("/").pop() ?? "";
|
||||||
if (route.request().method() === "PUT") {
|
if (route.request().method() === "PUT") {
|
||||||
|
|
@ -232,7 +310,7 @@ test.describe("Vendor directory prototype parity", () => {
|
||||||
await page.goto("/vendors");
|
await page.goto("/vendors");
|
||||||
|
|
||||||
await expect(page.getByRole("heading", { name: "Vendors / Technicians" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Vendors / Technicians" })).toBeVisible();
|
||||||
await expect(page.getByText("3 technicians · 2 active")).toBeVisible();
|
await expect(page.getByText("3 vendor entries · 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",
|
||||||
|
|
@ -251,16 +329,19 @@ test.describe("Vendor directory prototype parity", () => {
|
||||||
}
|
}
|
||||||
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 technicians · 2 active · 1 inactive")).toBeVisible();
|
await expect(page.getByText("2 active vendor entries · 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 technician · filtered/)).toBeVisible();
|
await expect(page.getByText(/1 active vendor entry · 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 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();
|
||||||
|
|
@ -270,7 +351,7 @@ test.describe("Vendor directory prototype parity", () => {
|
||||||
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" }).click();
|
||||||
await expect(page.getByText("Gateway Plumbing").first()).toBeVisible();
|
await expect(page.getByText("Gateway Plumbing").first()).toBeVisible();
|
||||||
await expect(page.getByText(/1 active technician · filtered/)).toBeVisible();
|
await expect(page.getByText(/1 active vendor entry · 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",
|
||||||
|
|
@ -291,46 +372,60 @@ test.describe("Vendor directory prototype parity", () => {
|
||||||
await expect(page.getByRole("button", { name: "Deactivate vendor" })).toHaveCount(0);
|
await expect(page.getByRole("button", { name: "Deactivate vendor" })).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("creates with company autofill, then views and edits every detail field", async ({
|
test("adds a technician to an existing company, then views and edits the aggregate", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
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" }).click();
|
||||||
await expect(page.getByText("Register a technician and their vendor company.")).toBeVisible();
|
await expect(
|
||||||
|
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" }).last()).toBeDisabled();
|
await expect(page.getByRole("button", { name: "Add Vendor Company" }).last()).toBeDisabled();
|
||||||
|
|
||||||
await page.getByLabel("Technician Name").fill("New Technician");
|
|
||||||
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")).toHaveValue("314-555-0100");
|
||||||
await expect(page.getByRole("textbox", { name: "Email" })).toHaveValue("dispatch@gateway.test");
|
await expect(page.getByRole("textbox", { name: "Email", exact: true })).toHaveValue(
|
||||||
|
"dispatch@gateway.test",
|
||||||
|
);
|
||||||
await expect(page.getByLabel("Address")).toHaveValue("1 Market St");
|
await expect(page.getByLabel("Address")).toHaveValue("1 Market St");
|
||||||
await expect(page.getByLabel("Google Maps URL")).toHaveValue("https://maps.google.com/gateway");
|
await expect(page.getByLabel("Google Maps URL")).toHaveValue("https://maps.google.com/gateway");
|
||||||
await page.getByRole("combobox", { name: "Add Trade" }).fill("Plumbing");
|
await expect(page.getByLabel("Preferred Contact")).toHaveCount(0);
|
||||||
await page.getByRole("button", { name: "Add trade" }).click();
|
await page.getByRole("button", { name: "Add technician" }).click();
|
||||||
await page.getByRole("combobox", { name: "Add Trade" }).fill("HVAC");
|
await page.getByLabel("Technician Name").last().fill("New Technician");
|
||||||
await page.getByRole("button", { name: "Add trade" }).click();
|
const tradeInput = page.getByRole("combobox", { name: "Add Trade" }).last();
|
||||||
await expect(page.getByRole("button", { name: "Move Plumbing up" })).toHaveCount(0);
|
await tradeInput.fill("HVAC");
|
||||||
await expect(page.getByRole("button", { name: "Move HVAC down" })).toHaveCount(0);
|
await tradeInput.press("Enter");
|
||||||
await expect(page.getByRole("button", { name: "Move HVAC up" })).toBeVisible();
|
await tradeInput.fill("Plumbing");
|
||||||
await expect(page.getByRole("button", { name: "Move Plumbing down" })).toBeVisible();
|
await tradeInput.press("Enter");
|
||||||
await page.getByRole("button", { name: "Move HVAC up" }).click();
|
await page.getByRole("button", { name: "Move Plumbing up" }).click();
|
||||||
await page.getByLabel("Notes").fill("Created in browser E2E");
|
await page.getByLabel("Notes").fill("Created in browser E2E");
|
||||||
await expect(page.getByText("Preferred Contact")).toHaveCount(0);
|
await page.getByRole("button", { name: "Add Vendor Company" }).last().click();
|
||||||
await page.getByRole("button", { name: "Add Vendor" }).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.createdBody).not.toHaveProperty("preferredContact");
|
name: "Gateway Plumbing",
|
||||||
expect(state.createdBody).toMatchObject({
|
|
||||||
companyName: "Gateway Plumbing",
|
|
||||||
companyId: 101,
|
|
||||||
contactName: "New Technician",
|
|
||||||
companyPhone: "(314) 555-0100",
|
companyPhone: "(314) 555-0100",
|
||||||
tradeSpecialties: "HVAC, Plumbing",
|
|
||||||
notes: "Created in browser E2E",
|
notes: "Created in browser E2E",
|
||||||
|
rowVersion: "rv-1",
|
||||||
});
|
});
|
||||||
|
expect(state.updatedBody?.technicians).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
contactName: "New Technician",
|
||||||
|
tradeSpecialties: "Plumbing, HVAC",
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const submittedTechnicians = Array.isArray(state.updatedBody?.technicians)
|
||||||
|
? (state.updatedBody.technicians as Array<Record<string, unknown>>)
|
||||||
|
: [];
|
||||||
|
const newTechnician = submittedTechnicians.find(
|
||||||
|
(technician) => technician.contactName === "New Technician",
|
||||||
|
);
|
||||||
|
expect(newTechnician?.preferredContact).toBeUndefined();
|
||||||
|
|
||||||
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();
|
||||||
|
|
@ -343,13 +438,10 @@ 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(detailDrawer.getByLabel("Total Jobs")).toHaveValue("72");
|
await expect(page).toHaveURL(/\/vendors\/edit\/1$/);
|
||||||
await expect(detailDrawer.getByLabel("Total Jobs")).toHaveAttribute("readonly");
|
await page.getByLabel("Notes").fill("Updated in browser E2E");
|
||||||
await detailDrawer.getByLabel("Notes").fill("Updated in browser E2E");
|
await page.getByRole("button", { name: "Save" }).click();
|
||||||
await detailDrawer.getByRole("switch", { name: "Active" }).click();
|
|
||||||
await detailDrawer.getByRole("button", { name: "Save" }).click();
|
|
||||||
await expect.poll(() => state.updatedBody?.notes).toBe("Updated in browser E2E");
|
await expect.poll(() => state.updatedBody?.notes).toBe("Updated in browser E2E");
|
||||||
expect(state.updatedBody?.isActive).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("reactivates a legacy inactive vendor with an exact sparse update", async ({ page }) => {
|
test("reactivates a legacy inactive vendor with an exact sparse update", async ({ page }) => {
|
||||||
|
|
@ -364,15 +456,11 @@ 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 detailDrawer.getByRole("button", { name: "Edit" }).click();
|
await detailDrawer.getByRole("button", { name: "Edit" }).click();
|
||||||
await expect(detailDrawer.getByRole("button", { name: "Save" })).toBeDisabled();
|
await expect(page).toHaveURL(/\/vendors\/edit\/1$/);
|
||||||
|
await page.getByRole("switch", { name: "Inactive" }).first().click();
|
||||||
await detailDrawer.getByRole("switch", { name: "Inactive" }).click();
|
await page.getByRole("button", { name: "Save" }).click();
|
||||||
await detailDrawer.getByRole("button", { name: "Save" }).click();
|
|
||||||
await expect.poll(() => state.updatedBody).toEqual({ isActive: true });
|
await expect.poll(() => state.updatedBody).toEqual({ isActive: true });
|
||||||
await expect(detailDrawer.getByText("Active").first()).toBeVisible();
|
await page.getByRole("tab", { name: "Active (2)", exact: true }).click();
|
||||||
|
|
||||||
await detailDrawer.getByRole("button", { name: "Close drawer" }).click();
|
|
||||||
await page.getByRole("tab", { name: "Active (2)" }).click();
|
|
||||||
await expect(page.getByText("Gateway Plumbing").first()).toBeVisible();
|
await expect(page.getByText("Gateway Plumbing").first()).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -389,13 +477,27 @@ 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" }).last()).toBeDisabled();
|
await expect(page.getByRole("button", { name: "Add Vendor Company" }).last()).toBeDisabled();
|
||||||
await page.getByLabel("Technician Name").fill("Independent Tech");
|
|
||||||
await page.getByRole("combobox", { name: "Company" }).fill("Independent Vendor LLC");
|
await page.getByRole("combobox", { name: "Company" }).fill("Independent Vendor LLC");
|
||||||
await page.getByRole("button", { name: "Add Vendor" }).last().click();
|
await page.getByLabel("Company Phone").fill("3145550199");
|
||||||
|
await page.getByRole("button", { name: "Add Vendor Company" }).last().click();
|
||||||
|
|
||||||
await expect.poll(() => state.createdBody?.companyName).toBe("Independent Vendor LLC");
|
await expect.poll(() => state.createdBody?.name).toBe("Independent Vendor LLC");
|
||||||
expect(state.createdBody?.companyId).toBeNull();
|
expect(state.createdBody?.technicians).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("closes vendor details with Escape and keeps only Edit in the footer", async ({ page }) => {
|
||||||
|
await mockVendorApi(page);
|
||||||
|
await page.goto("/vendors");
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click();
|
||||||
|
const detailDrawer = page.locator(".MuiDrawer-paper").last();
|
||||||
|
await expect(detailDrawer).toBeVisible();
|
||||||
|
await expect(detailDrawer.getByRole("button", { name: "Edit" })).toBeVisible();
|
||||||
|
await expect(detailDrawer.getByRole("button", { name: "Maps" })).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(page.getByRole("button", { name: "Close drawer" })).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("blocks deactivation for linked work orders and preserves the vendor on a raced 409", async ({
|
test("blocks deactivation for linked work orders and preserves the vendor on a raced 409", async ({
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { execFileSync, spawnSync } from "node:child_process";
|
import { execFileSync, spawnSync } from "node:child_process";
|
||||||
import { readFileSync } from "node:fs";
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
|
@ -38,7 +38,9 @@ function gitLines(args) {
|
||||||
function governedFiles() {
|
function governedFiles() {
|
||||||
const tracked = gitLines(["ls-files"]);
|
const tracked = gitLines(["ls-files"]);
|
||||||
const untracked = gitLines(["ls-files", "--others", "--exclude-standard"]);
|
const untracked = gitLines(["ls-files", "--others", "--exclude-standard"]);
|
||||||
return [...new Set([...tracked, ...untracked])].filter(isGoverned);
|
return [...new Set([...tracked, ...untracked])].filter(
|
||||||
|
(relativePath) => isGoverned(relativePath) && existsSync(path.join(ROOT, relativePath)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function lineCount(relativePath) {
|
function lineCount(relativePath) {
|
||||||
|
|
|
||||||
224
scripts/vendor-e2e-mock-server.mjs
Normal file
224
scripts/vendor-e2e-mock-server.mjs
Normal file
|
|
@ -0,0 +1,224 @@
|
||||||
|
import http from "node:http";
|
||||||
|
|
||||||
|
const port = Number(process.env.VENDOR_MOCK_PORT ?? 5142);
|
||||||
|
let nextCompanyId = 103;
|
||||||
|
let nextTechnicianId = 3;
|
||||||
|
const companies = [
|
||||||
|
{
|
||||||
|
companyId: 101,
|
||||||
|
rowVersion: "rv-101-1",
|
||||||
|
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: "adam@gateway.test",
|
||||||
|
preferredContact: "Email",
|
||||||
|
tradeSpecialties: "Plumbing",
|
||||||
|
isActive: true,
|
||||||
|
totalJobs: 72,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
companyId: 102,
|
||||||
|
rowVersion: "rv-102-1",
|
||||||
|
name: "Metro HVAC",
|
||||||
|
companyPhone: "(314) 555-0110",
|
||||||
|
email: "dispatch@metro.test",
|
||||||
|
address: "2 Pine St",
|
||||||
|
city: "Clayton",
|
||||||
|
state: "MO",
|
||||||
|
zip: "63105",
|
||||||
|
googleMapsUrl: "",
|
||||||
|
notes: "",
|
||||||
|
technicians: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function json(response, body, status = 200) {
|
||||||
|
response.writeHead(status, {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"access-control-allow-origin": "*",
|
||||||
|
});
|
||||||
|
response.end(JSON.stringify(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readBody(request) {
|
||||||
|
const chunks = [];
|
||||||
|
for await (const chunk of request) chunks.push(chunk);
|
||||||
|
return chunks.length ? JSON.parse(Buffer.concat(chunks).toString("utf8")) : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function findCompany(url) {
|
||||||
|
const vendorId = url.searchParams.get("vendorId");
|
||||||
|
const companyId = url.searchParams.get("companyId");
|
||||||
|
if (vendorId) {
|
||||||
|
return companies.find((company) =>
|
||||||
|
company.technicians.some((technician) => String(technician.id) === vendorId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return companies.find((company) => String(company.companyId) === companyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function companyFacet(company) {
|
||||||
|
return {
|
||||||
|
companyId: company.companyId,
|
||||||
|
name: company.name,
|
||||||
|
companyPhone: company.companyPhone,
|
||||||
|
email: company.email,
|
||||||
|
address: company.address,
|
||||||
|
city: company.city,
|
||||||
|
state: company.state,
|
||||||
|
zip: company.zip,
|
||||||
|
googleMapsUrl: company.googleMapsUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function directoryRows() {
|
||||||
|
return companies.flatMap((company) =>
|
||||||
|
company.technicians.length
|
||||||
|
? company.technicians.map((technician) => ({
|
||||||
|
Id: technician.id,
|
||||||
|
CompanyId: company.companyId,
|
||||||
|
CompanyName: company.name,
|
||||||
|
ContactName: technician.contactName,
|
||||||
|
Email: technician.email,
|
||||||
|
Phone: technician.phone,
|
||||||
|
CompanyPhone: company.companyPhone,
|
||||||
|
City: company.city,
|
||||||
|
State: company.state,
|
||||||
|
TradeSpecialties: technician.tradeSpecialties,
|
||||||
|
GoogleMapsUrl: company.googleMapsUrl,
|
||||||
|
Notes: company.notes,
|
||||||
|
TotalJobs: technician.totalJobs,
|
||||||
|
IsActive: technician.isActive,
|
||||||
|
PreferredContact: technician.preferredContact,
|
||||||
|
}))
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
Id: null,
|
||||||
|
CompanyId: company.companyId,
|
||||||
|
CompanyName: company.name,
|
||||||
|
ContactName: "",
|
||||||
|
Email: company.email,
|
||||||
|
Phone: "",
|
||||||
|
CompanyPhone: company.companyPhone,
|
||||||
|
City: company.city,
|
||||||
|
State: company.state,
|
||||||
|
TradeSpecialties: "",
|
||||||
|
GoogleMapsUrl: company.googleMapsUrl,
|
||||||
|
Notes: company.notes,
|
||||||
|
TotalJobs: 0,
|
||||||
|
IsActive: true,
|
||||||
|
PreferredContact: "Phone",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = http.createServer(async (request, response) => {
|
||||||
|
const url = new URL(request.url ?? "/", `http://${request.headers.host}`);
|
||||||
|
if (request.method === "OPTIONS") return json(response, {});
|
||||||
|
|
||||||
|
if (url.pathname === "/api/Vendor/GetVendorList") {
|
||||||
|
const search = (url.searchParams.get("search") ?? "").toLowerCase();
|
||||||
|
const active = url.searchParams.get("isActive") !== "false";
|
||||||
|
const rows = directoryRows().filter(
|
||||||
|
(row) =>
|
||||||
|
row.IsActive === active &&
|
||||||
|
(!search ||
|
||||||
|
`${row.CompanyName} ${row.ContactName} ${row.TradeSpecialties} ${row.City} ${row.State}`
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(search)),
|
||||||
|
);
|
||||||
|
return json(response, { Data: rows, TotalCount: rows.length, PageNumber: 1, PageSize: 12 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/api/Vendor/facets") {
|
||||||
|
return json(response, {
|
||||||
|
data: {
|
||||||
|
companies: companies.map(companyFacet),
|
||||||
|
trades: ["Plumbing", "HVAC"],
|
||||||
|
locations: companies.map((company) => ({
|
||||||
|
city: company.city,
|
||||||
|
state: company.state,
|
||||||
|
label: `${company.city}, ${company.state}`,
|
||||||
|
})),
|
||||||
|
jobBuckets: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/api/vendor-company-roster" && request.method === "GET") {
|
||||||
|
const company = findCompany(url);
|
||||||
|
return company ? json(response, company) : json(response, { message: "Not found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/api/vendor-company-roster" && request.method === "POST") {
|
||||||
|
const body = await readBody(request);
|
||||||
|
const company = {
|
||||||
|
...body,
|
||||||
|
companyId: nextCompanyId++,
|
||||||
|
rowVersion: `rv-${Date.now()}`,
|
||||||
|
technicians: (body.technicians ?? []).map((technician) => ({
|
||||||
|
...technician,
|
||||||
|
id: nextTechnicianId++,
|
||||||
|
totalJobs: 0,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
companies.push(company);
|
||||||
|
return json(response, company);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rosterMatch = url.pathname.match(/^\/api\/vendor-company-roster\/(\d+)$/);
|
||||||
|
if (rosterMatch && request.method === "PUT") {
|
||||||
|
const company = companies.find((item) => item.companyId === Number(rosterMatch[1]));
|
||||||
|
if (!company) return json(response, { message: "Not found" }, 404);
|
||||||
|
const body = await readBody(request);
|
||||||
|
Object.assign(company, body, {
|
||||||
|
companyId: company.companyId,
|
||||||
|
rowVersion: `rv-${Date.now()}`,
|
||||||
|
technicians: (body.technicians ?? []).map((technician) => ({
|
||||||
|
...technician,
|
||||||
|
id: technician.id ?? nextTechnicianId++,
|
||||||
|
totalJobs:
|
||||||
|
company.technicians.find((existing) => existing.id === technician.id)?.totalJobs ?? 0,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
return json(response, company);
|
||||||
|
}
|
||||||
|
|
||||||
|
const portalMatch = url.pathname.match(/^\/api\/vendors\/(\d+)\/portal-token$/);
|
||||||
|
if (portalMatch) {
|
||||||
|
return json(response, {
|
||||||
|
data: {
|
||||||
|
portalUrl: "https://vendor.example.test/token",
|
||||||
|
issuedAt: new Date().toISOString(),
|
||||||
|
expiresAt: new Date(Date.now() + 86_400_000).toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const impactMatch = url.pathname.match(/^\/api\/vendors\/(\d+)\/deactivation-impact$/);
|
||||||
|
if (impactMatch) {
|
||||||
|
return json(response, {
|
||||||
|
data: { vendorId: Number(impactMatch[1]), canDeactivate: true, openWorkOrders: [] },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return json(response, { message: `No mock for ${request.method} ${url.pathname}` }, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(port, "127.0.0.1", () => {
|
||||||
|
process.stdout.write(`Vendor E2E mock listening on http://127.0.0.1:${port}\n`);
|
||||||
|
});
|
||||||
|
|
@ -126,6 +126,10 @@ export const API_PATHS = {
|
||||||
facets: "Vendor/facets",
|
facets: "Vendor/facets",
|
||||||
deactivationImpact: "deactivation-impact",
|
deactivationImpact: "deactivation-impact",
|
||||||
},
|
},
|
||||||
|
vendorCompanyRoster: {
|
||||||
|
base: "vendor-company-roster",
|
||||||
|
byCompany: (companyId: string | number) => `vendor-company-roster/${companyId}`,
|
||||||
|
},
|
||||||
calendar: {
|
calendar: {
|
||||||
getEventsForCalendar: "Calendar/GetEventsForCalendar",
|
getEventsForCalendar: "Calendar/GetEventsForCalendar",
|
||||||
getEventList: "Calendar/GetEventList",
|
getEventList: "Calendar/GetEventList",
|
||||||
|
|
|
||||||
58
src/app/(protected)/vendors/_components/use-vendor-deactivation.ts
vendored
Normal file
58
src/app/(protected)/vendors/_components/use-vendor-deactivation.ts
vendored
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useDeleteVendor } from "@/domain/vendors/use-cases/use-delete-vendor";
|
||||||
|
import { useVendorDeactivationImpact } from "@/domain/vendors/use-cases/use-vendor-deactivation-impact";
|
||||||
|
import type { VendorDeactivationImpact, VendorListItem } from "@/domain/vendors/types/vendor";
|
||||||
|
|
||||||
|
export interface VendorDeactivationState {
|
||||||
|
target: VendorListItem | null;
|
||||||
|
error: string | null;
|
||||||
|
impact: VendorDeactivationImpact | undefined;
|
||||||
|
isLoading: boolean;
|
||||||
|
impactError: Error | null;
|
||||||
|
isSaving: boolean;
|
||||||
|
open: (row: VendorListItem) => void;
|
||||||
|
close: () => void;
|
||||||
|
confirm: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useVendorDeactivation(): VendorDeactivationState {
|
||||||
|
const [target, setTarget] = useState<VendorListItem | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const deleteVendor = useDeleteVendor();
|
||||||
|
const {
|
||||||
|
data: impact,
|
||||||
|
isLoading,
|
||||||
|
error: impactError,
|
||||||
|
} = useVendorDeactivationImpact(target?.id ?? undefined);
|
||||||
|
|
||||||
|
const open = (row: VendorListItem) => {
|
||||||
|
setTarget(row);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
setTarget(null);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirm = () => {
|
||||||
|
if (!target || target.id == null) return;
|
||||||
|
setError(null);
|
||||||
|
deleteVendor.mutate(target.id, {
|
||||||
|
onSuccess: () => setTarget(null),
|
||||||
|
onError: (err: Error) => setError(err.message || "Failed to deactivate vendor"),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
target,
|
||||||
|
error,
|
||||||
|
impact,
|
||||||
|
isLoading,
|
||||||
|
impactError,
|
||||||
|
isSaving: deleteVendor.isPending,
|
||||||
|
open,
|
||||||
|
close,
|
||||||
|
confirm,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,164 +0,0 @@
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import { useForm, useWatch, type FieldErrors, type Resolver } from "react-hook-form";
|
|
||||||
import type { ZodError } from "zod";
|
|
||||||
import {
|
|
||||||
getChangedVendorValues,
|
|
||||||
vendorUpdateSchema,
|
|
||||||
type VendorFormValues,
|
|
||||||
} from "@/domain/vendors/schemas/vendor-schema";
|
|
||||||
import { useUpdateVendor } from "@/domain/vendors/use-cases/use-update-vendor";
|
|
||||||
import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets";
|
|
||||||
import type { Vendor, VendorFacetCompany } from "@/domain/vendors/types/vendor";
|
|
||||||
|
|
||||||
const DEFAULT_VALUES: VendorFormValues = {
|
|
||||||
companyName: "",
|
|
||||||
contactName: "",
|
|
||||||
email: "",
|
|
||||||
phone: "",
|
|
||||||
companyPhone: "",
|
|
||||||
googleMapsUrl: "",
|
|
||||||
notes: "",
|
|
||||||
totalJobs: 0,
|
|
||||||
address: "",
|
|
||||||
city: "",
|
|
||||||
state: "",
|
|
||||||
zip: "",
|
|
||||||
tradeSpecialties: "",
|
|
||||||
isActive: true,
|
|
||||||
companyId: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
function toFormValues(vendor: Vendor): VendorFormValues {
|
|
||||||
return {
|
|
||||||
companyName: vendor.companyName ?? "",
|
|
||||||
contactName: vendor.contactName ?? "",
|
|
||||||
email: vendor.email ?? "",
|
|
||||||
phone: vendor.phone ?? "",
|
|
||||||
companyPhone: vendor.companyPhone ?? "",
|
|
||||||
googleMapsUrl: vendor.googleMapsUrl ?? "",
|
|
||||||
notes: vendor.notes ?? "",
|
|
||||||
totalJobs: vendor.totalJobs ?? 0,
|
|
||||||
address: vendor.address ?? "",
|
|
||||||
city: vendor.city ?? "",
|
|
||||||
state: vendor.state ?? "",
|
|
||||||
zip: vendor.zip ?? "",
|
|
||||||
tradeSpecialties: vendor.tradeSpecialties ?? "",
|
|
||||||
isActive: vendor.isActive,
|
|
||||||
companyId: vendor.companyId ?? null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildFieldErrors(error: ZodError): FieldErrors<VendorFormValues> {
|
|
||||||
return error.issues.reduce<FieldErrors<VendorFormValues>>((fieldErrors, issue) => {
|
|
||||||
const field = issue.path[0];
|
|
||||||
if (typeof field === "string") {
|
|
||||||
fieldErrors[field as keyof VendorFormValues] = {
|
|
||||||
type: issue.code,
|
|
||||||
message: issue.message,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return fieldErrors;
|
|
||||||
}, {});
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UseVendorDrawerFormProps {
|
|
||||||
detail: Vendor | undefined;
|
|
||||||
open: boolean;
|
|
||||||
vendorId: string | number | undefined;
|
|
||||||
onModeChange: (mode: "view" | "edit") => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useVendorDetailDrawerForm({
|
|
||||||
detail,
|
|
||||||
open,
|
|
||||||
vendorId,
|
|
||||||
onModeChange,
|
|
||||||
}: UseVendorDrawerFormProps) {
|
|
||||||
const { data: facets } = useVendorFacets();
|
|
||||||
const updateVendor = useUpdateVendor();
|
|
||||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
|
||||||
const [original, setOriginal] = useState<VendorFormValues | null>(null);
|
|
||||||
const originalRef = useRef<VendorFormValues | null>(null);
|
|
||||||
const companies = useMemo(() => facets?.companies ?? [], [facets]);
|
|
||||||
|
|
||||||
const resolver = useCallback<Resolver<VendorFormValues>>(async (values) => {
|
|
||||||
const result = vendorUpdateSchema.safeParse(
|
|
||||||
getChangedVendorValues(values, originalRef.current),
|
|
||||||
);
|
|
||||||
return result.success
|
|
||||||
? { values, errors: {} }
|
|
||||||
: { values: {}, errors: buildFieldErrors(result.error) };
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const form = useForm<VendorFormValues>({
|
|
||||||
resolver,
|
|
||||||
mode: "onChange",
|
|
||||||
defaultValues: DEFAULT_VALUES,
|
|
||||||
});
|
|
||||||
const { control, reset, setValue, trigger } = form;
|
|
||||||
const currentValues = useWatch({ control });
|
|
||||||
const changedValues = useMemo(
|
|
||||||
() => getChangedVendorValues(currentValues, original),
|
|
||||||
[currentValues, original],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open && detail) {
|
|
||||||
const values = toFormValues(detail);
|
|
||||||
reset(values);
|
|
||||||
setOriginal(values);
|
|
||||||
originalRef.current = values;
|
|
||||||
setSubmitError(null);
|
|
||||||
} else if (!open) {
|
|
||||||
setOriginal(null);
|
|
||||||
originalRef.current = null;
|
|
||||||
setSubmitError(null);
|
|
||||||
}
|
|
||||||
}, [open, detail, reset]);
|
|
||||||
|
|
||||||
const onCompanySelected = (company: VendorFacetCompany | null) => {
|
|
||||||
if (!company) return;
|
|
||||||
const options = { shouldDirty: true, shouldValidate: true } as const;
|
|
||||||
setValue("companyId", company.companyId, options);
|
|
||||||
setValue("companyPhone", company.companyPhone, options);
|
|
||||||
setValue("email", company.email, options);
|
|
||||||
setValue("address", company.address, options);
|
|
||||||
setValue("city", company.city, options);
|
|
||||||
setValue("state", company.state, options);
|
|
||||||
setValue("zip", company.zip, options);
|
|
||||||
setValue("googleMapsUrl", company.googleMapsUrl, options);
|
|
||||||
void trigger();
|
|
||||||
};
|
|
||||||
|
|
||||||
const cancel = () => {
|
|
||||||
if (original) reset(original);
|
|
||||||
setSubmitError(null);
|
|
||||||
onModeChange("view");
|
|
||||||
};
|
|
||||||
|
|
||||||
const submit = (values: VendorFormValues) => {
|
|
||||||
if (vendorId === undefined || vendorId === "") return;
|
|
||||||
const changed = getChangedVendorValues(values, original);
|
|
||||||
if (Object.keys(changed).length === 0) return;
|
|
||||||
setSubmitError(null);
|
|
||||||
updateVendor.mutate(
|
|
||||||
{ id: vendorId, values: changed },
|
|
||||||
{
|
|
||||||
onSuccess: () => onModeChange("view"),
|
|
||||||
onError: (error: Error) => setSubmitError(error.message || "Failed to save vendor"),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
...form,
|
|
||||||
cancel,
|
|
||||||
companies,
|
|
||||||
hasChanges: Object.keys(changedValues).length > 0,
|
|
||||||
isFormValid: vendorUpdateSchema.safeParse(changedValues).success,
|
|
||||||
onCompanySelected,
|
|
||||||
submit,
|
|
||||||
submitError,
|
|
||||||
updateVendor,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
103
src/app/(protected)/vendors/_components/use-vendor-list-state.ts
vendored
Normal file
103
src/app/(protected)/vendors/_components/use-vendor-list-state.ts
vendored
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { emptyVendorFilters, type VendorFilters } from "./vendor-filters";
|
||||||
|
import { useDebounce } from "@/hooks/use-debounce";
|
||||||
|
|
||||||
|
export interface VendorListState {
|
||||||
|
search: string;
|
||||||
|
debouncedSearch: string;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
isActive: boolean;
|
||||||
|
appliedFilters: VendorFilters;
|
||||||
|
listParams: {
|
||||||
|
search: string;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
isActive: boolean;
|
||||||
|
companies: string[];
|
||||||
|
trades: string[];
|
||||||
|
locations: string[];
|
||||||
|
jobBuckets: string[];
|
||||||
|
};
|
||||||
|
setSearchWithReset: (value: string) => void;
|
||||||
|
setPage: (page: number) => void;
|
||||||
|
setPageSizeWithReset: (size: number) => void;
|
||||||
|
setIsActiveWithReset: (isActive: boolean) => void;
|
||||||
|
setAppliedFiltersWithReset: (filters: VendorFilters) => void;
|
||||||
|
removeFilter: (category: keyof VendorFilters, value: string) => void;
|
||||||
|
clearFilters: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useVendorListState(): VendorListState {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [pageSize, setPageSize] = useState(12);
|
||||||
|
const [isActive, setIsActive] = useState(true);
|
||||||
|
const [appliedFilters, setAppliedFilters] = useState<VendorFilters>(emptyVendorFilters);
|
||||||
|
|
||||||
|
const debouncedSearch = useDebounce(search, 300);
|
||||||
|
|
||||||
|
const listParams = useMemo(
|
||||||
|
() => ({
|
||||||
|
search: debouncedSearch,
|
||||||
|
page: page + 1,
|
||||||
|
pageSize,
|
||||||
|
isActive,
|
||||||
|
companies: appliedFilters.companies,
|
||||||
|
trades: appliedFilters.trades,
|
||||||
|
locations: appliedFilters.locations,
|
||||||
|
jobBuckets: appliedFilters.jobBuckets,
|
||||||
|
}),
|
||||||
|
[debouncedSearch, page, pageSize, isActive, appliedFilters],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setSearchWithReset = (value: string) => {
|
||||||
|
setSearch(value);
|
||||||
|
setPage(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setPageSizeWithReset = (size: number) => {
|
||||||
|
setPageSize(size);
|
||||||
|
setPage(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setIsActiveWithReset = (next: boolean) => {
|
||||||
|
setIsActive(next);
|
||||||
|
setPage(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setAppliedFiltersWithReset = (filters: VendorFilters) => {
|
||||||
|
setAppliedFilters(filters);
|
||||||
|
setPage(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFilter = (category: keyof VendorFilters, value: string) => {
|
||||||
|
setAppliedFilters((current) => ({
|
||||||
|
...current,
|
||||||
|
[category]: current[category].filter((item) => item !== value),
|
||||||
|
}));
|
||||||
|
setPage(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
setAppliedFilters(emptyVendorFilters);
|
||||||
|
setPage(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
search,
|
||||||
|
debouncedSearch,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
isActive,
|
||||||
|
appliedFilters,
|
||||||
|
listParams,
|
||||||
|
setSearchWithReset,
|
||||||
|
setPage,
|
||||||
|
setPageSizeWithReset,
|
||||||
|
setIsActiveWithReset,
|
||||||
|
setAppliedFiltersWithReset,
|
||||||
|
removeFilter,
|
||||||
|
clearFilters,
|
||||||
|
};
|
||||||
|
}
|
||||||
148
src/app/(protected)/vendors/_components/use-vendor-roster-form.ts
vendored
Normal file
148
src/app/(protected)/vendors/_components/use-vendor-roster-form.ts
vendored
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import { useForm, useWatch, type FieldErrors } from "react-hook-form";
|
||||||
|
import {
|
||||||
|
emptyVendorCompanyRosterForm,
|
||||||
|
vendorCompanyRosterSchema,
|
||||||
|
type VendorCompanyRosterFormValues,
|
||||||
|
} from "@/domain/vendors/schemas/vendor-roster-schema";
|
||||||
|
import { useVendorCompanyRoster } from "@/domain/vendors/use-cases/use-vendor-company-roster";
|
||||||
|
import {
|
||||||
|
getSingleStatusOnlyChange,
|
||||||
|
useSaveVendorCompanyRoster,
|
||||||
|
} from "@/domain/vendors/use-cases/use-save-vendor-company-roster";
|
||||||
|
import { useVendorRosterResolver } from "./use-vendor-roster-resolver";
|
||||||
|
import { toFormValues, useVendorRosterSelection } from "./use-vendor-roster-selection";
|
||||||
|
import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets";
|
||||||
|
import { isVendorRosterConflictError } from "@/domain/vendors/lib/vendor-roster-conflict";
|
||||||
|
import type {
|
||||||
|
VendorCompanyRoster,
|
||||||
|
VendorFacetCompany,
|
||||||
|
VendorRosterConflict,
|
||||||
|
} from "@/domain/vendors/types/vendor";
|
||||||
|
|
||||||
|
export interface VendorRosterFormProps {
|
||||||
|
mode: "create" | "update";
|
||||||
|
vendorId?: string | number;
|
||||||
|
companyId?: string | number;
|
||||||
|
onSuccess?: (roster: VendorCompanyRoster) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VendorRosterForm {
|
||||||
|
control: ReturnType<typeof useForm<VendorCompanyRosterFormValues>>["control"];
|
||||||
|
errors: FieldErrors<VendorCompanyRosterFormValues>;
|
||||||
|
handleSubmit: ReturnType<typeof useForm<VendorCompanyRosterFormValues>>["handleSubmit"];
|
||||||
|
isLoading: boolean;
|
||||||
|
isError: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
isSaving: boolean;
|
||||||
|
isValid: boolean;
|
||||||
|
conflict: VendorRosterConflict | null;
|
||||||
|
loadErrorMessage: string | null;
|
||||||
|
submit: (values: VendorCompanyRosterFormValues) => void;
|
||||||
|
reload: () => void;
|
||||||
|
retryLoad: () => void;
|
||||||
|
roster: VendorCompanyRoster | undefined;
|
||||||
|
companies: VendorFacetCompany[];
|
||||||
|
trades: string[];
|
||||||
|
selectedCompanyId: string | number | null;
|
||||||
|
selectCompany: (company: VendorFacetCompany | null) => Promise<void>;
|
||||||
|
clearSelectedCompany: (nextName?: string) => void;
|
||||||
|
resetForm: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useVendorRosterForm({
|
||||||
|
mode,
|
||||||
|
vendorId,
|
||||||
|
companyId,
|
||||||
|
onSuccess,
|
||||||
|
}: VendorRosterFormProps): VendorRosterForm {
|
||||||
|
const query = useVendorCompanyRoster(
|
||||||
|
mode === "update" ? vendorId : undefined,
|
||||||
|
mode === "update" ? companyId : undefined,
|
||||||
|
);
|
||||||
|
const { data: facets } = useVendorFacets();
|
||||||
|
const save = useSaveVendorCompanyRoster();
|
||||||
|
const [conflict, setConflict] = useState<VendorRosterConflict | null>(null);
|
||||||
|
|
||||||
|
const routeRoster = mode === "update" ? query.data : undefined;
|
||||||
|
const companies = useMemo(() => facets?.companies ?? [], [facets]);
|
||||||
|
const trades = useMemo(() => facets?.trades ?? [], [facets]);
|
||||||
|
const resolver = useVendorRosterResolver(routeRoster);
|
||||||
|
|
||||||
|
const form = useForm<VendorCompanyRosterFormValues>({
|
||||||
|
resolver,
|
||||||
|
defaultValues: emptyVendorCompanyRosterForm,
|
||||||
|
values: mode === "update" && routeRoster ? toFormValues(routeRoster) : undefined,
|
||||||
|
mode: "onChange",
|
||||||
|
});
|
||||||
|
const { control, handleSubmit, reset, formState } = form;
|
||||||
|
const watched = useWatch({ control });
|
||||||
|
|
||||||
|
const clearConflict = useCallback(() => setConflict(null), []);
|
||||||
|
const selection = useVendorRosterSelection({
|
||||||
|
mode,
|
||||||
|
reset,
|
||||||
|
query,
|
||||||
|
clearConflict,
|
||||||
|
});
|
||||||
|
const { resetSelection } = selection;
|
||||||
|
|
||||||
|
const isValid = useMemo(() => {
|
||||||
|
if (vendorCompanyRosterSchema.safeParse(watched).success) return true;
|
||||||
|
return Boolean(
|
||||||
|
routeRoster &&
|
||||||
|
getSingleStatusOnlyChange(routeRoster, watched as VendorCompanyRosterFormValues),
|
||||||
|
);
|
||||||
|
}, [routeRoster, watched]);
|
||||||
|
|
||||||
|
const resetForm = useCallback(() => {
|
||||||
|
reset(emptyVendorCompanyRosterForm);
|
||||||
|
resetSelection();
|
||||||
|
clearConflict();
|
||||||
|
}, [clearConflict, reset, resetSelection]);
|
||||||
|
|
||||||
|
const committedRoster = mode === "update" ? routeRoster : selection.selectedRoster;
|
||||||
|
const isUpdate = mode === "update" || selection.selectedRoster != null;
|
||||||
|
|
||||||
|
const submit = (formValues: VendorCompanyRosterFormValues) => {
|
||||||
|
setConflict(null);
|
||||||
|
save.mutate(
|
||||||
|
{
|
||||||
|
mode: isUpdate ? "update" : "create",
|
||||||
|
values: formValues,
|
||||||
|
companyId: isUpdate ? (committedRoster?.companyId ?? companyId) : undefined,
|
||||||
|
rowVersion: isUpdate ? committedRoster?.rowVersion : undefined,
|
||||||
|
originalRoster: mode === "update" ? routeRoster : undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: (data) => onSuccess?.(data),
|
||||||
|
onError: (error) => {
|
||||||
|
if (isVendorRosterConflictError(error)) setConflict(error.conflict);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
control,
|
||||||
|
errors: formState.errors,
|
||||||
|
handleSubmit,
|
||||||
|
isLoading: Boolean(query.isLoading),
|
||||||
|
isError: Boolean(query.isError),
|
||||||
|
error: query.error ?? null,
|
||||||
|
isSaving: save.isPending,
|
||||||
|
isValid,
|
||||||
|
conflict,
|
||||||
|
loadErrorMessage: selection.loadErrorMessage,
|
||||||
|
submit,
|
||||||
|
reload: selection.reload,
|
||||||
|
retryLoad: selection.retryLoad,
|
||||||
|
roster: routeRoster,
|
||||||
|
companies,
|
||||||
|
trades,
|
||||||
|
selectedCompanyId: selection.selectedCompanyId,
|
||||||
|
selectCompany: selection.selectCompany,
|
||||||
|
clearSelectedCompany: selection.clearSelectedCompany,
|
||||||
|
resetForm,
|
||||||
|
};
|
||||||
|
}
|
||||||
27
src/app/(protected)/vendors/_components/use-vendor-roster-resolver.ts
vendored
Normal file
27
src/app/(protected)/vendors/_components/use-vendor-roster-resolver.ts
vendored
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { type Resolver } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import {
|
||||||
|
vendorCompanyRosterSchema,
|
||||||
|
type VendorCompanyRosterFormValues,
|
||||||
|
} from "@/domain/vendors/schemas/vendor-roster-schema";
|
||||||
|
import { getSingleStatusOnlyChange } from "@/domain/vendors/use-cases/use-save-vendor-company-roster";
|
||||||
|
import type { VendorCompanyRoster } from "@/domain/vendors/types/vendor";
|
||||||
|
|
||||||
|
export function useVendorRosterResolver(
|
||||||
|
routeRoster: VendorCompanyRoster | undefined,
|
||||||
|
): Resolver<VendorCompanyRosterFormValues> {
|
||||||
|
const strictResolver = useMemo(() => zodResolver(vendorCompanyRosterSchema), []);
|
||||||
|
return useMemo<Resolver<VendorCompanyRosterFormValues>>(
|
||||||
|
() => async (values, context, options) => {
|
||||||
|
if (
|
||||||
|
routeRoster &&
|
||||||
|
getSingleStatusOnlyChange(routeRoster, values as VendorCompanyRosterFormValues)
|
||||||
|
) {
|
||||||
|
return { values, errors: {} };
|
||||||
|
}
|
||||||
|
return strictResolver(values, context, options);
|
||||||
|
},
|
||||||
|
[routeRoster, strictResolver],
|
||||||
|
);
|
||||||
|
}
|
||||||
172
src/app/(protected)/vendors/_components/use-vendor-roster-selection.ts
vendored
Normal file
172
src/app/(protected)/vendors/_components/use-vendor-roster-selection.ts
vendored
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
import { useQueryClient, type UseQueryResult } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
emptyVendorCompanyRosterForm,
|
||||||
|
type VendorCompanyRosterFormValues,
|
||||||
|
} from "@/domain/vendors/schemas/vendor-roster-schema";
|
||||||
|
import { vendorCompanyRosterApi } from "@/domain/vendors/api/vendor-company-roster-api";
|
||||||
|
import { queryKeys } from "@/infra/query-key/query-key";
|
||||||
|
import type { VendorCompanyRoster, VendorFacetCompany } from "@/domain/vendors/types/vendor";
|
||||||
|
import type { UseFormReset } from "react-hook-form";
|
||||||
|
|
||||||
|
const SELECT_ERROR_MESSAGE = "Couldn't load that company's roster. Try again.";
|
||||||
|
const RELOAD_ERROR_MESSAGE = "Couldn't reload the latest company. Try again.";
|
||||||
|
|
||||||
|
export interface VendorRosterSelection {
|
||||||
|
selectedRoster: VendorCompanyRoster | null;
|
||||||
|
loadErrorMessage: string | null;
|
||||||
|
selectedCompanyId: string | number | null;
|
||||||
|
selectCompany: (company: VendorFacetCompany | null) => Promise<void>;
|
||||||
|
clearSelectedCompany: (nextName?: string) => void;
|
||||||
|
reload: () => void;
|
||||||
|
retryLoad: () => void;
|
||||||
|
resetSelection: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseVendorRosterSelectionOptions {
|
||||||
|
mode: "create" | "update";
|
||||||
|
reset: UseFormReset<VendorCompanyRosterFormValues>;
|
||||||
|
query: UseQueryResult<VendorCompanyRoster, Error>;
|
||||||
|
clearConflict: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toFormValues(roster: VendorCompanyRoster): VendorCompanyRosterFormValues {
|
||||||
|
return {
|
||||||
|
name: roster.name,
|
||||||
|
companyPhone: roster.companyPhone,
|
||||||
|
email: roster.email,
|
||||||
|
address: roster.address,
|
||||||
|
city: roster.city,
|
||||||
|
state: roster.state,
|
||||||
|
zip: roster.zip,
|
||||||
|
googleMapsUrl: roster.googleMapsUrl,
|
||||||
|
notes: roster.notes,
|
||||||
|
technicians: roster.technicians.map((technician) => ({
|
||||||
|
id: technician.id,
|
||||||
|
contactName: technician.contactName,
|
||||||
|
phone: technician.phone,
|
||||||
|
email: technician.email,
|
||||||
|
preferredContact: technician.preferredContact,
|
||||||
|
tradeSpecialties: technician.tradeSpecialties,
|
||||||
|
isActive: technician.isActive,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useVendorRosterSelection({
|
||||||
|
mode,
|
||||||
|
reset,
|
||||||
|
query,
|
||||||
|
clearConflict,
|
||||||
|
}: UseVendorRosterSelectionOptions): VendorRosterSelection {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [selectedRoster, setSelectedRoster] = useState<VendorCompanyRoster | null>(null);
|
||||||
|
const [loadErrorMessage, setLoadErrorMessage] = useState<string | null>(null);
|
||||||
|
const retryTargetRef = useRef<
|
||||||
|
{ kind: "select"; company: VendorFacetCompany } | { kind: "reload" } | null
|
||||||
|
>(null);
|
||||||
|
const requestIdRef = useRef(0);
|
||||||
|
|
||||||
|
const fetchRosterByCompany = useCallback(
|
||||||
|
async (id: string | number): Promise<VendorCompanyRoster> =>
|
||||||
|
queryClient.fetchQuery({
|
||||||
|
queryKey: queryKeys.vendors.roster("companyId", id),
|
||||||
|
queryFn: () => vendorCompanyRosterApi.get({ companyId: id }),
|
||||||
|
}),
|
||||||
|
[queryClient],
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectCompany = useCallback(
|
||||||
|
async (company: VendorFacetCompany | null) => {
|
||||||
|
if (!company || company.companyId == null) {
|
||||||
|
requestIdRef.current += 1;
|
||||||
|
setSelectedRoster(null);
|
||||||
|
setLoadErrorMessage(null);
|
||||||
|
retryTargetRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const requestId = (requestIdRef.current += 1);
|
||||||
|
setLoadErrorMessage(null);
|
||||||
|
retryTargetRef.current = { kind: "select", company };
|
||||||
|
try {
|
||||||
|
const roster = await fetchRosterByCompany(company.companyId);
|
||||||
|
if (requestIdRef.current !== requestId) return;
|
||||||
|
reset(toFormValues(roster));
|
||||||
|
setSelectedRoster(roster);
|
||||||
|
clearConflict();
|
||||||
|
retryTargetRef.current = null;
|
||||||
|
} catch {
|
||||||
|
if (requestIdRef.current !== requestId) return;
|
||||||
|
setLoadErrorMessage(SELECT_ERROR_MESSAGE);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[clearConflict, fetchRosterByCompany, reset],
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearSelectedCompany = useCallback(
|
||||||
|
(nextName = "") => {
|
||||||
|
requestIdRef.current += 1;
|
||||||
|
if (selectedRoster) {
|
||||||
|
reset({ ...emptyVendorCompanyRosterForm, name: nextName });
|
||||||
|
}
|
||||||
|
setSelectedRoster(null);
|
||||||
|
clearConflict();
|
||||||
|
setLoadErrorMessage(null);
|
||||||
|
retryTargetRef.current = null;
|
||||||
|
},
|
||||||
|
[clearConflict, reset, selectedRoster],
|
||||||
|
);
|
||||||
|
|
||||||
|
const reload = useCallback(() => {
|
||||||
|
const requestId = (requestIdRef.current += 1);
|
||||||
|
clearConflict();
|
||||||
|
setLoadErrorMessage(null);
|
||||||
|
retryTargetRef.current = { kind: "reload" };
|
||||||
|
if (mode === "update") {
|
||||||
|
void query.refetch().then((result) => {
|
||||||
|
if (requestIdRef.current !== requestId) return;
|
||||||
|
if (result.isError) setLoadErrorMessage(RELOAD_ERROR_MESSAGE);
|
||||||
|
else retryTargetRef.current = null;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selectedRoster?.companyId != null) {
|
||||||
|
void fetchRosterByCompany(selectedRoster.companyId)
|
||||||
|
.then((roster) => {
|
||||||
|
if (requestIdRef.current !== requestId) return;
|
||||||
|
reset(toFormValues(roster));
|
||||||
|
setSelectedRoster(roster);
|
||||||
|
retryTargetRef.current = null;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (requestIdRef.current !== requestId) return;
|
||||||
|
setLoadErrorMessage(RELOAD_ERROR_MESSAGE);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [clearConflict, fetchRosterByCompany, mode, query, reset, selectedRoster]);
|
||||||
|
|
||||||
|
const retryLoad = useCallback(() => {
|
||||||
|
const target = retryTargetRef.current;
|
||||||
|
if (!target) return;
|
||||||
|
if (target.kind === "select") void selectCompany(target.company);
|
||||||
|
else void reload();
|
||||||
|
}, [reload, selectCompany]);
|
||||||
|
|
||||||
|
const resetSelection = useCallback(() => {
|
||||||
|
requestIdRef.current += 1;
|
||||||
|
setSelectedRoster(null);
|
||||||
|
setLoadErrorMessage(null);
|
||||||
|
retryTargetRef.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
selectedRoster,
|
||||||
|
loadErrorMessage,
|
||||||
|
selectedCompanyId: selectedRoster?.companyId ?? null,
|
||||||
|
selectCompany,
|
||||||
|
clearSelectedCompany,
|
||||||
|
reload,
|
||||||
|
retryLoad,
|
||||||
|
resetSelection,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect } from "react";
|
||||||
import { useForm, useWatch } from "react-hook-form";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import {
|
import {
|
||||||
Alert,
|
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
|
|
@ -13,140 +10,72 @@ import {
|
||||||
Stack,
|
Stack,
|
||||||
Typography,
|
Typography,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { VendorFormFields } from "./vendor-form-fields";
|
import { VendorRosterConflictAlert } from "./vendor-roster-conflict-alert";
|
||||||
import { vendorSchema, type VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
import { VendorRosterFormFields } from "./vendor-roster-form-fields";
|
||||||
import { useCreateVendor } from "@/domain/vendors/use-cases/use-create-vendor";
|
import { VendorRosterLoadErrorAlert } from "./vendor-roster-load-error";
|
||||||
import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets";
|
import { useVendorRosterForm } from "./use-vendor-roster-form";
|
||||||
import type { VendorFacetCompany } from "@/domain/vendors/types/vendor";
|
|
||||||
|
|
||||||
interface VendorCreateModalProps {
|
interface VendorCreateModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
tradeOptions: string[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultValues: VendorFormValues = {
|
export function VendorCreateModal({ open, onClose }: VendorCreateModalProps) {
|
||||||
companyName: "",
|
const form = useVendorRosterForm({ mode: "create", onSuccess: onClose });
|
||||||
contactName: "",
|
const { resetForm } = form;
|
||||||
email: "",
|
|
||||||
phone: "",
|
|
||||||
companyPhone: "",
|
|
||||||
googleMapsUrl: "",
|
|
||||||
notes: "",
|
|
||||||
totalJobs: 0,
|
|
||||||
address: "",
|
|
||||||
city: "",
|
|
||||||
state: "",
|
|
||||||
zip: "",
|
|
||||||
tradeSpecialties: "",
|
|
||||||
isActive: true,
|
|
||||||
companyId: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function VendorCreateModal({ open, onClose, tradeOptions }: VendorCreateModalProps) {
|
|
||||||
const { data: facets, isLoading: isLoadingFacets, error: facetsError } = useVendorFacets();
|
|
||||||
const createVendor = useCreateVendor();
|
|
||||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const {
|
|
||||||
control,
|
|
||||||
handleSubmit,
|
|
||||||
reset,
|
|
||||||
setValue,
|
|
||||||
trigger,
|
|
||||||
formState: { errors },
|
|
||||||
} = useForm<VendorFormValues>({
|
|
||||||
resolver: zodResolver(vendorSchema),
|
|
||||||
defaultValues,
|
|
||||||
mode: "onChange",
|
|
||||||
});
|
|
||||||
const currentValues = useWatch({ control });
|
|
||||||
const isFormValid = vendorSchema.safeParse(currentValues).success;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (!open) resetForm();
|
||||||
reset(defaultValues);
|
}, [open, resetForm]);
|
||||||
setSubmitError(null);
|
|
||||||
}
|
|
||||||
}, [open, reset]);
|
|
||||||
|
|
||||||
const onCompanySelected = (company: VendorFacetCompany | null) => {
|
|
||||||
if (!company) return;
|
|
||||||
const options = { shouldDirty: true, shouldValidate: true } as const;
|
|
||||||
setValue("companyId", company.companyId, options);
|
|
||||||
setValue("companyPhone", company.companyPhone, options);
|
|
||||||
setValue("email", company.email, options);
|
|
||||||
setValue("address", company.address, options);
|
|
||||||
setValue("city", company.city, options);
|
|
||||||
setValue("state", company.state, options);
|
|
||||||
setValue("zip", company.zip, options);
|
|
||||||
setValue("googleMapsUrl", company.googleMapsUrl, options);
|
|
||||||
void trigger();
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSubmit = (values: VendorFormValues) => {
|
|
||||||
setSubmitError(null);
|
|
||||||
createVendor.mutate(values, {
|
|
||||||
onSuccess: () => {
|
|
||||||
onClose();
|
|
||||||
},
|
|
||||||
onError: (error: Error) => {
|
|
||||||
setSubmitError(error.message || "Failed to create vendor");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const companies = facets?.companies ?? [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open={open}
|
open={open}
|
||||||
onClose={createVendor.isPending ? undefined : onClose}
|
onClose={form.isSaving ? undefined : onClose}
|
||||||
fullWidth
|
fullWidth
|
||||||
maxWidth="sm"
|
maxWidth="md"
|
||||||
aria-labelledby="vendor-create-dialog-title"
|
aria-labelledby="vendor-create-dialog-title"
|
||||||
slotProps={{ paper: { role: "dialog" } }}
|
slotProps={{ paper: { role: "dialog" } }}
|
||||||
>
|
>
|
||||||
<DialogTitle id="vendor-create-dialog-title">
|
<DialogTitle id="vendor-create-dialog-title">
|
||||||
Add Vendor
|
Add Vendor Company
|
||||||
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.5 }}>
|
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.5 }}>
|
||||||
Register a technician and their vendor company.
|
Register a company and add zero, one, or multiple technicians.
|
||||||
</Typography>
|
</Typography>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<Stack spacing={2} className="mt-1">
|
<Stack spacing={2} className="mt-1">
|
||||||
{Boolean(facetsError) && (
|
{form.loadErrorMessage !== null && (
|
||||||
<Alert severity="warning">
|
<VendorRosterLoadErrorAlert message={form.loadErrorMessage} onRetry={form.retryLoad} />
|
||||||
Company autocomplete unavailable. You can still type a company manually.
|
|
||||||
</Alert>
|
|
||||||
)}
|
)}
|
||||||
{Boolean(submitError) && <Alert severity="error">{submitError}</Alert>}
|
{form.conflict !== null && (
|
||||||
{isLoadingFacets ? (
|
<VendorRosterConflictAlert conflict={form.conflict} onReload={form.reload} />
|
||||||
<CircularProgress size={24} />
|
|
||||||
) : (
|
|
||||||
<Box>
|
|
||||||
<VendorFormFields
|
|
||||||
control={control}
|
|
||||||
errors={errors}
|
|
||||||
companies={companies}
|
|
||||||
tradeOptions={tradeOptions}
|
|
||||||
onCompanySelected={onCompanySelected}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
)}
|
)}
|
||||||
|
<Box component="form" id="vendor-create-form" onSubmit={form.handleSubmit(form.submit)}>
|
||||||
|
<VendorRosterFormFields
|
||||||
|
control={form.control}
|
||||||
|
errors={form.errors}
|
||||||
|
companies={form.companies}
|
||||||
|
tradeOptions={form.trades}
|
||||||
|
selectedCompanyId={form.selectedCompanyId}
|
||||||
|
onSelectCompany={form.selectCompany}
|
||||||
|
onClearSelectedCompany={form.clearSelectedCompany}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button onClick={onClose} disabled={createVendor.isPending}>
|
<Button onClick={onClose} disabled={form.isSaving}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
color="primary"
|
color="primary"
|
||||||
variant="contained"
|
variant="contained"
|
||||||
onClick={handleSubmit(onSubmit)}
|
type="submit"
|
||||||
disabled={createVendor.isPending || !isFormValid}
|
form="vendor-create-form"
|
||||||
|
disabled={form.isSaving || !form.isValid}
|
||||||
>
|
>
|
||||||
{createVendor.isPending ? "Saving..." : "Add Vendor"}
|
{form.isSaving ? <CircularProgress size={20} color="inherit" /> : "Add Vendor Company"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
|
||||||
126
src/app/(protected)/vendors/_components/vendor-deactivation-dialog.tsx
vendored
Normal file
126
src/app/(protected)/vendors/_components/vendor-deactivation-dialog.tsx
vendored
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogContentText,
|
||||||
|
DialogTitle,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
Stack,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import type { VendorDeactivationImpact, VendorListItem } from "@/domain/vendors/types/vendor";
|
||||||
|
|
||||||
|
interface VendorDeactivationDialogProps {
|
||||||
|
target: VendorListItem | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
isSaving: boolean;
|
||||||
|
error: string | null;
|
||||||
|
impact: VendorDeactivationImpact | undefined;
|
||||||
|
impactError: Error | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VendorDeactivationDialog({
|
||||||
|
target,
|
||||||
|
isLoading,
|
||||||
|
isSaving,
|
||||||
|
error,
|
||||||
|
impact,
|
||||||
|
impactError,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
}: VendorDeactivationDialogProps) {
|
||||||
|
const hasBlockingImpact = Boolean(impact && !impact.canDeactivate);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={Boolean(target)} onClose={isSaving ? undefined : onClose}>
|
||||||
|
<DialogTitle>Deactivate Vendor</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<DialogContentText>
|
||||||
|
Deactivate "{target?.companyName}"? Existing work-order and audit history will
|
||||||
|
be preserved.
|
||||||
|
</DialogContentText>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
||||||
|
<CircularProgress size={16} />
|
||||||
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||||
|
Checking linked work orders...
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{Boolean(impactError) && !isLoading && (
|
||||||
|
<Alert severity="warning">
|
||||||
|
Could not verify linked work orders. Close and try again.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{impact != null && !isLoading && !impact.canDeactivate && (
|
||||||
|
<Alert severity="error">
|
||||||
|
This vendor cannot be deactivated because it still has open work orders.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{impact != null && impact.openWorkOrders.length > 0 && (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" className="font-semibold uppercase">
|
||||||
|
Open work orders ({impact.openWorkOrders.length})
|
||||||
|
</Typography>
|
||||||
|
<List dense sx={{ mt: 0.5 }}>
|
||||||
|
{impact.openWorkOrders.map((wo) => (
|
||||||
|
<ListItem
|
||||||
|
key={String(wo.workOrderId)}
|
||||||
|
sx={{ px: 0, py: 0.25 }}
|
||||||
|
secondaryAction={
|
||||||
|
wo.scheduledDate ? (
|
||||||
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||||
|
{wo.scheduledDate}
|
||||||
|
</Typography>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Stack>
|
||||||
|
<Typography variant="body2">
|
||||||
|
{wo.workOrderNumber
|
||||||
|
? `${wo.workOrderNumber} — ${wo.workOrderTitle || "Untitled"}`
|
||||||
|
: wo.workOrderTitle || `Work order ${wo.workOrderId}`}
|
||||||
|
</Typography>
|
||||||
|
{Boolean(wo.status) && (
|
||||||
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||||
|
{wo.status}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{Boolean(error) && <Alert severity="error">{error}</Alert>}
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={onClose} disabled={isSaving}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="error"
|
||||||
|
variant="contained"
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={isSaving || isLoading || hasBlockingImpact || Boolean(impactError)}
|
||||||
|
>
|
||||||
|
{isSaving ? "Deactivating..." : "Deactivate"}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,323 +0,0 @@
|
||||||
import type { FormEventHandler, ReactNode } from "react";
|
|
||||||
import { Controller, type Control, type FieldErrors } from "react-hook-form";
|
|
||||||
import CloseIcon from "@mui/icons-material/Close";
|
|
||||||
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
|
||||||
import LaunchIcon from "@mui/icons-material/Launch";
|
|
||||||
import {
|
|
||||||
Alert,
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Chip,
|
|
||||||
FormControlLabel,
|
|
||||||
IconButton,
|
|
||||||
Link,
|
|
||||||
Stack,
|
|
||||||
Switch,
|
|
||||||
Tooltip,
|
|
||||||
Typography,
|
|
||||||
} from "@mui/material";
|
|
||||||
import { VendorFormFields } from "./vendor-form-fields";
|
|
||||||
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
|
||||||
import type { Vendor, VendorFacetCompany, VendorListItem } from "@/domain/vendors/types/vendor";
|
|
||||||
|
|
||||||
function getInitials(name: string): string {
|
|
||||||
const parts = name.trim().split(/\s+/).filter(Boolean);
|
|
||||||
if (parts.length === 0) return "—";
|
|
||||||
const first = parts[0]?.[0] ?? "";
|
|
||||||
const last = parts.length > 1 ? (parts[parts.length - 1]?.[0] ?? "") : "";
|
|
||||||
return (first + last).toUpperCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
interface HeaderProps {
|
|
||||||
vendor: VendorListItem | null;
|
|
||||||
mode: "view" | "edit";
|
|
||||||
pending: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function VendorDrawerHeader({ vendor, mode, pending, onClose }: HeaderProps) {
|
|
||||||
return (
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
sx={{
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
px: 3,
|
|
||||||
py: 2,
|
|
||||||
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Stack direction="row" spacing={1.5} sx={{ alignItems: "center" }}>
|
|
||||||
<Box
|
|
||||||
className="flex items-center justify-center"
|
|
||||||
sx={{
|
|
||||||
width: 36,
|
|
||||||
height: 36,
|
|
||||||
borderRadius: "50%",
|
|
||||||
backgroundColor: "var(--accent, #1976d2)",
|
|
||||||
color: "#fff",
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 700,
|
|
||||||
letterSpacing: 0.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{getInitials(vendor?.contactName ?? "")}
|
|
||||||
</Box>
|
|
||||||
<Box>
|
|
||||||
<Typography
|
|
||||||
id="vendor-detail-drawer-title"
|
|
||||||
variant="subtitle1"
|
|
||||||
component="h2"
|
|
||||||
className="font-semibold"
|
|
||||||
>
|
|
||||||
{vendor?.companyName || "Vendor"}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
|
||||||
{mode === "edit" ? "Editing vendor" : "Vendor details"}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Stack>
|
|
||||||
<IconButton aria-label="Close drawer" onClick={onClose} size="small" disabled={pending}>
|
|
||||||
<CloseIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ViewProps {
|
|
||||||
detail: Vendor;
|
|
||||||
mapsUrl: string | undefined;
|
|
||||||
submitError: string | null;
|
|
||||||
trades: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function VendorDrawerView({ detail, mapsUrl, submitError, trades }: ViewProps) {
|
|
||||||
return (
|
|
||||||
<Stack spacing={3} className="flex-1 overflow-y-auto" sx={{ px: 3, py: 3 }}>
|
|
||||||
{Boolean(submitError) && <Alert severity="error">{submitError}</Alert>}
|
|
||||||
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
|
||||||
<Chip
|
|
||||||
size="small"
|
|
||||||
label={detail.isActive ? "Active" : "Inactive"}
|
|
||||||
color={detail.isActive ? "success" : "default"}
|
|
||||||
/>
|
|
||||||
{Boolean(trades[0]) && (
|
|
||||||
<Chip
|
|
||||||
size="small"
|
|
||||||
variant="outlined"
|
|
||||||
label={trades.length > 1 ? `${trades[0]} +${trades.length - 1}` : trades[0]}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
<DetailSection title="Technician">
|
|
||||||
<DetailField label="Technician Name" value={detail.contactName} />
|
|
||||||
<DetailField label="Technician Phone" value={detail.phone} />
|
|
||||||
</DetailSection>
|
|
||||||
<DetailSection title="Company">
|
|
||||||
<DetailField label="Company" value={detail.companyName} />
|
|
||||||
<DetailField label="Company Phone" value={detail.companyPhone} />
|
|
||||||
<DetailField label="Email" value={detail.email} />
|
|
||||||
</DetailSection>
|
|
||||||
<DetailSection title="Location">
|
|
||||||
<DetailField
|
|
||||||
label="Address"
|
|
||||||
value={[detail.address, detail.city, detail.state, detail.zip].filter(Boolean).join(", ")}
|
|
||||||
/>
|
|
||||||
{Boolean(mapsUrl) && (
|
|
||||||
<Link
|
|
||||||
href={mapsUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="inline-flex items-center gap-1"
|
|
||||||
>
|
|
||||||
<LaunchIcon fontSize="small" />
|
|
||||||
Open in Google Maps
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</DetailSection>
|
|
||||||
<TradesSection trades={trades} />
|
|
||||||
<DetailSection title="Activity">
|
|
||||||
<DetailField label="Total Jobs" value={String(detail.totalJobs ?? 0)} />
|
|
||||||
</DetailSection>
|
|
||||||
{Boolean(detail.notes) && (
|
|
||||||
<DetailSection title="Notes">
|
|
||||||
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>
|
|
||||||
{detail.notes}
|
|
||||||
</Typography>
|
|
||||||
</DetailSection>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TradesSection({ trades }: { trades: string[] }) {
|
|
||||||
return (
|
|
||||||
<DetailSection title="Trades">
|
|
||||||
{trades.length === 0 ? (
|
|
||||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
|
||||||
No trades specified.
|
|
||||||
</Typography>
|
|
||||||
) : (
|
|
||||||
<Box className="flex flex-wrap gap-1">
|
|
||||||
{trades.map((trade, index) => (
|
|
||||||
<Chip
|
|
||||||
key={`${trade}-${index}`}
|
|
||||||
size="small"
|
|
||||||
label={index === 0 ? `${trade} (primary)` : trade}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</DetailSection>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EditProps {
|
|
||||||
control: Control<VendorFormValues>;
|
|
||||||
errors: FieldErrors<VendorFormValues>;
|
|
||||||
companies: VendorFacetCompany[];
|
|
||||||
tradeOptions: string[];
|
|
||||||
submitError: string | null;
|
|
||||||
onCompanySelected: (company: VendorFacetCompany | null) => void;
|
|
||||||
onSubmit: FormEventHandler<HTMLFormElement>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function VendorDrawerEdit({
|
|
||||||
control,
|
|
||||||
errors,
|
|
||||||
companies,
|
|
||||||
tradeOptions,
|
|
||||||
submitError,
|
|
||||||
onCompanySelected,
|
|
||||||
onSubmit,
|
|
||||||
}: EditProps) {
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
component="form"
|
|
||||||
id="vendor-edit-form"
|
|
||||||
onSubmit={onSubmit}
|
|
||||||
className="flex flex-1 flex-col overflow-y-auto"
|
|
||||||
sx={{ px: 3, py: 3 }}
|
|
||||||
>
|
|
||||||
<Stack spacing={3}>
|
|
||||||
{Boolean(submitError) && <Alert severity="error">{submitError}</Alert>}
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="isActive"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Switch checked={Boolean(field.value)} onChange={field.onChange} color="success" />
|
|
||||||
}
|
|
||||||
label={field.value ? "Active" : "Inactive"}
|
|
||||||
sx={{ alignSelf: "flex-start" }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<VendorFormFields
|
|
||||||
control={control}
|
|
||||||
errors={errors}
|
|
||||||
companies={companies}
|
|
||||||
tradeOptions={tradeOptions}
|
|
||||||
onCompanySelected={onCompanySelected}
|
|
||||||
showTotalJobs
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ActionsProps {
|
|
||||||
mode: "view" | "edit";
|
|
||||||
mapsUrl: string | undefined;
|
|
||||||
pending: boolean;
|
|
||||||
canSave: boolean;
|
|
||||||
onCancel: () => void;
|
|
||||||
onEdit: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function VendorDrawerActions({
|
|
||||||
mode,
|
|
||||||
mapsUrl,
|
|
||||||
pending,
|
|
||||||
canSave,
|
|
||||||
onCancel,
|
|
||||||
onEdit,
|
|
||||||
}: ActionsProps) {
|
|
||||||
return (
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
spacing={2}
|
|
||||||
sx={{
|
|
||||||
px: 3,
|
|
||||||
py: 2,
|
|
||||||
borderTop: (theme) => `1px solid ${theme.palette.divider}`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{mode === "view" ? (
|
|
||||||
<>
|
|
||||||
{Boolean(mapsUrl) && (
|
|
||||||
<Tooltip title="Open in Google Maps">
|
|
||||||
<Button
|
|
||||||
component="a"
|
|
||||||
href={mapsUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
variant="outlined"
|
|
||||||
startIcon={<LaunchIcon />}
|
|
||||||
>
|
|
||||||
Maps
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
<Box className="flex-1" />
|
|
||||||
<Button variant="contained" onClick={onEdit} startIcon={<EditOutlinedIcon />}>
|
|
||||||
Edit
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Button variant="outlined" onClick={onCancel} disabled={pending} fullWidth>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
type="submit"
|
|
||||||
form="vendor-edit-form"
|
|
||||||
disabled={pending || !canSave}
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
{pending ? "Saving..." : "Save"}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DetailSection({ title, children }: { title: string; children: ReactNode }) {
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<Typography
|
|
||||||
variant="subtitle2"
|
|
||||||
className="font-semibold uppercase"
|
|
||||||
sx={{ color: "text.secondary", mb: 1 }}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</Typography>
|
|
||||||
<Stack spacing={1.5}>{children}</Stack>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DetailField({ label, value }: { label: string; value: string }) {
|
|
||||||
const display = value && value.trim() !== "" ? value : "—";
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<Typography variant="caption" sx={{ color: "text.secondary", display: "block" }}>
|
|
||||||
{label}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2">{display}</Typography>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +1,27 @@
|
||||||
import { Alert, Box, CircularProgress, Drawer, Stack, Typography } from "@mui/material";
|
import { useEffect, type ReactNode } from "react";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
|
import CloseIcon from "@mui/icons-material/Close";
|
||||||
|
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
||||||
|
import LaunchIcon from "@mui/icons-material/Launch";
|
||||||
import {
|
import {
|
||||||
VendorDrawerActions,
|
Alert,
|
||||||
VendorDrawerEdit,
|
Box,
|
||||||
VendorDrawerHeader,
|
Button,
|
||||||
VendorDrawerView,
|
Chip,
|
||||||
} from "./vendor-detail-drawer-content";
|
CircularProgress,
|
||||||
import { useVendorDetailDrawerForm } from "./use-vendor-detail-drawer";
|
Divider,
|
||||||
import { useVendorDetail } from "@/domain/vendors/use-cases/use-vendor-detail";
|
Drawer,
|
||||||
import type { VendorListItem } from "@/domain/vendors/types/vendor";
|
IconButton,
|
||||||
|
Link,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
Stack,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { useVendorCompanyRoster } from "@/domain/vendors/use-cases/use-vendor-company-roster";
|
||||||
|
import type { VendorCompanyRoster, VendorListItem } from "@/domain/vendors/types/vendor";
|
||||||
|
|
||||||
const DRAWER_WIDTH = 420;
|
const DRAWER_WIDTH = 440;
|
||||||
|
|
||||||
function splitTrades(value: string | undefined): string[] {
|
function splitTrades(value: string | undefined): string[] {
|
||||||
return (value ?? "")
|
return (value ?? "")
|
||||||
|
|
@ -21,35 +33,266 @@ function splitTrades(value: string | undefined): string[] {
|
||||||
interface VendorDetailDrawerProps {
|
interface VendorDetailDrawerProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
vendor: VendorListItem | null;
|
vendor: VendorListItem | null;
|
||||||
mode: "view" | "edit";
|
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onModeChange: (mode: "view" | "edit") => void;
|
|
||||||
tradeOptions: string[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VendorDetailDrawer({
|
function SectionTitle({ children }: { children: ReactNode }) {
|
||||||
open,
|
return (
|
||||||
|
<Typography
|
||||||
|
variant="subtitle2"
|
||||||
|
className="font-semibold uppercase"
|
||||||
|
sx={{ color: "text.secondary", mb: 1 }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, value }: { label: string; value: string }) {
|
||||||
|
const display = value && value.trim() !== "" ? value : "—";
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" sx={{ color: "text.secondary", display: "block" }}>
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2">{display}</Typography>
|
||||||
|
</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({
|
||||||
|
roster,
|
||||||
vendor,
|
vendor,
|
||||||
mode,
|
|
||||||
onClose,
|
onClose,
|
||||||
onModeChange,
|
}: {
|
||||||
tradeOptions,
|
roster?: VendorCompanyRoster;
|
||||||
}: VendorDetailDrawerProps) {
|
vendor: VendorListItem | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
sx={{
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
px: 3,
|
||||||
|
py: 2,
|
||||||
|
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box>
|
||||||
|
<Typography
|
||||||
|
id="vendor-detail-drawer-title"
|
||||||
|
variant="subtitle1"
|
||||||
|
component="h2"
|
||||||
|
className="font-semibold"
|
||||||
|
>
|
||||||
|
{roster?.name || vendor?.companyName || "Vendor company"}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||||
|
{roster ? `${roster.technicians.length} technician(s)` : "Vendor details"}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<IconButton aria-label="Close drawer" onClick={onClose} size="small">
|
||||||
|
<CloseIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CompanySection({ roster }: { roster: VendorCompanyRoster }) {
|
||||||
|
const mapsUrl = roster.googleMapsUrl;
|
||||||
|
return (
|
||||||
|
<Stack spacing={1.5}>
|
||||||
|
<Field label="Company Phone" value={roster.companyPhone} />
|
||||||
|
<Field label="Email" value={roster.email} />
|
||||||
|
<Field
|
||||||
|
label="Address"
|
||||||
|
value={[roster.address, roster.city, roster.state, roster.zip].filter(Boolean).join(", ")}
|
||||||
|
/>
|
||||||
|
{Boolean(mapsUrl) && (
|
||||||
|
<Link
|
||||||
|
href={mapsUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<LaunchIcon fontSize="small" />
|
||||||
|
Open in Google Maps
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TechniciansSection({ roster }: { roster: VendorCompanyRoster }) {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<SectionTitle>Technicians</SectionTitle>
|
||||||
|
{roster.technicians.length === 0 ? (
|
||||||
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||||
|
No technicians assigned to this company.
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<List disablePadding>
|
||||||
|
{roster.technicians.map((technician) => (
|
||||||
|
<TechnicianEntry
|
||||||
|
key={String(technician.id ?? technician.contactName)}
|
||||||
|
contactName={technician.contactName}
|
||||||
|
phone={technician.phone}
|
||||||
|
email={technician.email}
|
||||||
|
preferredContact={technician.preferredContact}
|
||||||
|
tradeSpecialties={technician.tradeSpecialties}
|
||||||
|
isActive={technician.isActive}
|
||||||
|
totalJobs={technician.totalJobs}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerBody({ roster }: { roster: VendorCompanyRoster }) {
|
||||||
|
return (
|
||||||
|
<Stack spacing={3} className="flex-1 overflow-y-auto" sx={{ px: 3, py: 3 }}>
|
||||||
|
<SectionTitle>Company</SectionTitle>
|
||||||
|
<CompanySection roster={roster} />
|
||||||
|
<Divider />
|
||||||
|
<TechniciansSection roster={roster} />
|
||||||
|
{Boolean(roster.notes) && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<Box>
|
||||||
|
<SectionTitle>Notes</SectionTitle>
|
||||||
|
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>
|
||||||
|
{roster.notes}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerActions({ onEdit }: { onEdit: () => void }) {
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
sx={{
|
||||||
|
px: 3,
|
||||||
|
py: 2,
|
||||||
|
borderTop: (theme) => `1px solid ${theme.palette.divider}`,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button variant="contained" onClick={onEdit} startIcon={<EditOutlinedIcon />}>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VendorDetailDrawer({ open, vendor, onClose }: VendorDetailDrawerProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
const vendorId = vendor?.id;
|
const vendorId = vendor?.id;
|
||||||
|
const companyId = vendor?.companyId;
|
||||||
|
const hasVendorId = vendorId != null;
|
||||||
const {
|
const {
|
||||||
data: detail,
|
data: roster,
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
} = useVendorDetail(vendorId !== undefined && vendorId !== "" ? String(vendorId) : undefined);
|
} = useVendorCompanyRoster(
|
||||||
const drawerForm = useVendorDetailDrawerForm({ detail, open, vendorId, onModeChange });
|
hasVendorId ? vendorId : undefined,
|
||||||
const trades = splitTrades(detail?.tradeSpecialties ?? vendor?.tradeSpecialties);
|
!hasVendorId ? (companyId ?? undefined) : undefined,
|
||||||
const mapsUrl = detail?.googleMapsUrl;
|
);
|
||||||
const pending = drawerForm.updateVendor.isPending;
|
|
||||||
|
const editHref = hasVendorId
|
||||||
|
? `/vendors/edit/${vendorId}`
|
||||||
|
: `/vendors/company/${companyId ?? ""}`;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
|
||||||
|
const closeOnEscape = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", closeOnEscape);
|
||||||
|
return () => window.removeEventListener("keydown", closeOnEscape);
|
||||||
|
}, [onClose, open]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer
|
<Drawer
|
||||||
open={open}
|
open={open}
|
||||||
onClose={pending ? undefined : onClose}
|
onClose={onClose}
|
||||||
anchor="right"
|
anchor="right"
|
||||||
aria-labelledby="vendor-detail-drawer-title"
|
aria-labelledby="vendor-detail-drawer-title"
|
||||||
ModalProps={{ keepMounted: false }}
|
ModalProps={{ keepMounted: false }}
|
||||||
|
|
@ -61,43 +304,20 @@ export function VendorDetailDrawer({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stack className="flex h-full flex-col">
|
<Stack className="flex h-full flex-col">
|
||||||
<VendorDrawerHeader vendor={vendor} mode={mode} pending={pending} onClose={onClose} />
|
<DrawerHeader roster={roster} vendor={vendor} onClose={onClose} />
|
||||||
|
|
||||||
{isLoading ? (
|
{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>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<Alert severity="error" sx={{ m: 2 }}>
|
<Alert severity="error" sx={{ m: 2 }}>
|
||||||
{error instanceof Error ? error.message : "Failed to load vendor"}
|
Failed to load vendor company. Try again.
|
||||||
</Alert>
|
</Alert>
|
||||||
) : detail ? (
|
) : roster ? (
|
||||||
<>
|
<>
|
||||||
{mode === "view" ? (
|
<DrawerBody roster={roster} />
|
||||||
<VendorDrawerView
|
<DrawerActions onEdit={() => navigate(editHref)} />
|
||||||
detail={detail}
|
|
||||||
mapsUrl={mapsUrl}
|
|
||||||
submitError={drawerForm.submitError}
|
|
||||||
trades={trades}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<VendorDrawerEdit
|
|
||||||
control={drawerForm.control}
|
|
||||||
errors={drawerForm.formState.errors}
|
|
||||||
companies={drawerForm.companies}
|
|
||||||
tradeOptions={tradeOptions}
|
|
||||||
submitError={drawerForm.submitError}
|
|
||||||
onCompanySelected={drawerForm.onCompanySelected}
|
|
||||||
onSubmit={drawerForm.handleSubmit(drawerForm.submit)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<VendorDrawerActions
|
|
||||||
mode={mode}
|
|
||||||
mapsUrl={mapsUrl}
|
|
||||||
pending={pending}
|
|
||||||
canSave={drawerForm.hasChanges && drawerForm.isFormValid}
|
|
||||||
onCancel={drawerForm.cancel}
|
|
||||||
onEdit={() => onModeChange("edit")}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Box className="flex flex-1 items-center justify-center">
|
<Box className="flex flex-1 items-center justify-center">
|
||||||
|
|
|
||||||
|
|
@ -1,424 +0,0 @@
|
||||||
import { useState } from "react";
|
|
||||||
import { Controller, type Control, type FieldErrors } from "react-hook-form";
|
|
||||||
import AddIcon from "@mui/icons-material/Add";
|
|
||||||
import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
|
|
||||||
import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
|
|
||||||
import CloseIcon from "@mui/icons-material/Close";
|
|
||||||
import {
|
|
||||||
Autocomplete,
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Chip,
|
|
||||||
Divider,
|
|
||||||
FormLabel,
|
|
||||||
Stack,
|
|
||||||
TextField,
|
|
||||||
Typography,
|
|
||||||
} from "@mui/material";
|
|
||||||
import type { VendorFacetCompany } from "@/domain/vendors/types/vendor";
|
|
||||||
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
|
||||||
|
|
||||||
interface VendorFormFieldsProps {
|
|
||||||
control: Control<VendorFormValues>;
|
|
||||||
errors: FieldErrors<VendorFormValues>;
|
|
||||||
companies: VendorFacetCompany[];
|
|
||||||
tradeOptions: string[];
|
|
||||||
onCompanySelected: (company: VendorFacetCompany | null) => void;
|
|
||||||
showTotalJobs?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
type VendorFieldErrors = FieldErrors<VendorFormValues>;
|
|
||||||
|
|
||||||
function splitTrades(value: string | undefined): string[] {
|
|
||||||
return (value ?? "")
|
|
||||||
.split(",")
|
|
||||||
.map((trade) => trade.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
function joinTrades(trades: string[]): string {
|
|
||||||
return trades.join(", ");
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatPhoneMask(input: string): string {
|
|
||||||
const digits = (input ?? "").replace(/\D+/g, "").slice(0, 10);
|
|
||||||
if (digits.length === 0) return "";
|
|
||||||
if (digits.length < 4) return `(${digits}`;
|
|
||||||
if (digits.length < 7) return `(${digits.slice(0, 3)}) ${digits.slice(3)}`;
|
|
||||||
return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function TechnicianFields({
|
|
||||||
control,
|
|
||||||
errors,
|
|
||||||
}: {
|
|
||||||
control: Control<VendorFormValues>;
|
|
||||||
errors: VendorFieldErrors;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<Typography variant="subtitle2" className="font-semibold uppercase">
|
|
||||||
Technician
|
|
||||||
</Typography>
|
|
||||||
<Stack spacing={2} className="mt-2">
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="contactName"
|
|
||||||
render={({ field }) => (
|
|
||||||
<TextField
|
|
||||||
{...field}
|
|
||||||
label="Technician Name"
|
|
||||||
required
|
|
||||||
error={Boolean(errors.contactName)}
|
|
||||||
helperText={errors.contactName?.message}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="phone"
|
|
||||||
render={({ field }) => (
|
|
||||||
<TextField
|
|
||||||
{...field}
|
|
||||||
label="Technician Phone"
|
|
||||||
placeholder="(XXX) XXX-XXXX"
|
|
||||||
inputMode="tel"
|
|
||||||
value={field.value ?? ""}
|
|
||||||
onChange={(event) => field.onChange(formatPhoneMask(event.target.value))}
|
|
||||||
error={Boolean(errors.phone)}
|
|
||||||
helperText={errors.phone?.message}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CompanyFields({
|
|
||||||
control,
|
|
||||||
errors,
|
|
||||||
companies,
|
|
||||||
onCompanySelected,
|
|
||||||
}: {
|
|
||||||
control: Control<VendorFormValues>;
|
|
||||||
errors: VendorFieldErrors;
|
|
||||||
companies: VendorFacetCompany[];
|
|
||||||
onCompanySelected: (company: VendorFacetCompany | null) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<Typography variant="subtitle2" className="font-semibold uppercase">
|
|
||||||
Company
|
|
||||||
</Typography>
|
|
||||||
<Stack spacing={2} className="mt-2">
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="companyName"
|
|
||||||
render={({ field }) => (
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="companyId"
|
|
||||||
render={({ field: companyIdField }) => (
|
|
||||||
<Autocomplete
|
|
||||||
freeSolo
|
|
||||||
options={companies}
|
|
||||||
getOptionLabel={(option) => (typeof option === "string" ? option : option.name)}
|
|
||||||
isOptionEqualToValue={(option, value) =>
|
|
||||||
typeof option === "string" || typeof value === "string"
|
|
||||||
? option === value
|
|
||||||
: option.name === value.name
|
|
||||||
}
|
|
||||||
value={field.value ?? ""}
|
|
||||||
inputValue={field.value ?? ""}
|
|
||||||
onInputChange={(_event, value, reason) => {
|
|
||||||
if (reason === "input") {
|
|
||||||
field.onChange(value);
|
|
||||||
companyIdField.onChange(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onChange={(_event, value, reason) => {
|
|
||||||
if (typeof value === "string") {
|
|
||||||
field.onChange(value);
|
|
||||||
companyIdField.onChange(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (value) {
|
|
||||||
field.onChange(value.name);
|
|
||||||
companyIdField.onChange(value.companyId);
|
|
||||||
if (reason === "selectOption") {
|
|
||||||
onCompanySelected(value);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
field.onChange("");
|
|
||||||
companyIdField.onChange(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
renderInput={(params) => (
|
|
||||||
<TextField
|
|
||||||
{...params}
|
|
||||||
label="Company"
|
|
||||||
required
|
|
||||||
placeholder="Select or type a company"
|
|
||||||
error={Boolean(errors.companyName)}
|
|
||||||
helperText={errors.companyName?.message}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="companyPhone"
|
|
||||||
render={({ field }) => (
|
|
||||||
<TextField
|
|
||||||
{...field}
|
|
||||||
label="Company Phone"
|
|
||||||
placeholder="(XXX) XXX-XXXX"
|
|
||||||
inputMode="tel"
|
|
||||||
value={field.value ?? ""}
|
|
||||||
onChange={(event) => field.onChange(formatPhoneMask(event.target.value))}
|
|
||||||
error={Boolean(errors.companyPhone)}
|
|
||||||
helperText={errors.companyPhone?.message}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="email"
|
|
||||||
render={({ field }) => (
|
|
||||||
<TextField
|
|
||||||
{...field}
|
|
||||||
label="Email"
|
|
||||||
type="email"
|
|
||||||
error={Boolean(errors.email)}
|
|
||||||
helperText={errors.email?.message}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="address"
|
|
||||||
render={({ field }) => <TextField {...field} label="Address" fullWidth />}
|
|
||||||
/>
|
|
||||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="city"
|
|
||||||
render={({ field }) => <TextField {...field} label="City" fullWidth />}
|
|
||||||
/>
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="state"
|
|
||||||
render={({ field }) => (
|
|
||||||
<TextField {...field} label="State" placeholder="e.g., IL" fullWidth />
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="zip"
|
|
||||||
render={({ field }) => <TextField {...field} label="Zip" fullWidth />}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="googleMapsUrl"
|
|
||||||
render={({ field }) => (
|
|
||||||
<TextField
|
|
||||||
{...field}
|
|
||||||
label="Google Maps URL"
|
|
||||||
placeholder="https://maps.google.com/..."
|
|
||||||
error={Boolean(errors.googleMapsUrl)}
|
|
||||||
helperText={errors.googleMapsUrl?.message ?? "Must use HTTPS"}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TradeSpecialtiesField({
|
|
||||||
control,
|
|
||||||
tradeOptions,
|
|
||||||
}: {
|
|
||||||
control: Control<VendorFormValues>;
|
|
||||||
tradeOptions: string[];
|
|
||||||
}) {
|
|
||||||
const [tradeInput, setTradeInput] = useState("");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<FormLabel component="legend">Trade Specialties</FormLabel>
|
|
||||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
|
||||||
First trade is primary. Reorder with the arrows.
|
|
||||||
</Typography>
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="tradeSpecialties"
|
|
||||||
render={({ field }) => {
|
|
||||||
const trades = splitTrades(field.value);
|
|
||||||
const remove = (trade: string) => {
|
|
||||||
field.onChange(joinTrades(trades.filter((entry) => entry !== trade)));
|
|
||||||
};
|
|
||||||
const move = (index: number, direction: -1 | 1) => {
|
|
||||||
const next = [...trades];
|
|
||||||
const target = index + direction;
|
|
||||||
if (target < 0 || target >= next.length) return;
|
|
||||||
[next[index], next[target]] = [next[target], next[index]];
|
|
||||||
field.onChange(joinTrades(next));
|
|
||||||
};
|
|
||||||
const add = (trade: string) => {
|
|
||||||
const normalized = trade.trim();
|
|
||||||
if (!normalized || trades.includes(normalized)) {
|
|
||||||
setTradeInput("");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
field.onChange(joinTrades([...trades, normalized]));
|
|
||||||
setTradeInput("");
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box className="mt-2">
|
|
||||||
<Box className="flex flex-wrap gap-1.5">
|
|
||||||
{trades.length === 0 ? (
|
|
||||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
|
||||||
No trades selected.
|
|
||||||
</Typography>
|
|
||||||
) : (
|
|
||||||
trades.map((trade, index) => (
|
|
||||||
<Chip
|
|
||||||
key={`${trade}-${index}`}
|
|
||||||
label={index === 0 ? `${trade} (primary)` : trade}
|
|
||||||
onDelete={() => remove(trade)}
|
|
||||||
deleteIcon={<CloseIcon data-testid={`remove-trade-${trade}`} />}
|
|
||||||
sx={{ px: 0.5 }}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
<Stack direction="row" spacing={1} className="mt-2" sx={{ alignItems: "center" }}>
|
|
||||||
<Autocomplete
|
|
||||||
freeSolo
|
|
||||||
options={tradeOptions}
|
|
||||||
value={null}
|
|
||||||
inputValue={tradeInput}
|
|
||||||
onInputChange={(_event, value, reason) => {
|
|
||||||
if (reason === "input") setTradeInput(value);
|
|
||||||
}}
|
|
||||||
onChange={(_event, value) => {
|
|
||||||
if (typeof value === "string") add(value);
|
|
||||||
}}
|
|
||||||
renderInput={(params) => (
|
|
||||||
<TextField
|
|
||||||
{...params}
|
|
||||||
label="Add Trade"
|
|
||||||
placeholder="Type or select a trade"
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === "Enter") {
|
|
||||||
event.preventDefault();
|
|
||||||
add(tradeInput);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
color="primary"
|
|
||||||
onClick={() => add(tradeInput)}
|
|
||||||
aria-label="Add trade"
|
|
||||||
disabled={!tradeInput.trim()}
|
|
||||||
>
|
|
||||||
<AddIcon />
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
{trades.length > 1 && (
|
|
||||||
<Stack direction="row" spacing={1} className="mt-2 flex-wrap">
|
|
||||||
{trades.map((trade, index) => (
|
|
||||||
<Stack key={`reorder-${trade}-${index}`} direction="row" spacing={0.5}>
|
|
||||||
{index > 0 && (
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
aria-label={`Move ${trade} up`}
|
|
||||||
onClick={() => move(index, -1)}
|
|
||||||
>
|
|
||||||
<ArrowUpwardIcon fontSize="small" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{index < trades.length - 1 && (
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
aria-label={`Move ${trade} down`}
|
|
||||||
onClick={() => move(index, 1)}
|
|
||||||
>
|
|
||||||
<ArrowDownwardIcon fontSize="small" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function VendorFormFields({
|
|
||||||
control,
|
|
||||||
errors,
|
|
||||||
companies,
|
|
||||||
tradeOptions,
|
|
||||||
onCompanySelected,
|
|
||||||
showTotalJobs = false,
|
|
||||||
}: VendorFormFieldsProps) {
|
|
||||||
return (
|
|
||||||
<Stack spacing={3}>
|
|
||||||
<TechnicianFields control={control} errors={errors} />
|
|
||||||
<Divider />
|
|
||||||
<CompanyFields
|
|
||||||
control={control}
|
|
||||||
errors={errors}
|
|
||||||
companies={companies}
|
|
||||||
onCompanySelected={onCompanySelected}
|
|
||||||
/>
|
|
||||||
<Divider />
|
|
||||||
<TradeSpecialtiesField control={control} tradeOptions={tradeOptions} />
|
|
||||||
<Divider />
|
|
||||||
{showTotalJobs && (
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="totalJobs"
|
|
||||||
render={({ field }) => (
|
|
||||||
<TextField
|
|
||||||
{...field}
|
|
||||||
value={field.value ?? 0}
|
|
||||||
label="Total Jobs"
|
|
||||||
type="number"
|
|
||||||
slotProps={{ input: { readOnly: true } }}
|
|
||||||
helperText="Calculated from work-order history"
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="notes"
|
|
||||||
render={({ field }) => (
|
|
||||||
<TextField {...field} label="Notes" multiline minRows={3} fullWidth />
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,180 +0,0 @@
|
||||||
import { useEffect } from "react";
|
|
||||||
import { Controller, useForm, useWatch } from "react-hook-form";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { useNavigate, useParams } from "react-router";
|
|
||||||
import {
|
|
||||||
Alert,
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
CircularProgress,
|
|
||||||
FormControlLabel,
|
|
||||||
Paper,
|
|
||||||
Stack,
|
|
||||||
Switch,
|
|
||||||
Typography,
|
|
||||||
} from "@mui/material";
|
|
||||||
import { VendorPortalTokenPanel } from "./vendor-portal-token-panel";
|
|
||||||
import { VendorFormFields } from "./vendor-form-fields";
|
|
||||||
import { vendorSchema, type VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
|
||||||
import { useCreateVendor } from "@/domain/vendors/use-cases/use-create-vendor";
|
|
||||||
import { useUpdateVendor } from "@/domain/vendors/use-cases/use-update-vendor";
|
|
||||||
import { useVendorDetail } from "@/domain/vendors/use-cases/use-vendor-detail";
|
|
||||||
import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets";
|
|
||||||
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
|
|
||||||
import type { VendorFacetCompany } from "@/domain/vendors/types/vendor";
|
|
||||||
|
|
||||||
const defaultValues: VendorFormValues = {
|
|
||||||
companyName: "",
|
|
||||||
contactName: "",
|
|
||||||
email: "",
|
|
||||||
phone: "",
|
|
||||||
companyPhone: "",
|
|
||||||
googleMapsUrl: "",
|
|
||||||
notes: "",
|
|
||||||
totalJobs: 0,
|
|
||||||
address: "",
|
|
||||||
city: "",
|
|
||||||
state: "",
|
|
||||||
zip: "",
|
|
||||||
tradeSpecialties: "",
|
|
||||||
isActive: true,
|
|
||||||
companyId: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function VendorFormPage() {
|
|
||||||
const { id } = useParams();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const isEdit = Boolean(id);
|
|
||||||
|
|
||||||
const { data: vendor, isLoading, error } = useVendorDetail(id);
|
|
||||||
const { data: facets } = useVendorFacets();
|
|
||||||
const { data: trades = [] } = useDropdownOptionsByCategory("Trade");
|
|
||||||
const createVendor = useCreateVendor();
|
|
||||||
const updateVendor = useUpdateVendor();
|
|
||||||
|
|
||||||
const {
|
|
||||||
handleSubmit,
|
|
||||||
reset,
|
|
||||||
setValue,
|
|
||||||
control,
|
|
||||||
trigger,
|
|
||||||
formState: { errors },
|
|
||||||
} = useForm<VendorFormValues>({
|
|
||||||
resolver: zodResolver(vendorSchema),
|
|
||||||
defaultValues,
|
|
||||||
mode: "onChange",
|
|
||||||
});
|
|
||||||
const currentValues = useWatch({ control });
|
|
||||||
const isFormValid = vendorSchema.safeParse(currentValues).success;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (vendor && isEdit) {
|
|
||||||
reset({
|
|
||||||
companyName: vendor.companyName,
|
|
||||||
contactName: vendor.contactName,
|
|
||||||
email: vendor.email,
|
|
||||||
phone: vendor.phone,
|
|
||||||
companyPhone: vendor.companyPhone,
|
|
||||||
googleMapsUrl: vendor.googleMapsUrl,
|
|
||||||
notes: vendor.notes,
|
|
||||||
totalJobs: vendor.totalJobs,
|
|
||||||
address: vendor.address,
|
|
||||||
city: vendor.city,
|
|
||||||
state: vendor.state,
|
|
||||||
zip: vendor.zip,
|
|
||||||
tradeSpecialties: vendor.tradeSpecialties,
|
|
||||||
isActive: vendor.isActive,
|
|
||||||
companyId: vendor.companyId ?? null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [vendor, isEdit, reset]);
|
|
||||||
|
|
||||||
const onCompanySelected = (company: VendorFacetCompany | null) => {
|
|
||||||
if (!company) return;
|
|
||||||
const options = { shouldDirty: true, shouldValidate: true } as const;
|
|
||||||
setValue("companyId", company.companyId, options);
|
|
||||||
setValue("companyPhone", company.companyPhone, options);
|
|
||||||
setValue("email", company.email, options);
|
|
||||||
setValue("address", company.address, options);
|
|
||||||
setValue("city", company.city, options);
|
|
||||||
setValue("state", company.state, options);
|
|
||||||
setValue("zip", company.zip, options);
|
|
||||||
setValue("googleMapsUrl", company.googleMapsUrl, options);
|
|
||||||
void trigger();
|
|
||||||
};
|
|
||||||
|
|
||||||
const isSaving = createVendor.isPending || updateVendor.isPending;
|
|
||||||
|
|
||||||
const onSubmit = (values: VendorFormValues) => {
|
|
||||||
if (isEdit && id) {
|
|
||||||
updateVendor.mutate({ id, values }, { onSuccess: () => navigate("/vendors") });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
createVendor.mutate(values, { onSuccess: () => navigate("/vendors") });
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isEdit && isLoading) {
|
|
||||||
return (
|
|
||||||
<Box className="flex justify-center p-10">
|
|
||||||
<CircularProgress />
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box className="mx-auto max-w-4xl p-4">
|
|
||||||
<Paper variant="outlined" className="p-6">
|
|
||||||
<Stack spacing={4}>
|
|
||||||
<Typography variant="h5">{isEdit ? "Edit Vendor" : "Create Vendor"}</Typography>
|
|
||||||
|
|
||||||
{Boolean(error) && (
|
|
||||||
<Alert severity="error">
|
|
||||||
{error instanceof Error ? error.message : "Failed to load vendor"}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Box component="form" onSubmit={handleSubmit(onSubmit)}>
|
|
||||||
<Stack spacing={3}>
|
|
||||||
<Controller
|
|
||||||
name="isActive"
|
|
||||||
control={control}
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormControlLabel
|
|
||||||
control={<Switch checked={Boolean(field.value)} onChange={field.onChange} />}
|
|
||||||
label={field.value ? "Active" : "Inactive"}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<VendorFormFields
|
|
||||||
control={control}
|
|
||||||
errors={errors}
|
|
||||||
companies={facets?.companies ?? []}
|
|
||||||
tradeOptions={trades.map((trade) => trade.value)}
|
|
||||||
onCompanySelected={onCompanySelected}
|
|
||||||
showTotalJobs={isEdit}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{isEdit && id != null && <VendorPortalTokenPanel vendorId={id} />}
|
|
||||||
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
spacing={2}
|
|
||||||
sx={{
|
|
||||||
justifyContent: "flex-end",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button variant="outlined" onClick={() => navigate("/vendors")} disabled={isSaving}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" variant="contained" disabled={isSaving || !isFormValid}>
|
|
||||||
{isSaving ? "Saving..." : isEdit ? "Update" : "Create"}
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
58
src/app/(protected)/vendors/_components/vendor-roster-conflict-alert.tsx
vendored
Normal file
58
src/app/(protected)/vendors/_components/vendor-roster-conflict-alert.tsx
vendored
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { Alert, AlertTitle, Box, Button, List, ListItem, Stack, Typography } from "@mui/material";
|
||||||
|
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||||
|
import type { VendorRosterConflict } from "@/domain/vendors/types/vendor";
|
||||||
|
|
||||||
|
interface VendorRosterConflictAlertProps {
|
||||||
|
conflict: VendorRosterConflict;
|
||||||
|
onReload: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VendorRosterConflictAlert({ conflict, onReload }: VendorRosterConflictAlertProps) {
|
||||||
|
if (conflict.kind === "stale") {
|
||||||
|
return (
|
||||||
|
<Alert
|
||||||
|
severity="warning"
|
||||||
|
icon={<RefreshIcon fontSize="small" />}
|
||||||
|
action={
|
||||||
|
<Button color="inherit" size="small" onClick={onReload}>
|
||||||
|
Reload
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<AlertTitle>Someone else updated this company</AlertTitle>
|
||||||
|
{conflict.message}
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Alert severity="error">
|
||||||
|
<AlertTitle>Open work orders block this save</AlertTitle>
|
||||||
|
{conflict.message}
|
||||||
|
{conflict.blockedWorkOrders.length > 0 && (
|
||||||
|
<Box className="mt-1">
|
||||||
|
<List dense disablePadding>
|
||||||
|
{conflict.blockedWorkOrders.map((workOrder) => (
|
||||||
|
<ListItem key={String(workOrder.workOrderId)} disableGutters>
|
||||||
|
<Stack>
|
||||||
|
<Typography variant="body2">
|
||||||
|
{workOrder.workOrderNumber
|
||||||
|
? `${workOrder.workOrderNumber} — ${workOrder.workOrderTitle || "Untitled"}`
|
||||||
|
: workOrder.workOrderTitle || `Work order ${workOrder.workOrderId}`}
|
||||||
|
</Typography>
|
||||||
|
{Boolean(workOrder.status) && (
|
||||||
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||||
|
{workOrder.status}
|
||||||
|
{workOrder.scheduledDate ? ` · ${workOrder.scheduledDate}` : ""}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
<Typography variant="caption">Resolve these work orders, then save again.</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
386
src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx
vendored
Normal file
386
src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx
vendored
Normal file
|
|
@ -0,0 +1,386 @@
|
||||||
|
import { Controller, useFieldArray, type Control, type FieldErrors } from "react-hook-form";
|
||||||
|
import AddIcon from "@mui/icons-material/Add";
|
||||||
|
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
|
||||||
|
import {
|
||||||
|
Autocomplete,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Divider,
|
||||||
|
FormControlLabel,
|
||||||
|
IconButton,
|
||||||
|
Stack,
|
||||||
|
Switch,
|
||||||
|
TextField,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { VendorTradeSpecialtiesField } from "./vendor-trade-specialties-field";
|
||||||
|
import { formatNorthAmericanPhone } from "@/lib/format/na-phone";
|
||||||
|
import type { VendorCompanyRosterFormValues } from "@/domain/vendors/schemas/vendor-roster-schema";
|
||||||
|
import type { RosterTechnicianValues } from "@/domain/vendors/schemas/vendor-roster-schema";
|
||||||
|
import type { VendorFacetCompany } from "@/domain/vendors/types/vendor";
|
||||||
|
|
||||||
|
interface VendorRosterFormFieldsProps {
|
||||||
|
control: Control<VendorCompanyRosterFormValues>;
|
||||||
|
errors: FieldErrors<VendorCompanyRosterFormValues>;
|
||||||
|
companies?: VendorFacetCompany[];
|
||||||
|
tradeOptions?: string[];
|
||||||
|
selectedCompanyId?: string | number | null;
|
||||||
|
onSelectCompany?: (company: VendorFacetCompany | null) => Promise<void>;
|
||||||
|
onClearSelectedCompany?: (nextName?: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CompanyNameField({
|
||||||
|
control,
|
||||||
|
errors,
|
||||||
|
companies,
|
||||||
|
selectedCompanyId,
|
||||||
|
onSelectCompany,
|
||||||
|
onClearSelectedCompany,
|
||||||
|
}: VendorRosterFormFieldsProps) {
|
||||||
|
const useAutocomplete = companies !== undefined && onSelectCompany !== undefined;
|
||||||
|
const selectedCompany =
|
||||||
|
companies?.find(
|
||||||
|
(company) =>
|
||||||
|
company.companyId != null && String(company.companyId) === String(selectedCompanyId),
|
||||||
|
) ?? null;
|
||||||
|
|
||||||
|
if (!useAutocomplete) {
|
||||||
|
return (
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
label="Company"
|
||||||
|
required
|
||||||
|
error={Boolean(errors.name)}
|
||||||
|
helperText={errors.name?.message}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<Autocomplete<VendorFacetCompany, false, false, true>
|
||||||
|
freeSolo
|
||||||
|
options={companies ?? []}
|
||||||
|
value={selectedCompany ?? field.value}
|
||||||
|
inputValue={field.value}
|
||||||
|
getOptionLabel={(option) => (typeof option === "string" ? option : option.name)}
|
||||||
|
isOptionEqualToValue={(option, value) =>
|
||||||
|
typeof value !== "string" &&
|
||||||
|
option.companyId != null &&
|
||||||
|
String(option.companyId) === String(value.companyId)
|
||||||
|
}
|
||||||
|
onChange={(_event, value) => {
|
||||||
|
if (typeof value === "object" && value !== null) {
|
||||||
|
void onSelectCompany?.(value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onClearSelectedCompany?.(typeof value === "string" ? value : "");
|
||||||
|
field.onChange(typeof value === "string" ? value : "");
|
||||||
|
}}
|
||||||
|
onInputChange={(_event, value, reason) => {
|
||||||
|
if (reason !== "input") return;
|
||||||
|
if (selectedCompanyId != null) onClearSelectedCompany?.(value);
|
||||||
|
field.onChange(value);
|
||||||
|
}}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label="Company"
|
||||||
|
required
|
||||||
|
error={Boolean(errors.name)}
|
||||||
|
helperText={errors.name?.message ?? "Choose an existing company or enter a new one"}
|
||||||
|
inputRef={field.ref}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CompanyFields({
|
||||||
|
control,
|
||||||
|
errors,
|
||||||
|
companies,
|
||||||
|
selectedCompanyId,
|
||||||
|
onSelectCompany,
|
||||||
|
onClearSelectedCompany,
|
||||||
|
}: VendorRosterFormFieldsProps) {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" className="font-semibold uppercase">
|
||||||
|
Company
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={2} className="mt-2">
|
||||||
|
<CompanyNameField
|
||||||
|
control={control}
|
||||||
|
errors={errors}
|
||||||
|
companies={companies}
|
||||||
|
selectedCompanyId={selectedCompanyId}
|
||||||
|
onSelectCompany={onSelectCompany}
|
||||||
|
onClearSelectedCompany={onClearSelectedCompany}
|
||||||
|
/>
|
||||||
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="companyPhone"
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
label="Company Phone"
|
||||||
|
placeholder="(XXX) XXX-XXXX"
|
||||||
|
inputMode="tel"
|
||||||
|
value={field.value ?? ""}
|
||||||
|
onChange={(event) => field.onChange(formatNorthAmericanPhone(event.target.value))}
|
||||||
|
error={Boolean(errors.companyPhone)}
|
||||||
|
helperText={errors.companyPhone?.message ?? "Phone or email required"}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
error={Boolean(errors.email)}
|
||||||
|
helperText={errors.email?.message}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="address"
|
||||||
|
render={({ field }) => <TextField {...field} label="Address" fullWidth />}
|
||||||
|
/>
|
||||||
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="city"
|
||||||
|
render={({ field }) => <TextField {...field} label="City" fullWidth />}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="state"
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField {...field} label="State" placeholder="e.g., MO" fullWidth />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="zip"
|
||||||
|
render={({ field }) => <TextField {...field} label="Zip" fullWidth />}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="googleMapsUrl"
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
label="Google Maps URL"
|
||||||
|
placeholder="https://maps.google.com/..."
|
||||||
|
error={Boolean(errors.googleMapsUrl)}
|
||||||
|
helperText={errors.googleMapsUrl?.message ?? "Must be an HTTPS link"}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="notes"
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField {...field} label="Notes" multiline minRows={2} fullWidth />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TechnicianRowProps {
|
||||||
|
control: Control<VendorCompanyRosterFormValues>;
|
||||||
|
index: number;
|
||||||
|
error: FieldErrors<RosterTechnicianValues> | undefined;
|
||||||
|
onRemove: () => void;
|
||||||
|
canRemove: boolean;
|
||||||
|
tradeOptions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function TechnicianRow({
|
||||||
|
control,
|
||||||
|
index,
|
||||||
|
error,
|
||||||
|
onRemove,
|
||||||
|
canRemove,
|
||||||
|
tradeOptions,
|
||||||
|
}: TechnicianRowProps) {
|
||||||
|
return (
|
||||||
|
<Stack spacing={2} className="rounded-md border border-solid border-gray-200 p-3">
|
||||||
|
<Stack direction="row" sx={{ alignItems: "center", justifyContent: "space-between" }}>
|
||||||
|
<Typography variant="subtitle2" className="font-semibold">
|
||||||
|
Technician {index + 1}
|
||||||
|
</Typography>
|
||||||
|
<Tooltip title="Remove technician">
|
||||||
|
<span>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label={`Remove technician ${index + 1}`}
|
||||||
|
onClick={onRemove}
|
||||||
|
disabled={!canRemove}
|
||||||
|
>
|
||||||
|
<DeleteOutlineIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</Stack>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name={`technicians.${index}.contactName`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
label="Technician Name"
|
||||||
|
error={Boolean(error?.contactName)}
|
||||||
|
helperText={error?.contactName?.message}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name={`technicians.${index}.phone`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
label="Technician Phone"
|
||||||
|
placeholder="(XXX) XXX-XXXX"
|
||||||
|
inputMode="tel"
|
||||||
|
value={field.value ?? ""}
|
||||||
|
onChange={(event) => field.onChange(formatNorthAmericanPhone(event.target.value))}
|
||||||
|
error={Boolean(error?.phone)}
|
||||||
|
helperText={error?.phone?.message}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name={`technicians.${index}.email`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
label="Technician Email"
|
||||||
|
type="email"
|
||||||
|
error={Boolean(error?.email)}
|
||||||
|
helperText={error?.email?.message}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
<VendorTradeSpecialtiesField control={control} index={index} tradeOptions={tradeOptions} />
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name={`technicians.${index}.isActive`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onChange={(_event, checked) => field.onChange(checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={field.value ? "Active" : "Inactive"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TechniciansFieldArray({
|
||||||
|
control,
|
||||||
|
errors,
|
||||||
|
tradeOptions,
|
||||||
|
}: {
|
||||||
|
control: Control<VendorCompanyRosterFormValues>;
|
||||||
|
errors: FieldErrors<VendorCompanyRosterFormValues>;
|
||||||
|
tradeOptions: string[];
|
||||||
|
}) {
|
||||||
|
const { fields, append, remove } = useFieldArray({ control, name: "technicians" });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Stack direction="row" sx={{ alignItems: "center", justifyContent: "space-between" }}>
|
||||||
|
<Typography variant="subtitle2" className="font-semibold uppercase">
|
||||||
|
Technicians ({fields.length})
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
variant="text"
|
||||||
|
startIcon={<AddIcon fontSize="small" />}
|
||||||
|
onClick={() =>
|
||||||
|
append({
|
||||||
|
contactName: "",
|
||||||
|
phone: "",
|
||||||
|
email: "",
|
||||||
|
tradeSpecialties: "",
|
||||||
|
isActive: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Add technician
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
<Stack spacing={2} className="mt-2">
|
||||||
|
{fields.length === 0 ? (
|
||||||
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||||
|
No technicians yet. A company can be saved with zero technicians.
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
fields.map((field, index) => (
|
||||||
|
<TechnicianRow
|
||||||
|
key={field.id}
|
||||||
|
control={control}
|
||||||
|
index={index}
|
||||||
|
error={errors.technicians?.[index]}
|
||||||
|
onRemove={() => remove(index)}
|
||||||
|
canRemove
|
||||||
|
tradeOptions={tradeOptions}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VendorRosterFormFields(props: VendorRosterFormFieldsProps) {
|
||||||
|
const { control, errors, tradeOptions = [] } = props;
|
||||||
|
return (
|
||||||
|
<Stack spacing={3}>
|
||||||
|
<CompanyFields {...props} />
|
||||||
|
<Divider />
|
||||||
|
<TechniciansFieldArray control={control} errors={errors} tradeOptions={tradeOptions} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
src/app/(protected)/vendors/_components/vendor-roster-load-error.tsx
vendored
Normal file
21
src/app/(protected)/vendors/_components/vendor-roster-load-error.tsx
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { Alert, Button } from "@mui/material";
|
||||||
|
|
||||||
|
interface VendorRosterLoadErrorAlertProps {
|
||||||
|
message: string;
|
||||||
|
onRetry: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VendorRosterLoadErrorAlert({ message, onRetry }: VendorRosterLoadErrorAlertProps) {
|
||||||
|
return (
|
||||||
|
<Alert
|
||||||
|
severity="error"
|
||||||
|
action={
|
||||||
|
<Button color="inherit" size="small" onClick={onRetry}>
|
||||||
|
Try again
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{message}
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
130
src/app/(protected)/vendors/_components/vendor-roster-page.tsx
vendored
Normal file
130
src/app/(protected)/vendors/_components/vendor-roster-page.tsx
vendored
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
|
import { Alert, Box, Button, CircularProgress, Paper, Stack, Typography } from "@mui/material";
|
||||||
|
import { VendorPortalTokenPanel } from "./vendor-portal-token-panel";
|
||||||
|
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";
|
||||||
|
|
||||||
|
interface VendorRosterPageProps {
|
||||||
|
vendorId?: string;
|
||||||
|
companyId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PageShell({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Box className="mx-auto max-w-4xl p-4">
|
||||||
|
<Paper variant="outlined" className="p-6">
|
||||||
|
<Stack spacing={4}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h5" component="h1">
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.5 }}>
|
||||||
|
{subtitle}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
{children}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPageProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const isEdit = vendorId !== undefined || companyId !== undefined;
|
||||||
|
const form = useVendorRosterForm({
|
||||||
|
mode: isEdit ? "update" : "create",
|
||||||
|
vendorId,
|
||||||
|
companyId,
|
||||||
|
onSuccess: () => navigate("/vendors"),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isEdit && form.isLoading) {
|
||||||
|
return (
|
||||||
|
<Box className="flex justify-center p-10">
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEdit && form.isError) {
|
||||||
|
return (
|
||||||
|
<PageShell title="Vendor company" subtitle="Edit the company and its technicians.">
|
||||||
|
<Alert severity="error">Failed to load vendor company. Try again.</Alert>
|
||||||
|
<Stack direction="row" sx={{ justifyContent: "flex-end" }}>
|
||||||
|
<Button variant="outlined" onClick={() => navigate("/vendors")}>
|
||||||
|
Back to vendors
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</PageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEdit && !form.roster) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = isEdit ? "Edit Vendor Company" : "Add Vendor Company";
|
||||||
|
const subtitle = isEdit
|
||||||
|
? "Update company details and technicians in one save."
|
||||||
|
: "Register a company and add zero, one, or multiple technicians.";
|
||||||
|
const companySelectionProps = isEdit
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
companies: form.companies,
|
||||||
|
selectedCompanyId: form.selectedCompanyId,
|
||||||
|
onSelectCompany: form.selectCompany,
|
||||||
|
onClearSelectedCompany: form.clearSelectedCompany,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell title={title} subtitle={subtitle}>
|
||||||
|
<Box component="form" onSubmit={form.handleSubmit(form.submit)}>
|
||||||
|
<Stack spacing={3}>
|
||||||
|
{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}
|
||||||
|
{...companySelectionProps}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isEdit && vendorId !== undefined && vendorId !== "" && (
|
||||||
|
<VendorPortalTokenPanel vendorId={vendorId} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={2} sx={{ justifyContent: "flex-end" }}>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => navigate("/vendors")}
|
||||||
|
disabled={form.isSaving}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" variant="contained" disabled={form.isSaving || !form.isValid}>
|
||||||
|
{form.isSaving ? "Saving..." : isEdit ? "Save" : "Create"}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</PageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
152
src/app/(protected)/vendors/_components/vendor-trade-specialties-field.tsx
vendored
Normal file
152
src/app/(protected)/vendors/_components/vendor-trade-specialties-field.tsx
vendored
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Controller, type Control } from "react-hook-form";
|
||||||
|
import AddIcon from "@mui/icons-material/Add";
|
||||||
|
import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
|
||||||
|
import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
|
||||||
|
import CloseIcon from "@mui/icons-material/Close";
|
||||||
|
import {
|
||||||
|
Autocomplete,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
FormLabel,
|
||||||
|
Stack,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import type { VendorCompanyRosterFormValues } from "@/domain/vendors/schemas/vendor-roster-schema";
|
||||||
|
|
||||||
|
function splitTrades(value: string | undefined): string[] {
|
||||||
|
return (value ?? "")
|
||||||
|
.split(",")
|
||||||
|
.map((trade) => trade.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VendorTradeSpecialtiesFieldProps {
|
||||||
|
control: Control<VendorCompanyRosterFormValues>;
|
||||||
|
index: number;
|
||||||
|
tradeOptions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VendorTradeSpecialtiesField({
|
||||||
|
control,
|
||||||
|
index,
|
||||||
|
tradeOptions,
|
||||||
|
}: VendorTradeSpecialtiesFieldProps) {
|
||||||
|
const [tradeInput, setTradeInput] = useState("");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<FormLabel component="legend">Trade Specialties</FormLabel>
|
||||||
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||||
|
First trade is primary. Reorder with the arrows.
|
||||||
|
</Typography>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name={`technicians.${index}.tradeSpecialties`}
|
||||||
|
render={({ field }) => {
|
||||||
|
const trades = splitTrades(field.value);
|
||||||
|
const commit = (next: string[]) => field.onChange(next.join(", "));
|
||||||
|
const add = (value: string) => {
|
||||||
|
const normalized = value.trim();
|
||||||
|
if (normalized && !trades.includes(normalized)) commit([...trades, normalized]);
|
||||||
|
setTradeInput("");
|
||||||
|
};
|
||||||
|
const move = (tradeIndex: number, direction: -1 | 1) => {
|
||||||
|
const target = tradeIndex + direction;
|
||||||
|
if (target < 0 || target >= trades.length) return;
|
||||||
|
const next = [...trades];
|
||||||
|
[next[tradeIndex], next[target]] = [next[target], next[tradeIndex]];
|
||||||
|
commit(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack spacing={1} className="mt-2">
|
||||||
|
<Box className="flex flex-wrap gap-1.5">
|
||||||
|
{trades.length === 0 ? (
|
||||||
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||||
|
No trades selected.
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
trades.map((trade, tradeIndex) => (
|
||||||
|
<Chip
|
||||||
|
key={`${trade}-${tradeIndex}`}
|
||||||
|
label={tradeIndex === 0 ? `${trade} (primary)` : trade}
|
||||||
|
onDelete={() =>
|
||||||
|
commit(trades.filter((_, itemIndex) => itemIndex !== tradeIndex))
|
||||||
|
}
|
||||||
|
deleteIcon={<CloseIcon data-testid={`remove-trade-${trade}`} />}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
||||||
|
<Autocomplete
|
||||||
|
freeSolo
|
||||||
|
options={tradeOptions}
|
||||||
|
value={null}
|
||||||
|
inputValue={tradeInput}
|
||||||
|
onInputChange={(_event, value, reason) => {
|
||||||
|
if (reason === "input") setTradeInput(value);
|
||||||
|
}}
|
||||||
|
onChange={(_event, value) => {
|
||||||
|
if (typeof value === "string") add(value);
|
||||||
|
}}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label="Add Trade"
|
||||||
|
placeholder="Type or select a trade"
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
add(tradeInput);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
onClick={() => add(tradeInput)}
|
||||||
|
aria-label="Add trade"
|
||||||
|
disabled={!tradeInput.trim()}
|
||||||
|
>
|
||||||
|
<AddIcon />
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
{trades.length > 1 && (
|
||||||
|
<Stack direction="row" spacing={1} className="flex-wrap">
|
||||||
|
{trades.map((trade, tradeIndex) => (
|
||||||
|
<Stack key={`reorder-${trade}-${tradeIndex}`} direction="row" spacing={0.5}>
|
||||||
|
{tradeIndex > 0 && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
aria-label={`Move ${trade} up`}
|
||||||
|
onClick={() => move(tradeIndex, -1)}
|
||||||
|
>
|
||||||
|
<ArrowUpwardIcon fontSize="small" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{tradeIndex < trades.length - 1 && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
aria-label={`Move ${trade} down`}
|
||||||
|
onClick={() => move(tradeIndex, 1)}
|
||||||
|
>
|
||||||
|
<ArrowDownwardIcon fontSize="small" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
168
src/app/(protected)/vendors/_components/vendors-list-toolbar.tsx
vendored
Normal file
168
src/app/(protected)/vendors/_components/vendors-list-toolbar.tsx
vendored
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
import FilterListIcon from "@mui/icons-material/FilterList";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
CircularProgress,
|
||||||
|
Stack,
|
||||||
|
TablePagination,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
|
||||||
|
interface VendorsListToolbarProps {
|
||||||
|
globalCount: number;
|
||||||
|
activeCount: number;
|
||||||
|
isFetching: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
search: string;
|
||||||
|
onSearchChange: (value: string) => void;
|
||||||
|
appliedFilterCount: number;
|
||||||
|
onOpenFilters: () => void;
|
||||||
|
onAddVendor: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VendorsListToolbar({
|
||||||
|
globalCount,
|
||||||
|
activeCount,
|
||||||
|
isFetching,
|
||||||
|
isLoading,
|
||||||
|
search,
|
||||||
|
onSearchChange,
|
||||||
|
appliedFilterCount,
|
||||||
|
onOpenFilters,
|
||||||
|
onAddVendor,
|
||||||
|
}: VendorsListToolbarProps) {
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
direction={{ xs: "column", md: "row" }}
|
||||||
|
spacing={2}
|
||||||
|
sx={{
|
||||||
|
alignItems: { xs: "stretch", md: "center" },
|
||||||
|
justifyContent: "space-between",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack direction="row" spacing={2} sx={{ alignItems: "baseline" }}>
|
||||||
|
<Typography variant="h5" component="h1">
|
||||||
|
Vendors / Technicians
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||||
|
{globalCount} vendor entries · {activeCount} active
|
||||||
|
</Typography>
|
||||||
|
{isFetching && !isLoading && <CircularProgress size={16} />}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack
|
||||||
|
direction={{ xs: "column", sm: "row" }}
|
||||||
|
spacing={2}
|
||||||
|
sx={{ alignItems: { xs: "stretch", sm: "center" } }}
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
placeholder="Search name, company, trade..."
|
||||||
|
className="min-w-[260px]"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={onOpenFilters}
|
||||||
|
startIcon={<FilterListIcon />}
|
||||||
|
aria-label="Open vendor filters"
|
||||||
|
>
|
||||||
|
Filters
|
||||||
|
{appliedFilterCount > 0 && (
|
||||||
|
<Chip size="small" color="primary" label={appliedFilterCount} sx={{ ml: 1 }} />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button variant="contained" onClick={onAddVendor}>
|
||||||
|
+ Add Vendor
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AppliedChipEntry {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
onRemove: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VendorAppliedFiltersBarProps {
|
||||||
|
chips: AppliedChipEntry[];
|
||||||
|
onClearAll: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VendorAppliedFiltersBar({ chips, onClearAll }: VendorAppliedFiltersBarProps) {
|
||||||
|
if (chips.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<Stack direction="row" spacing={1} className="flex flex-wrap items-center">
|
||||||
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||||
|
Applied:
|
||||||
|
</Typography>
|
||||||
|
{chips.map((chip) => (
|
||||||
|
<Chip
|
||||||
|
key={chip.key}
|
||||||
|
size="small"
|
||||||
|
label={chip.label}
|
||||||
|
onDelete={chip.onRemove}
|
||||||
|
aria-label={`Remove filter ${chip.label}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<Button size="small" onClick={onClearAll}>
|
||||||
|
Clear all
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VendorsListFooterProps {
|
||||||
|
totalCount: number;
|
||||||
|
isActive: boolean;
|
||||||
|
activeCount: number;
|
||||||
|
inactiveCount: number;
|
||||||
|
isFiltered: boolean;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
onPageSizeChange: (pageSize: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VendorsListFooter({
|
||||||
|
totalCount,
|
||||||
|
isActive,
|
||||||
|
activeCount,
|
||||||
|
inactiveCount,
|
||||||
|
isFiltered,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
onPageChange,
|
||||||
|
onPageSizeChange,
|
||||||
|
}: VendorsListFooterProps) {
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
direction={{ xs: "column", sm: "row" }}
|
||||||
|
spacing={2}
|
||||||
|
sx={{
|
||||||
|
alignItems: { xs: "stretch", sm: "center" },
|
||||||
|
justifyContent: "space-between",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||||
|
{totalCount} {isActive ? "active" : "inactive"}{" "}
|
||||||
|
{totalCount === 1 ? "vendor entry" : "vendor entries"}
|
||||||
|
{isFiltered && " · filtered"}
|
||||||
|
{` · ${activeCount} active · ${inactiveCount} inactive`}
|
||||||
|
</Typography>
|
||||||
|
<TablePagination
|
||||||
|
component="div"
|
||||||
|
count={totalCount}
|
||||||
|
page={page}
|
||||||
|
onPageChange={(_e, nextPage) => onPageChange(nextPage)}
|
||||||
|
rowsPerPage={pageSize}
|
||||||
|
onRowsPerPageChange={(e) => onPageSizeChange(Number(e.target.value))}
|
||||||
|
rowsPerPageOptions={[12, 24, 48]}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
263
src/app/(protected)/vendors/_components/vendors-table.tsx
vendored
Normal file
263
src/app/(protected)/vendors/_components/vendors-table.tsx
vendored
Normal file
|
|
@ -0,0 +1,263 @@
|
||||||
|
import { type KeyboardEvent, type MouseEvent } from "react";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
|
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
|
||||||
|
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
||||||
|
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
Chip,
|
||||||
|
CircularProgress,
|
||||||
|
IconButton,
|
||||||
|
Link,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import type { VendorListItem } from "@/domain/vendors/types/vendor";
|
||||||
|
|
||||||
|
const TABLE_COLUMNS = [
|
||||||
|
"Company",
|
||||||
|
"Technician",
|
||||||
|
"Location",
|
||||||
|
"Trade",
|
||||||
|
"Phone",
|
||||||
|
"Email",
|
||||||
|
"Total Jobs",
|
||||||
|
"Status",
|
||||||
|
"View",
|
||||||
|
"Edit",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function getInitials(name: string): string {
|
||||||
|
const parts = name.trim().split(/\s+/).filter(Boolean);
|
||||||
|
if (parts.length === 0) return "?";
|
||||||
|
const first = parts[0]?.[0] ?? "";
|
||||||
|
const last = parts.length > 1 ? (parts[parts.length - 1]?.[0] ?? "") : "";
|
||||||
|
return (first + last).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitTrades(value: string | undefined): string[] {
|
||||||
|
return (value ?? "")
|
||||||
|
.split(",")
|
||||||
|
.map((trade) => trade.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPropagation(event: MouseEvent): void {
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VendorTableRowProps {
|
||||||
|
row: VendorListItem;
|
||||||
|
onOpenDetail: (row: VendorListItem) => void;
|
||||||
|
onOpenDeactivation: (row: VendorListItem) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function VendorTableRow({ row, onOpenDetail, onOpenDeactivation }: VendorTableRowProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const trades = splitTrades(row.tradeSpecialties);
|
||||||
|
const primaryTrade = trades[0] ?? "—";
|
||||||
|
const extraTrades = trades.length > 1 ? trades.length - 1 : 0;
|
||||||
|
const locationText = [row.city, row.state].filter(Boolean).join(", ") || "—";
|
||||||
|
const hasVendorId = row.id != null;
|
||||||
|
const rowKey = String(row.id ?? `company-${row.companyId ?? ""}`);
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key !== "Enter" && event.key !== " ") return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
onOpenDetail(row);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={rowKey}
|
||||||
|
hover
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
aria-label={`Open vendor details for ${row.companyName}`}
|
||||||
|
sx={{ cursor: "pointer" }}
|
||||||
|
onClick={() => onOpenDetail(row)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
>
|
||||||
|
<TableCell>
|
||||||
|
<Typography className="font-semibold">{row.companyName || "—"}</Typography>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Stack direction="row" spacing={1.5} sx={{ alignItems: "center" }}>
|
||||||
|
<Avatar
|
||||||
|
sx={{
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
fontSize: 12,
|
||||||
|
backgroundColor: "var(--accent, #1976d2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getInitials(row.contactName)}
|
||||||
|
</Avatar>
|
||||||
|
<Typography variant="body2">{row.contactName || "—"}</Typography>
|
||||||
|
</Stack>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{locationText}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{primaryTrade === "—" ? (
|
||||||
|
"—"
|
||||||
|
) : (
|
||||||
|
<Stack direction="row" spacing={0.5} sx={{ alignItems: "center" }}>
|
||||||
|
<span>{primaryTrade}</span>
|
||||||
|
{extraTrades > 0 && <Chip size="small" label={`+${extraTrades}`} />}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{row.phone ? (
|
||||||
|
<Link href={`tel:${row.phone}`} underline="hover" onClick={stopPropagation}>
|
||||||
|
{row.phone}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{row.email ? (
|
||||||
|
<Link href={`mailto:${row.email}`} underline="hover" onClick={stopPropagation}>
|
||||||
|
{row.email}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{row.totalJobs ?? 0}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={row.isActive ? "Active" : "Inactive"}
|
||||||
|
color={row.isActive ? "success" : "default"}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="center">
|
||||||
|
<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
|
||||||
|
direction="row"
|
||||||
|
spacing={0.5}
|
||||||
|
sx={{ alignItems: "center", justifyContent: "center" }}
|
||||||
|
>
|
||||||
|
<Tooltip title="Edit">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label={`Edit vendor ${row.companyName}`}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
navigate(
|
||||||
|
row.id != null
|
||||||
|
? `/vendors/edit/${row.id}`
|
||||||
|
: `/vendors/company/${row.companyId ?? ""}`,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EditOutlinedIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</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>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VendorsTableProps {
|
||||||
|
rows: VendorListItem[];
|
||||||
|
isLoading: boolean;
|
||||||
|
showError: boolean;
|
||||||
|
onOpenDetail: (row: VendorListItem) => void;
|
||||||
|
onOpenDeactivation: (row: VendorListItem) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VendorsTable({
|
||||||
|
rows,
|
||||||
|
isLoading,
|
||||||
|
showError,
|
||||||
|
onOpenDetail,
|
||||||
|
onOpenDeactivation,
|
||||||
|
}: VendorsTableProps) {
|
||||||
|
const showEmptyRow = !isLoading && !showError && rows.length === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableContainer component={Paper} variant="outlined" sx={{ overflowX: "auto" }}>
|
||||||
|
<Table size="small" sx={{ minWidth: 960 }}>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
{TABLE_COLUMNS.map((column) => (
|
||||||
|
<TableCell
|
||||||
|
key={column}
|
||||||
|
align={column === "View" || column === "Edit" ? "center" : "left"}
|
||||||
|
className="font-semibold"
|
||||||
|
>
|
||||||
|
{column}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{isLoading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={TABLE_COLUMNS.length} align="center" className="py-10">
|
||||||
|
<CircularProgress size={28} />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : showEmptyRow ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={TABLE_COLUMNS.length} align="center" className="py-10">
|
||||||
|
<Typography sx={{ color: "text.secondary" }}>
|
||||||
|
No vendors match the current filters.
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
rows.map((row) => (
|
||||||
|
<VendorTableRow
|
||||||
|
key={String(row.id ?? `company-${row.companyId ?? ""}`)}
|
||||||
|
row={row}
|
||||||
|
onOpenDetail={onOpenDetail}
|
||||||
|
onOpenDeactivation={onOpenDeactivation}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
src/app/(protected)/vendors/company/[companyId].tsx
vendored
Normal file
7
src/app/(protected)/vendors/company/[companyId].tsx
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { useParams } from "react-router";
|
||||||
|
import VendorRosterPage from "../_components/vendor-roster-page";
|
||||||
|
|
||||||
|
export default function EditVendorCompanyByCompanyPage() {
|
||||||
|
const { companyId } = useParams();
|
||||||
|
return <VendorRosterPage companyId={companyId} />;
|
||||||
|
}
|
||||||
8
src/app/(protected)/vendors/edit/[id].tsx
vendored
8
src/app/(protected)/vendors/edit/[id].tsx
vendored
|
|
@ -1 +1,7 @@
|
||||||
export { default } from "../_components/vendor-form";
|
import { useParams } from "react-router";
|
||||||
|
import VendorRosterPage from "../_components/vendor-roster-page";
|
||||||
|
|
||||||
|
export default function EditVendorCompanyPage() {
|
||||||
|
const { id } = useParams();
|
||||||
|
return <VendorRosterPage vendorId={id} />;
|
||||||
|
}
|
||||||
|
|
|
||||||
609
src/app/(protected)/vendors/index.tsx
vendored
609
src/app/(protected)/vendors/index.tsx
vendored
|
|
@ -1,78 +1,21 @@
|
||||||
import { useMemo, useState, type KeyboardEvent, type MouseEvent } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
|
import { Alert, Box, Tab, Tabs } from "@mui/material";
|
||||||
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
|
||||||
import FilterListIcon from "@mui/icons-material/FilterList";
|
|
||||||
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
|
|
||||||
import {
|
|
||||||
Alert,
|
|
||||||
Avatar,
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Chip,
|
|
||||||
CircularProgress,
|
|
||||||
Dialog,
|
|
||||||
DialogActions,
|
|
||||||
DialogContent,
|
|
||||||
DialogContentText,
|
|
||||||
DialogTitle,
|
|
||||||
IconButton,
|
|
||||||
Link,
|
|
||||||
List,
|
|
||||||
ListItem,
|
|
||||||
Paper,
|
|
||||||
Stack,
|
|
||||||
Tab,
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableContainer,
|
|
||||||
TableHead,
|
|
||||||
TablePagination,
|
|
||||||
TableRow,
|
|
||||||
Tabs,
|
|
||||||
TextField,
|
|
||||||
Tooltip,
|
|
||||||
Typography,
|
|
||||||
} from "@mui/material";
|
|
||||||
import { VendorCreateModal } from "./_components/vendor-create-modal";
|
import { VendorCreateModal } from "./_components/vendor-create-modal";
|
||||||
import { VendorDetailDrawer } from "./_components/vendor-detail-drawer";
|
import { VendorDetailDrawer } from "./_components/vendor-detail-drawer";
|
||||||
|
import { VendorDeactivationDialog } from "./_components/vendor-deactivation-dialog";
|
||||||
import { VendorFiltersDrawer } from "./_components/vendor-filters-drawer";
|
import { VendorFiltersDrawer } from "./_components/vendor-filters-drawer";
|
||||||
import { emptyVendorFilters, type VendorFilters } from "./_components/vendor-filters";
|
import {
|
||||||
|
VendorAppliedFiltersBar,
|
||||||
|
VendorsListFooter,
|
||||||
|
VendorsListToolbar,
|
||||||
|
} from "./_components/vendors-list-toolbar";
|
||||||
|
import { VendorsTable } from "./_components/vendors-table";
|
||||||
|
import { useVendorDeactivation } from "./_components/use-vendor-deactivation";
|
||||||
|
import { useVendorListState } from "./_components/use-vendor-list-state";
|
||||||
|
import type { VendorFilters } from "./_components/vendor-filters";
|
||||||
import type { VendorListItem } from "@/domain/vendors/types/vendor";
|
import type { VendorListItem } from "@/domain/vendors/types/vendor";
|
||||||
import { useDeleteVendor } from "@/domain/vendors/use-cases/use-delete-vendor";
|
|
||||||
import { useVendorDeactivationImpact } from "@/domain/vendors/use-cases/use-vendor-deactivation-impact";
|
|
||||||
import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets";
|
import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets";
|
||||||
import { useVendorsList } from "@/domain/vendors/use-cases/use-vendors-list";
|
import { useVendorsList } from "@/domain/vendors/use-cases/use-vendors-list";
|
||||||
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
|
|
||||||
import { useDebounce } from "@/hooks/use-debounce";
|
|
||||||
|
|
||||||
const TABLE_COLUMNS = [
|
|
||||||
"Company",
|
|
||||||
"Technician",
|
|
||||||
"Location",
|
|
||||||
"Trade",
|
|
||||||
"Phone",
|
|
||||||
"Email",
|
|
||||||
"Total Jobs",
|
|
||||||
"Status",
|
|
||||||
"View",
|
|
||||||
"Edit",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
function getInitials(name: string): string {
|
|
||||||
const parts = name.trim().split(/\s+/).filter(Boolean);
|
|
||||||
if (parts.length === 0) return "?";
|
|
||||||
const first = parts[0]?.[0] ?? "";
|
|
||||||
const last = parts.length > 1 ? (parts[parts.length - 1]?.[0] ?? "") : "";
|
|
||||||
return (first + last).toUpperCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function splitTrades(value: string | undefined): string[] {
|
|
||||||
return (value ?? "")
|
|
||||||
.split(",")
|
|
||||||
.map((trade) => trade.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AppliedChip {
|
interface AppliedChip {
|
||||||
category: keyof VendorFilters;
|
category: keyof VendorFilters;
|
||||||
|
|
@ -95,210 +38,61 @@ function describeChips(
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function VendorsListPage() {
|
export default function VendorsListPage() {
|
||||||
const [search, setSearch] = useState("");
|
const filters = useVendorListState();
|
||||||
const [page, setPage] = useState(0);
|
|
||||||
const [pageSize, setPageSize] = useState(12);
|
|
||||||
const [isActive, setIsActive] = useState(true);
|
|
||||||
const [appliedFilters, setAppliedFilters] = useState<VendorFilters>(emptyVendorFilters);
|
|
||||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<VendorListItem | null>(null);
|
|
||||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
|
||||||
const [detailTarget, setDetailTarget] = useState<VendorListItem | null>(null);
|
const [detailTarget, setDetailTarget] = useState<VendorListItem | null>(null);
|
||||||
const [detailMode, setDetailMode] = useState<"view" | "edit">("view");
|
const deactivation = useVendorDeactivation();
|
||||||
|
|
||||||
const debouncedSearch = useDebounce(search, 300);
|
const { data: facets } = useVendorFacets(filters.isActive);
|
||||||
const deleteVendor = useDeleteVendor();
|
const { data, isLoading, isFetching, error } = useVendorsList(filters.listParams);
|
||||||
const {
|
|
||||||
data: deactivationImpact,
|
|
||||||
isLoading: isLoadingImpact,
|
|
||||||
error: impactError,
|
|
||||||
} = useVendorDeactivationImpact(deleteTarget?.id);
|
|
||||||
const { data: facets } = useVendorFacets(isActive);
|
|
||||||
const { data: trades = [] } = useDropdownOptionsByCategory("Trade");
|
|
||||||
const tradeOptions = useMemo(() => trades.map((trade) => trade.value), [trades]);
|
|
||||||
|
|
||||||
const listParams = useMemo(
|
|
||||||
() => ({
|
|
||||||
search: debouncedSearch,
|
|
||||||
page: page + 1,
|
|
||||||
pageSize,
|
|
||||||
isActive,
|
|
||||||
companies: appliedFilters.companies,
|
|
||||||
trades: appliedFilters.trades,
|
|
||||||
locations: appliedFilters.locations,
|
|
||||||
jobBuckets: appliedFilters.jobBuckets,
|
|
||||||
}),
|
|
||||||
[debouncedSearch, page, pageSize, isActive, appliedFilters],
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data, isLoading, isFetching, error } = useVendorsList(listParams);
|
|
||||||
const { data: activeCountData } = useVendorsList({ isActive: true, page: 1, pageSize: 1 });
|
const { data: activeCountData } = useVendorsList({ isActive: true, page: 1, pageSize: 1 });
|
||||||
const { data: inactiveCountData } = useVendorsList({ isActive: false, page: 1, pageSize: 1 });
|
const { data: inactiveCountData } = useVendorsList({ isActive: false, page: 1, pageSize: 1 });
|
||||||
|
|
||||||
const rows = data?.items ?? [];
|
const rows = data?.items ?? [];
|
||||||
const totalCount = data?.totalCount ?? 0;
|
const totalCount = data?.totalCount ?? 0;
|
||||||
const activeCount = activeCountData?.totalCount ?? 0;
|
const activeCount = activeCountData?.totalCount ?? 0;
|
||||||
const inactiveCount = inactiveCountData?.totalCount ?? 0;
|
const inactiveCount = inactiveCountData?.totalCount ?? 0;
|
||||||
const globalCount = activeCount + inactiveCount;
|
|
||||||
|
|
||||||
const jobBucketLabels = useMemo(
|
const jobBucketLabels = useMemo(
|
||||||
() => new Map((facets?.jobBuckets ?? []).map((bucket) => [bucket.id, bucket.label])),
|
() => new Map((facets?.jobBuckets ?? []).map((bucket) => [bucket.id, bucket.label])),
|
||||||
[facets],
|
[facets],
|
||||||
);
|
);
|
||||||
const appliedChips = useMemo(
|
const appliedChips = describeChips(filters.appliedFilters, jobBucketLabels);
|
||||||
() => describeChips(appliedFilters, jobBucketLabels),
|
|
||||||
[appliedFilters, jobBucketLabels],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleApplyFilters = (next: VendorFilters) => {
|
|
||||||
setAppliedFilters(next);
|
|
||||||
setPage(0);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClearFilters = () => {
|
|
||||||
setAppliedFilters(emptyVendorFilters);
|
|
||||||
setPage(0);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveChip = (chip: AppliedChip) => {
|
|
||||||
setAppliedFilters((current) => ({
|
|
||||||
...current,
|
|
||||||
[chip.category]: current[chip.category].filter((value) => value !== chip.value),
|
|
||||||
}));
|
|
||||||
setPage(0);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openDetail = (row: VendorListItem, mode: "view" | "edit") => {
|
|
||||||
setDetailTarget(row);
|
|
||||||
setDetailMode(mode);
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeDetail = () => {
|
|
||||||
setDetailTarget(null);
|
|
||||||
setDetailMode("view");
|
|
||||||
};
|
|
||||||
|
|
||||||
const openDeactivation = (row: VendorListItem) => {
|
|
||||||
setDeleteTarget(row);
|
|
||||||
setDeleteError(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeDeactivation = () => {
|
|
||||||
setDeleteTarget(null);
|
|
||||||
setDeleteError(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteConfirm = () => {
|
|
||||||
if (!deleteTarget) return;
|
|
||||||
setDeleteError(null);
|
|
||||||
deleteVendor.mutate(deleteTarget.id, {
|
|
||||||
onSuccess: () => {
|
|
||||||
setDeleteTarget(null);
|
|
||||||
},
|
|
||||||
onError: (error: Error) => {
|
|
||||||
setDeleteError(error.message || "Failed to deactivate vendor");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const stopAnd = (handler: () => void) => (event: MouseEvent) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
handler();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRowKeyDown = (row: VendorListItem) => (event: KeyboardEvent) => {
|
|
||||||
if (event.key !== "Enter" && event.key !== " ") return;
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
openDetail(row, "view");
|
|
||||||
};
|
|
||||||
|
|
||||||
const showLoadingRow = isLoading;
|
|
||||||
const showEmptyRow = !isLoading && !error && rows.length === 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className="flex flex-col gap-4 p-4">
|
<Box className="flex flex-col gap-4 p-4">
|
||||||
<Stack
|
<VendorsListToolbar
|
||||||
direction={{ xs: "column", md: "row" }}
|
globalCount={activeCount + inactiveCount}
|
||||||
spacing={2}
|
activeCount={activeCount}
|
||||||
sx={{
|
isFetching={isFetching}
|
||||||
alignItems: { xs: "stretch", md: "center" },
|
isLoading={isLoading}
|
||||||
justifyContent: "space-between",
|
search={filters.search}
|
||||||
}}
|
onSearchChange={filters.setSearchWithReset}
|
||||||
>
|
appliedFilterCount={appliedChips.length}
|
||||||
<Stack direction="row" spacing={2} sx={{ alignItems: "baseline" }}>
|
onOpenFilters={() => setFiltersOpen(true)}
|
||||||
<Typography variant="h5" component="h1">
|
onAddVendor={() => setCreateOpen(true)}
|
||||||
Vendors / Technicians
|
/>
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
|
||||||
{globalCount} technicians · {activeCount} active
|
|
||||||
</Typography>
|
|
||||||
{isFetching && !isLoading && <CircularProgress size={16} />}
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Stack
|
|
||||||
direction={{ xs: "column", sm: "row" }}
|
|
||||||
spacing={2}
|
|
||||||
sx={{ alignItems: { xs: "stretch", sm: "center" } }}
|
|
||||||
>
|
|
||||||
<TextField
|
|
||||||
size="small"
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => {
|
|
||||||
setSearch(e.target.value);
|
|
||||||
setPage(0);
|
|
||||||
}}
|
|
||||||
placeholder="Search name, company, trade..."
|
|
||||||
className="min-w-[260px]"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
onClick={() => setFiltersOpen(true)}
|
|
||||||
startIcon={<FilterListIcon />}
|
|
||||||
aria-label="Open vendor filters"
|
|
||||||
>
|
|
||||||
Filters
|
|
||||||
{appliedChips.length > 0 && (
|
|
||||||
<Chip size="small" color="primary" label={appliedChips.length} sx={{ ml: 1 }} />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
<Button variant="contained" onClick={() => setCreateOpen(true)}>
|
|
||||||
+ Add Vendor
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
value={isActive ? "active" : "inactive"}
|
value={filters.isActive ? "active" : "inactive"}
|
||||||
onChange={(_event, value: "active" | "inactive") => {
|
onChange={(_event, value: "active" | "inactive") =>
|
||||||
setIsActive(value === "active");
|
filters.setIsActiveWithReset(value === "active")
|
||||||
setPage(0);
|
}
|
||||||
}}
|
|
||||||
aria-label="Vendor status"
|
aria-label="Vendor status"
|
||||||
>
|
>
|
||||||
<Tab value="active" label={`Active (${activeCount})`} />
|
<Tab value="active" label={`Active (${activeCount})`} />
|
||||||
<Tab value="inactive" label={`Inactive (${inactiveCount})`} />
|
<Tab value="inactive" label={`Inactive (${inactiveCount})`} />
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
{appliedChips.length > 0 && (
|
<VendorAppliedFiltersBar
|
||||||
<Stack direction="row" spacing={1} className="flex flex-wrap items-center">
|
chips={appliedChips.map((chip) => ({
|
||||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
key: `${chip.category}-${chip.value}`,
|
||||||
Applied:
|
label: chip.label,
|
||||||
</Typography>
|
onRemove: () => filters.removeFilter(chip.category, chip.value),
|
||||||
{appliedChips.map((chip) => (
|
}))}
|
||||||
<Chip
|
onClearAll={filters.clearFilters}
|
||||||
key={`${chip.category}-${chip.value}`}
|
/>
|
||||||
size="small"
|
|
||||||
label={chip.label}
|
|
||||||
onDelete={() => handleRemoveChip(chip)}
|
|
||||||
aria-label={`Remove filter ${chip.label}`}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<Button size="small" onClick={handleClearFilters}>
|
|
||||||
Clear all
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{Boolean(error) && (
|
{Boolean(error) && (
|
||||||
<Alert severity="error">
|
<Alert severity="error">
|
||||||
|
|
@ -306,307 +100,52 @@ export default function VendorsListPage() {
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<TableContainer component={Paper} variant="outlined" sx={{ overflowX: "auto" }}>
|
<VendorsTable
|
||||||
<Table size="small" sx={{ minWidth: 960 }}>
|
rows={rows}
|
||||||
<TableHead>
|
isLoading={isLoading}
|
||||||
<TableRow>
|
showError={Boolean(error)}
|
||||||
{TABLE_COLUMNS.map((column) => (
|
onOpenDetail={setDetailTarget}
|
||||||
<TableCell
|
onOpenDeactivation={deactivation.open}
|
||||||
key={column}
|
|
||||||
align={column === "View" || column === "Edit" ? "center" : "left"}
|
|
||||||
className="font-semibold"
|
|
||||||
>
|
|
||||||
{column}
|
|
||||||
</TableCell>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
</TableHead>
|
|
||||||
<TableBody>
|
|
||||||
{showLoadingRow ? (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={TABLE_COLUMNS.length} align="center" className="py-10">
|
|
||||||
<CircularProgress size={28} />
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : showEmptyRow ? (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={TABLE_COLUMNS.length} align="center" className="py-10">
|
|
||||||
<Typography sx={{ color: "text.secondary" }}>
|
|
||||||
No vendors match the current filters.
|
|
||||||
</Typography>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : (
|
|
||||||
rows.map((row) => {
|
|
||||||
const trades = splitTrades(row.tradeSpecialties);
|
|
||||||
const primaryTrade = trades[0] ?? "—";
|
|
||||||
const extraTrades = trades.length > 1 ? trades.length - 1 : 0;
|
|
||||||
const locationText = [row.city, row.state].filter(Boolean).join(", ") || "—";
|
|
||||||
return (
|
|
||||||
<TableRow
|
|
||||||
key={String(row.id)}
|
|
||||||
hover
|
|
||||||
tabIndex={0}
|
|
||||||
role="button"
|
|
||||||
aria-label={`Open vendor details for ${row.companyName}`}
|
|
||||||
sx={{ cursor: "pointer" }}
|
|
||||||
onClick={() => openDetail(row, "view")}
|
|
||||||
onKeyDown={handleRowKeyDown(row)}
|
|
||||||
>
|
|
||||||
<TableCell>
|
|
||||||
<Typography className="font-semibold">{row.companyName || "—"}</Typography>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Stack direction="row" spacing={1.5} sx={{ alignItems: "center" }}>
|
|
||||||
<Avatar
|
|
||||||
sx={{
|
|
||||||
width: 28,
|
|
||||||
height: 28,
|
|
||||||
fontSize: 12,
|
|
||||||
backgroundColor: "var(--accent, #1976d2)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{getInitials(row.contactName)}
|
|
||||||
</Avatar>
|
|
||||||
<Typography variant="body2">{row.contactName || "—"}</Typography>
|
|
||||||
</Stack>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{locationText}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{primaryTrade === "—" ? (
|
|
||||||
"—"
|
|
||||||
) : (
|
|
||||||
<Stack direction="row" spacing={0.5} sx={{ alignItems: "center" }}>
|
|
||||||
<span>{primaryTrade}</span>
|
|
||||||
{extraTrades > 0 && <Chip size="small" label={`+${extraTrades}`} />}
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{row.phone ? (
|
|
||||||
<Link
|
|
||||||
href={`tel:${row.phone}`}
|
|
||||||
underline="hover"
|
|
||||||
onClick={(event) => event.stopPropagation()}
|
|
||||||
>
|
|
||||||
{row.phone}
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
"—"
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{row.email ? (
|
|
||||||
<Link
|
|
||||||
href={`mailto:${row.email}`}
|
|
||||||
underline="hover"
|
|
||||||
onClick={(event) => event.stopPropagation()}
|
|
||||||
>
|
|
||||||
{row.email}
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
"—"
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{row.totalJobs ?? 0}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Chip
|
|
||||||
size="small"
|
|
||||||
label={row.isActive ? "Active" : "Inactive"}
|
|
||||||
color={row.isActive ? "success" : "default"}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell align="center">
|
|
||||||
<Tooltip title="View details">
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
aria-label={`View vendor ${row.companyName}`}
|
|
||||||
onClick={stopAnd(() => openDetail(row, "view"))}
|
|
||||||
>
|
|
||||||
<VisibilityOutlinedIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell align="center">
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
spacing={0.5}
|
|
||||||
sx={{ alignItems: "center", justifyContent: "center" }}
|
|
||||||
>
|
|
||||||
<Tooltip title="Edit">
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
aria-label={`Edit vendor ${row.companyName}`}
|
|
||||||
onClick={stopAnd(() => openDetail(row, "edit"))}
|
|
||||||
>
|
|
||||||
<EditOutlinedIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
{row.isActive && (
|
|
||||||
<Tooltip title="Deactivate">
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
aria-label="Deactivate vendor"
|
|
||||||
onClick={stopAnd(() => openDeactivation(row))}
|
|
||||||
>
|
|
||||||
<DeleteOutlineIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</TableContainer>
|
|
||||||
|
|
||||||
<Stack
|
|
||||||
direction={{ xs: "column", sm: "row" }}
|
|
||||||
spacing={2}
|
|
||||||
sx={{
|
|
||||||
alignItems: { xs: "stretch", sm: "center" },
|
|
||||||
justifyContent: "space-between",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
|
||||||
{totalCount} {isActive ? "active" : "inactive"}{" "}
|
|
||||||
{totalCount === 1 ? "technician" : "technicians"}
|
|
||||||
{(appliedChips.length > 0 || Boolean(debouncedSearch)) && " · filtered"}
|
|
||||||
{` · ${activeCount} active · ${inactiveCount} inactive`}
|
|
||||||
</Typography>
|
|
||||||
<TablePagination
|
|
||||||
component="div"
|
|
||||||
count={totalCount}
|
|
||||||
page={page}
|
|
||||||
onPageChange={(_e, nextPage) => setPage(nextPage)}
|
|
||||||
rowsPerPage={pageSize}
|
|
||||||
onRowsPerPageChange={(e) => {
|
|
||||||
setPageSize(Number(e.target.value));
|
|
||||||
setPage(0);
|
|
||||||
}}
|
|
||||||
rowsPerPageOptions={[12, 24, 48]}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<VendorCreateModal
|
|
||||||
open={createOpen}
|
|
||||||
onClose={() => setCreateOpen(false)}
|
|
||||||
tradeOptions={tradeOptions}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<VendorsListFooter
|
||||||
|
totalCount={totalCount}
|
||||||
|
isActive={filters.isActive}
|
||||||
|
activeCount={activeCount}
|
||||||
|
inactiveCount={inactiveCount}
|
||||||
|
isFiltered={appliedChips.length > 0 || Boolean(filters.debouncedSearch)}
|
||||||
|
page={filters.page}
|
||||||
|
pageSize={filters.pageSize}
|
||||||
|
onPageChange={filters.setPage}
|
||||||
|
onPageSizeChange={filters.setPageSizeWithReset}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<VendorCreateModal open={createOpen} onClose={() => setCreateOpen(false)} />
|
||||||
|
|
||||||
<VendorFiltersDrawer
|
<VendorFiltersDrawer
|
||||||
open={filtersOpen}
|
open={filtersOpen}
|
||||||
onClose={() => setFiltersOpen(false)}
|
onClose={() => setFiltersOpen(false)}
|
||||||
facets={facets}
|
facets={facets}
|
||||||
appliedFilters={appliedFilters}
|
appliedFilters={filters.appliedFilters}
|
||||||
onApply={handleApplyFilters}
|
onApply={filters.setAppliedFiltersWithReset}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<VendorDetailDrawer
|
<VendorDetailDrawer
|
||||||
open={Boolean(detailTarget)}
|
open={Boolean(detailTarget)}
|
||||||
vendor={detailTarget}
|
vendor={detailTarget}
|
||||||
mode={detailMode}
|
onClose={() => setDetailTarget(null)}
|
||||||
onClose={closeDetail}
|
|
||||||
onModeChange={setDetailMode}
|
|
||||||
tradeOptions={tradeOptions}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Dialog
|
<VendorDeactivationDialog
|
||||||
open={Boolean(deleteTarget)}
|
target={deactivation.target}
|
||||||
onClose={deleteVendor.isPending ? undefined : closeDeactivation}
|
isLoading={deactivation.isLoading}
|
||||||
>
|
isSaving={deactivation.isSaving}
|
||||||
<DialogTitle>Deactivate Vendor</DialogTitle>
|
error={deactivation.error}
|
||||||
<DialogContent>
|
impact={deactivation.impact}
|
||||||
<Stack spacing={2}>
|
impactError={deactivation.impactError}
|
||||||
<DialogContentText>
|
onClose={deactivation.close}
|
||||||
Deactivate "{deleteTarget?.companyName}"? Existing work-order and audit
|
onConfirm={deactivation.confirm}
|
||||||
history will be preserved.
|
/>
|
||||||
</DialogContentText>
|
|
||||||
|
|
||||||
{isLoadingImpact && (
|
|
||||||
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
|
||||||
<CircularProgress size={16} />
|
|
||||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
|
||||||
Checking linked work orders...
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{Boolean(impactError) && !isLoadingImpact && (
|
|
||||||
<Alert severity="warning">
|
|
||||||
Could not verify linked work orders. Close and try again.
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{deactivationImpact != null &&
|
|
||||||
!isLoadingImpact &&
|
|
||||||
!deactivationImpact.canDeactivate && (
|
|
||||||
<Alert severity="error">
|
|
||||||
This vendor cannot be deactivated because it still has open work orders.
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{deactivationImpact != null && deactivationImpact.openWorkOrders.length > 0 && (
|
|
||||||
<Box>
|
|
||||||
<Typography variant="subtitle2" className="font-semibold uppercase">
|
|
||||||
Open work orders ({deactivationImpact.openWorkOrders.length})
|
|
||||||
</Typography>
|
|
||||||
<List dense sx={{ mt: 0.5 }}>
|
|
||||||
{deactivationImpact.openWorkOrders.map((wo) => (
|
|
||||||
<ListItem
|
|
||||||
key={String(wo.workOrderId)}
|
|
||||||
sx={{ px: 0, py: 0.25 }}
|
|
||||||
secondaryAction={
|
|
||||||
wo.scheduledDate ? (
|
|
||||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
|
||||||
{wo.scheduledDate}
|
|
||||||
</Typography>
|
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Stack>
|
|
||||||
<Typography variant="body2">
|
|
||||||
{wo.workOrderNumber
|
|
||||||
? `${wo.workOrderNumber} — ${wo.workOrderTitle || "Untitled"}`
|
|
||||||
: wo.workOrderTitle || `Work order ${wo.workOrderId}`}
|
|
||||||
</Typography>
|
|
||||||
{Boolean(wo.status) && (
|
|
||||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
|
||||||
{wo.status}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</ListItem>
|
|
||||||
))}
|
|
||||||
</List>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{Boolean(deleteError) && <Alert severity="error">{deleteError}</Alert>}
|
|
||||||
</Stack>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button onClick={closeDeactivation} disabled={deleteVendor.isPending}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color="error"
|
|
||||||
variant="contained"
|
|
||||||
onClick={handleDeleteConfirm}
|
|
||||||
disabled={
|
|
||||||
deleteVendor.isPending ||
|
|
||||||
isLoadingImpact ||
|
|
||||||
Boolean(deactivationImpact && !deactivationImpact.canDeactivate) ||
|
|
||||||
Boolean(impactError)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{deleteVendor.isPending ? "Deactivating..." : "Deactivate"}
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
6
src/app/(protected)/vendors/new.tsx
vendored
6
src/app/(protected)/vendors/new.tsx
vendored
|
|
@ -1 +1,5 @@
|
||||||
export { default } from "./_components/vendor-form";
|
import VendorRosterPage from "./_components/vendor-roster-page";
|
||||||
|
|
||||||
|
export default function NewVendorCompanyPage() {
|
||||||
|
return <VendorRosterPage />;
|
||||||
|
}
|
||||||
|
|
|
||||||
100
src/domain/vendors/api/vendor-company-roster-api.ts
vendored
Normal file
100
src/domain/vendors/api/vendor-company-roster-api.ts
vendored
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
import { isHTTPError } from "ky";
|
||||||
|
import { API_PATHS } from "@/api/api-paths";
|
||||||
|
import { apiGet, apiPost, apiPut } from "@/api/api";
|
||||||
|
import { handleApiResponse } from "@/api/handle-api-response";
|
||||||
|
import {
|
||||||
|
mapRosterConflict,
|
||||||
|
mapVendorCompanyRoster,
|
||||||
|
mapVendorRosterToBackend,
|
||||||
|
} from "@/domain/vendors/mappers/vendor-roster-mapper";
|
||||||
|
import { VendorRosterConflictError } from "@/domain/vendors/lib/vendor-roster-conflict";
|
||||||
|
import type { VendorCompanyRoster } from "@/domain/vendors/types/vendor";
|
||||||
|
|
||||||
|
const STALE_MESSAGE =
|
||||||
|
"This company was changed by another session. Reload the latest version and try again.";
|
||||||
|
const OPEN_WORK_ORDER_MESSAGE =
|
||||||
|
"This company cannot be saved because it still has open work orders.";
|
||||||
|
|
||||||
|
async function readConflictBody(error: unknown): Promise<unknown> {
|
||||||
|
if (!isHTTPError(error)) return undefined;
|
||||||
|
try {
|
||||||
|
return await error.response.json();
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
const text = await error.response.text();
|
||||||
|
return text ? JSON.parse(text) : undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function throwRosterConflict(error: unknown): Promise<never> {
|
||||||
|
const status = isHTTPError(error) ? error.response.status : 0;
|
||||||
|
const body = await readConflictBody(error);
|
||||||
|
const message = status === 409 ? STALE_MESSAGE : "Failed to save vendor company";
|
||||||
|
const conflict = mapRosterConflict(body, message);
|
||||||
|
if (conflict.kind === "open-work-orders" && conflict.blockedWorkOrders.length === 0) {
|
||||||
|
conflict.message = OPEN_WORK_ORDER_MESSAGE;
|
||||||
|
}
|
||||||
|
throw new VendorRosterConflictError(conflict, status || 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RosterQuery {
|
||||||
|
vendorId?: string | number;
|
||||||
|
companyId?: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRosterSearchParams(query: RosterQuery): Record<string, string> {
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
if (query.vendorId !== undefined && query.vendorId !== "") {
|
||||||
|
params.vendorId = String(query.vendorId);
|
||||||
|
}
|
||||||
|
if (query.companyId !== undefined && query.companyId !== "") {
|
||||||
|
params.companyId = String(query.companyId);
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const vendorCompanyRosterApi = {
|
||||||
|
get: async (query: RosterQuery): Promise<VendorCompanyRoster> => {
|
||||||
|
const data = await apiGet<unknown>(API_PATHS.vendorCompanyRoster.base, {
|
||||||
|
searchParams: buildRosterSearchParams(query),
|
||||||
|
});
|
||||||
|
return mapVendorCompanyRoster(handleApiResponse(data));
|
||||||
|
},
|
||||||
|
|
||||||
|
create: async (payload: unknown): Promise<VendorCompanyRoster> => {
|
||||||
|
try {
|
||||||
|
const data = await apiPost<unknown>(
|
||||||
|
API_PATHS.vendorCompanyRoster.base,
|
||||||
|
mapVendorRosterToBackend(payload),
|
||||||
|
);
|
||||||
|
return mapVendorCompanyRoster(handleApiResponse(data));
|
||||||
|
} catch (error) {
|
||||||
|
if (isHTTPError(error) && error.response.status === 409) {
|
||||||
|
await throwRosterConflict(error);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
update: async (
|
||||||
|
companyId: string | number,
|
||||||
|
payload: unknown,
|
||||||
|
rowVersion: string,
|
||||||
|
): Promise<VendorCompanyRoster> => {
|
||||||
|
try {
|
||||||
|
const data = await apiPut<unknown>(
|
||||||
|
API_PATHS.vendorCompanyRoster.byCompany(companyId),
|
||||||
|
mapVendorRosterToBackend({ ...(payload as Record<string, unknown>), rowVersion }),
|
||||||
|
);
|
||||||
|
return mapVendorCompanyRoster(handleApiResponse(data));
|
||||||
|
} catch (error) {
|
||||||
|
if (isHTTPError(error) && error.response.status === 409) {
|
||||||
|
await throwRosterConflict(error);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
17
src/domain/vendors/lib/vendor-roster-conflict.ts
vendored
Normal file
17
src/domain/vendors/lib/vendor-roster-conflict.ts
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import type { VendorRosterConflict } from "@/domain/vendors/types/vendor";
|
||||||
|
|
||||||
|
export class VendorRosterConflictError extends Error {
|
||||||
|
readonly status: number;
|
||||||
|
readonly conflict: VendorRosterConflict;
|
||||||
|
|
||||||
|
constructor(conflict: VendorRosterConflict, status = 409) {
|
||||||
|
super(conflict.message);
|
||||||
|
this.name = "VendorRosterConflictError";
|
||||||
|
this.status = status;
|
||||||
|
this.conflict = conflict;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isVendorRosterConflictError(error: unknown): error is VendorRosterConflictError {
|
||||||
|
return error instanceof VendorRosterConflictError;
|
||||||
|
}
|
||||||
7
src/domain/vendors/mappers/vendor-mapper.ts
vendored
7
src/domain/vendors/mappers/vendor-mapper.ts
vendored
|
|
@ -38,10 +38,11 @@ function readNumber(record: Record<string, unknown>, ...keys: string[]): number
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readId(record: Record<string, unknown>): string | number {
|
function readId(record: Record<string, unknown>): string | number | null {
|
||||||
const value = record.id ?? record.Id;
|
const value = record.id ?? record.Id;
|
||||||
if (typeof value === "string" || typeof value === "number") return value;
|
if (typeof value === "string") return value.trim() === "" ? null : value;
|
||||||
return "";
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readCompanyId(record: Record<string, unknown>): string | number | null {
|
function readCompanyId(record: Record<string, unknown>): string | number | null {
|
||||||
|
|
|
||||||
156
src/domain/vendors/mappers/vendor-roster-mapper.ts
vendored
Normal file
156
src/domain/vendors/mappers/vendor-roster-mapper.ts
vendored
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
import type {
|
||||||
|
VendorCompanyRoster,
|
||||||
|
VendorPreferredContact,
|
||||||
|
VendorRosterBlockedWorkOrder,
|
||||||
|
VendorRosterConflict,
|
||||||
|
VendorRosterConflictKind,
|
||||||
|
VendorRosterTechnician,
|
||||||
|
} from "@/domain/vendors/types/vendor";
|
||||||
|
import { toCanonicalPhone } from "@/domain/vendors/mappers/vendor-mapper";
|
||||||
|
|
||||||
|
const PREFERRED_CONTACT_VALUES = new Set<VendorPreferredContact>(["Phone", "Email", "Text"]);
|
||||||
|
|
||||||
|
function asRecord(raw: unknown): Record<string, unknown> {
|
||||||
|
return typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readString(record: Record<string, unknown>, ...keys: string[]): string {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = record[key];
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
if (typeof value === "number") return String(value);
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBool(record: Record<string, unknown>, ...keys: string[]): boolean {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = record[key];
|
||||||
|
if (typeof value === "boolean") return value;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPreferredContact(record: Record<string, unknown>): VendorPreferredContact | undefined {
|
||||||
|
const raw = record.preferredContact ?? record.PreferredContact;
|
||||||
|
if (typeof raw === "string") {
|
||||||
|
const normalized = raw.trim();
|
||||||
|
if (PREFERRED_CONTACT_VALUES.has(normalized as VendorPreferredContact)) {
|
||||||
|
return normalized as VendorPreferredContact;
|
||||||
|
}
|
||||||
|
const lowered = normalized.toLowerCase();
|
||||||
|
if (lowered === "phone" || lowered === "call") return "Phone";
|
||||||
|
if (lowered === "email" || lowered === "e-mail") return "Email";
|
||||||
|
if (lowered === "text" || lowered === "sms") return "Text";
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readId(record: Record<string, unknown>, ...keys: string[]): string | number | undefined {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = record[key];
|
||||||
|
if (typeof value === "string" && value.trim() !== "") return value;
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCompanyId(record: Record<string, unknown>): string | number | null {
|
||||||
|
const value = readId(record, "companyId", "CompanyId");
|
||||||
|
return value ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapRosterTechnician(raw: unknown): VendorRosterTechnician {
|
||||||
|
const item = asRecord(raw);
|
||||||
|
return {
|
||||||
|
id: readId(item, "id", "Id"),
|
||||||
|
contactName: readString(item, "contactName", "ContactName"),
|
||||||
|
phone: readString(item, "phone", "Phone"),
|
||||||
|
email: readString(item, "email", "Email"),
|
||||||
|
preferredContact: readPreferredContact(item),
|
||||||
|
tradeSpecialties: readString(item, "tradeSpecialties", "TradeSpecialties"),
|
||||||
|
isActive: readBool(item, "isActive", "IsActive"),
|
||||||
|
totalJobs: Number(item.totalJobs ?? item.TotalJobs ?? 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapRosterTechnicians(raw: unknown): VendorRosterTechnician[] {
|
||||||
|
return Array.isArray(raw) ? raw.map(mapRosterTechnician) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapVendorCompanyRoster(raw: unknown): VendorCompanyRoster {
|
||||||
|
const item = asRecord(raw);
|
||||||
|
return {
|
||||||
|
companyId: readCompanyId(item),
|
||||||
|
rowVersion: readString(item, "rowVersion", "RowVersion"),
|
||||||
|
name: readString(item, "name", "Name", "companyName", "CompanyName"),
|
||||||
|
companyPhone: readString(item, "companyPhone", "CompanyPhone"),
|
||||||
|
email: readString(item, "email", "Email"),
|
||||||
|
address: readString(item, "address", "Address"),
|
||||||
|
city: readString(item, "city", "City"),
|
||||||
|
state: readString(item, "state", "State"),
|
||||||
|
zip: readString(item, "zip", "Zip", "zipCode", "ZipCode"),
|
||||||
|
googleMapsUrl: readString(item, "googleMapsUrl", "GoogleMapsUrl"),
|
||||||
|
notes: readString(item, "notes", "Notes"),
|
||||||
|
technicians: mapRosterTechnicians(item.technicians ?? item.Technicians),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapRosterTechnicianToBackend(technician: unknown): Record<string, unknown> {
|
||||||
|
const item = asRecord(technician);
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
contactName: readString(item, "contactName", "ContactName"),
|
||||||
|
phone: toCanonicalPhone(item.phone ?? item.Phone),
|
||||||
|
email: readString(item, "email", "Email"),
|
||||||
|
tradeSpecialties: readString(item, "tradeSpecialties", "TradeSpecialties"),
|
||||||
|
isActive: readBool(item, "isActive", "IsActive"),
|
||||||
|
};
|
||||||
|
const preferredContact = readPreferredContact(item);
|
||||||
|
if (preferredContact) payload.preferredContact = preferredContact;
|
||||||
|
const id = readId(item, "id", "Id");
|
||||||
|
if (id !== undefined) payload.id = id;
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapVendorRosterToBackend(values: unknown): Record<string, unknown> {
|
||||||
|
const item = asRecord(values);
|
||||||
|
const techniciansRaw = item.technicians ?? item.Technicians;
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
name: readString(item, "name", "Name", "companyName", "CompanyName"),
|
||||||
|
companyPhone: toCanonicalPhone(item.companyPhone ?? item.CompanyPhone),
|
||||||
|
email: readString(item, "email", "Email"),
|
||||||
|
address: readString(item, "address", "Address"),
|
||||||
|
city: readString(item, "city", "City"),
|
||||||
|
state: readString(item, "state", "State"),
|
||||||
|
zip: readString(item, "zip", "Zip", "zipCode", "ZipCode"),
|
||||||
|
googleMapsUrl: readString(item, "googleMapsUrl", "GoogleMapsUrl"),
|
||||||
|
notes: readString(item, "notes", "Notes"),
|
||||||
|
technicians: Array.isArray(techniciansRaw)
|
||||||
|
? techniciansRaw.map(mapRosterTechnicianToBackend)
|
||||||
|
: [],
|
||||||
|
};
|
||||||
|
const rowVersion = readString(item, "rowVersion", "RowVersion");
|
||||||
|
if (rowVersion) payload.rowVersion = rowVersion;
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapBlockedWorkOrder(raw: unknown): VendorRosterBlockedWorkOrder {
|
||||||
|
const item = asRecord(raw);
|
||||||
|
return {
|
||||||
|
workOrderId: readId(item, "workOrderId", "WorkOrderId", "id", "Id") ?? "",
|
||||||
|
workOrderNumber: readString(item, "workOrderNumber", "WorkOrderNumber"),
|
||||||
|
workOrderTitle: readString(item, "workOrderTitle", "WorkOrderTitle", "title", "Title"),
|
||||||
|
status: readString(item, "status", "Status"),
|
||||||
|
scheduledDate: readString(item, "scheduledDate", "ScheduledDate") || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapRosterConflict(raw: unknown, fallbackMessage: string): VendorRosterConflict {
|
||||||
|
const item = asRecord(raw);
|
||||||
|
const blockedRaw = item.blockedWorkOrders ?? item.BlockedWorkOrders ?? item.openWorkOrders;
|
||||||
|
const blockedWorkOrders = Array.isArray(blockedRaw) ? blockedRaw.map(mapBlockedWorkOrder) : [];
|
||||||
|
const kind: VendorRosterConflictKind =
|
||||||
|
blockedWorkOrders.length > 0 ? "open-work-orders" : "stale";
|
||||||
|
const message = readString(item, "message", "Message") || fallbackMessage;
|
||||||
|
return { kind, message, blockedWorkOrders };
|
||||||
|
}
|
||||||
106
src/domain/vendors/schemas/vendor-roster-schema.ts
vendored
Normal file
106
src/domain/vendors/schemas/vendor-roster-schema.ts
vendored
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
VENDOR_PREFERRED_CONTACT_VALUES,
|
||||||
|
type VendorPreferredContact,
|
||||||
|
} from "@/domain/vendors/types/vendor";
|
||||||
|
|
||||||
|
const preferredContactEnum = z.enum(
|
||||||
|
VENDOR_PREFERRED_CONTACT_VALUES as [VendorPreferredContact, ...VendorPreferredContact[]],
|
||||||
|
);
|
||||||
|
|
||||||
|
export function isAbsoluteHttpsUrl(value: string): boolean {
|
||||||
|
if (value.trim() === "") return true;
|
||||||
|
try {
|
||||||
|
const parsed = new URL(value);
|
||||||
|
return parsed.protocol === "https:";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const httpsUrl = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.refine(isAbsoluteHttpsUrl, "Google Maps URL must be a valid HTTPS link");
|
||||||
|
|
||||||
|
export function isValidNorthAmericanPhone(value: string): boolean {
|
||||||
|
if (value === "") return true;
|
||||||
|
const digits = value.replace(/\D+/g, "");
|
||||||
|
return digits.length === 10 || (digits.length === 11 && digits.startsWith("1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const northAmericanPhone = z
|
||||||
|
.string()
|
||||||
|
.refine(isValidNorthAmericanPhone, "Enter a 10-digit phone number");
|
||||||
|
|
||||||
|
const optionalEmail = z.union([z.string().email("Invalid email"), z.literal("")]);
|
||||||
|
|
||||||
|
export const rosterTechnicianSchema = z.object({
|
||||||
|
id: z.union([z.string(), z.number()]).optional(),
|
||||||
|
contactName: z.string(),
|
||||||
|
phone: northAmericanPhone,
|
||||||
|
email: optionalEmail,
|
||||||
|
preferredContact: preferredContactEnum.optional(),
|
||||||
|
tradeSpecialties: z.string(),
|
||||||
|
isActive: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type RosterTechnicianValues = z.infer<typeof rosterTechnicianSchema>;
|
||||||
|
|
||||||
|
const baseCompanyFields = {
|
||||||
|
name: z.string().trim().min(1, "Company is required"),
|
||||||
|
companyPhone: northAmericanPhone,
|
||||||
|
email: optionalEmail,
|
||||||
|
address: z.string(),
|
||||||
|
city: z.string(),
|
||||||
|
state: z.string(),
|
||||||
|
zip: z.string(),
|
||||||
|
googleMapsUrl: httpsUrl,
|
||||||
|
notes: z.string(),
|
||||||
|
technicians: z.array(rosterTechnicianSchema),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const vendorCompanyRosterSchema = z.object(baseCompanyFields).superRefine((data, ctx) => {
|
||||||
|
const hasPhone = Boolean(data.companyPhone && data.companyPhone.trim());
|
||||||
|
const hasEmail = Boolean(data.email && data.email.trim());
|
||||||
|
if (!hasPhone && !hasEmail) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["companyPhone"],
|
||||||
|
message: "Company phone or email is required",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export type VendorCompanyRosterFormValues = z.infer<typeof vendorCompanyRosterSchema>;
|
||||||
|
|
||||||
|
export const vendorCompanyRosterUpdateSchema = vendorCompanyRosterSchema.and(
|
||||||
|
z.object({
|
||||||
|
companyId: z.union([z.string(), z.number()]).nullable(),
|
||||||
|
rowVersion: z.string().min(1, "Row version is required"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export type VendorCompanyRosterUpdateValues = z.infer<typeof vendorCompanyRosterUpdateSchema>;
|
||||||
|
|
||||||
|
export const emptyRosterTechnician: RosterTechnicianValues = {
|
||||||
|
contactName: "",
|
||||||
|
phone: "",
|
||||||
|
email: "",
|
||||||
|
preferredContact: "Phone",
|
||||||
|
tradeSpecialties: "",
|
||||||
|
isActive: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const emptyVendorCompanyRosterForm: VendorCompanyRosterFormValues = {
|
||||||
|
name: "",
|
||||||
|
companyPhone: "",
|
||||||
|
email: "",
|
||||||
|
address: "",
|
||||||
|
city: "",
|
||||||
|
state: "",
|
||||||
|
zip: "",
|
||||||
|
googleMapsUrl: "",
|
||||||
|
notes: "",
|
||||||
|
technicians: [],
|
||||||
|
};
|
||||||
44
src/domain/vendors/types/vendor.ts
vendored
44
src/domain/vendors/types/vendor.ts
vendored
|
|
@ -3,7 +3,7 @@ export type VendorPreferredContact = "Phone" | "Email" | "Text";
|
||||||
export const VENDOR_PREFERRED_CONTACT_VALUES: VendorPreferredContact[] = ["Phone", "Email", "Text"];
|
export const VENDOR_PREFERRED_CONTACT_VALUES: VendorPreferredContact[] = ["Phone", "Email", "Text"];
|
||||||
|
|
||||||
export interface VendorListItem {
|
export interface VendorListItem {
|
||||||
id: string | number;
|
id: string | number | null;
|
||||||
companyId: string | number | null;
|
companyId: string | number | null;
|
||||||
companyName: string;
|
companyName: string;
|
||||||
contactName: string;
|
contactName: string;
|
||||||
|
|
@ -109,3 +109,45 @@ export interface VendorDeactivationImpact {
|
||||||
canDeactivate: boolean;
|
canDeactivate: boolean;
|
||||||
openWorkOrders: VendorOpenWorkOrderSummary[];
|
openWorkOrders: VendorOpenWorkOrderSummary[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VendorRosterTechnician {
|
||||||
|
id?: string | number;
|
||||||
|
contactName: string;
|
||||||
|
phone: string;
|
||||||
|
email: string;
|
||||||
|
preferredContact?: VendorPreferredContact;
|
||||||
|
tradeSpecialties: string;
|
||||||
|
isActive: boolean;
|
||||||
|
totalJobs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VendorCompanyRoster {
|
||||||
|
companyId: string | number | null;
|
||||||
|
rowVersion: string;
|
||||||
|
name: string;
|
||||||
|
companyPhone: string;
|
||||||
|
email: string;
|
||||||
|
address: string;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
zip: string;
|
||||||
|
googleMapsUrl: string;
|
||||||
|
notes: string;
|
||||||
|
technicians: VendorRosterTechnician[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VendorRosterBlockedWorkOrder {
|
||||||
|
workOrderId: string | number;
|
||||||
|
workOrderNumber: string;
|
||||||
|
workOrderTitle: string;
|
||||||
|
status: string;
|
||||||
|
scheduledDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VendorRosterConflictKind = "open-work-orders" | "stale";
|
||||||
|
|
||||||
|
export interface VendorRosterConflict {
|
||||||
|
kind: VendorRosterConflictKind;
|
||||||
|
message: string;
|
||||||
|
blockedWorkOrders: VendorRosterBlockedWorkOrder[];
|
||||||
|
}
|
||||||
|
|
|
||||||
131
src/domain/vendors/use-cases/use-save-vendor-company-roster.ts
vendored
Normal file
131
src/domain/vendors/use-cases/use-save-vendor-company-roster.ts
vendored
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import { vendorCompanyRosterApi } from "@/domain/vendors/api/vendor-company-roster-api";
|
||||||
|
import { vendorsApi } from "@/domain/vendors/api/vendors-api";
|
||||||
|
import { isVendorRosterConflictError } from "@/domain/vendors/lib/vendor-roster-conflict";
|
||||||
|
import type { VendorCompanyRoster } from "@/domain/vendors/types/vendor";
|
||||||
|
import type {
|
||||||
|
VendorCompanyRosterFormValues,
|
||||||
|
VendorCompanyRosterUpdateValues,
|
||||||
|
} from "@/domain/vendors/schemas/vendor-roster-schema";
|
||||||
|
import { queryKeys } from "@/infra/query-key/query-key";
|
||||||
|
|
||||||
|
export interface SaveVendorCompanyRosterInput {
|
||||||
|
mode: "create" | "update";
|
||||||
|
values: VendorCompanyRosterFormValues;
|
||||||
|
companyId?: string | number | null;
|
||||||
|
rowVersion?: string;
|
||||||
|
originalRoster?: VendorCompanyRoster;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SaveVendorCompanyRosterContext {
|
||||||
|
conflict?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COMPANY_FIELDS = [
|
||||||
|
"name",
|
||||||
|
"companyPhone",
|
||||||
|
"email",
|
||||||
|
"address",
|
||||||
|
"city",
|
||||||
|
"state",
|
||||||
|
"zip",
|
||||||
|
"googleMapsUrl",
|
||||||
|
"notes",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const TECHNICIAN_FIELDS = [
|
||||||
|
"id",
|
||||||
|
"contactName",
|
||||||
|
"phone",
|
||||||
|
"email",
|
||||||
|
"preferredContact",
|
||||||
|
"tradeSpecialties",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function getSingleStatusOnlyChange(
|
||||||
|
original: VendorCompanyRoster,
|
||||||
|
values: VendorCompanyRosterFormValues,
|
||||||
|
): { id: string | number; isActive: boolean } | null {
|
||||||
|
if (COMPANY_FIELDS.some((field) => original[field] !== values[field])) return null;
|
||||||
|
if (original.technicians.length !== values.technicians.length) return null;
|
||||||
|
|
||||||
|
const changed: Array<{ id: string | number; isActive: boolean }> = [];
|
||||||
|
for (let index = 0; index < original.technicians.length; index += 1) {
|
||||||
|
const before = original.technicians[index];
|
||||||
|
const after = values.technicians[index];
|
||||||
|
if (!before || !after || TECHNICIAN_FIELDS.some((field) => before[field] !== after[field])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (before.isActive !== after.isActive) {
|
||||||
|
if (before.id == null) return null;
|
||||||
|
changed.push({ id: before.id, isActive: after.isActive });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed.length === 1 ? changed[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSaveVendorCompanyRoster(): UseMutationResult<
|
||||||
|
VendorCompanyRoster,
|
||||||
|
unknown,
|
||||||
|
SaveVendorCompanyRosterInput,
|
||||||
|
SaveVendorCompanyRosterContext
|
||||||
|
> {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation<
|
||||||
|
VendorCompanyRoster,
|
||||||
|
unknown,
|
||||||
|
SaveVendorCompanyRosterInput,
|
||||||
|
SaveVendorCompanyRosterContext
|
||||||
|
>({
|
||||||
|
mutationFn: async ({
|
||||||
|
mode,
|
||||||
|
values,
|
||||||
|
companyId,
|
||||||
|
rowVersion,
|
||||||
|
originalRoster,
|
||||||
|
}: SaveVendorCompanyRosterInput) => {
|
||||||
|
if (mode === "update") {
|
||||||
|
const statusChange = originalRoster
|
||||||
|
? getSingleStatusOnlyChange(originalRoster, values)
|
||||||
|
: null;
|
||||||
|
if (statusChange) {
|
||||||
|
const baseRoster = originalRoster as VendorCompanyRoster;
|
||||||
|
await vendorsApi.update(statusChange.id, { isActive: statusChange.isActive });
|
||||||
|
return {
|
||||||
|
...baseRoster,
|
||||||
|
technicians: baseRoster.technicians.map((technician) =>
|
||||||
|
technician.id === statusChange.id
|
||||||
|
? { ...technician, isActive: statusChange.isActive }
|
||||||
|
: technician,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (companyId === undefined || companyId === null || companyId === "") {
|
||||||
|
throw new Error("Company id is required to update");
|
||||||
|
}
|
||||||
|
if (!rowVersion) throw new Error("Row version is required to update");
|
||||||
|
return vendorCompanyRosterApi.update(companyId, values, rowVersion);
|
||||||
|
}
|
||||||
|
return vendorCompanyRosterApi.create(values);
|
||||||
|
},
|
||||||
|
onSuccess: (data, variables) => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: queryKeys.vendors.all });
|
||||||
|
if (variables.mode === "update" && variables.companyId) {
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.vendors.roster("companyId", variables.companyId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
toast.success(
|
||||||
|
variables.mode === "update" ? "Vendor company updated" : "Vendor company created",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
if (isVendorRosterConflictError(error)) return;
|
||||||
|
toast.error("Failed to save vendor company. Try again.");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { VendorCompanyRosterFormValues, VendorCompanyRosterUpdateValues };
|
||||||
36
src/domain/vendors/use-cases/use-vendor-company-roster.ts
vendored
Normal file
36
src/domain/vendors/use-cases/use-vendor-company-roster.ts
vendored
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
vendorCompanyRosterApi,
|
||||||
|
type RosterQuery,
|
||||||
|
} from "@/domain/vendors/api/vendor-company-roster-api";
|
||||||
|
import type { VendorCompanyRoster } from "@/domain/vendors/types/vendor";
|
||||||
|
import { queryKeys } from "@/infra/query-key/query-key";
|
||||||
|
|
||||||
|
function buildQuery(vendorId?: string | number, companyId?: string | number): RosterQuery | null {
|
||||||
|
const hasVendorId = vendorId !== undefined && vendorId !== null && vendorId !== "";
|
||||||
|
const hasCompanyId = companyId !== undefined && companyId !== null && companyId !== "";
|
||||||
|
if (!hasVendorId && !hasCompanyId) return null;
|
||||||
|
return {
|
||||||
|
vendorId: hasVendorId ? vendorId : undefined,
|
||||||
|
companyId: hasCompanyId ? companyId : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useVendorCompanyRoster(
|
||||||
|
vendorId?: string | number,
|
||||||
|
companyId?: string | number,
|
||||||
|
): UseQueryResult<VendorCompanyRoster, Error> {
|
||||||
|
const query = buildQuery(vendorId, companyId);
|
||||||
|
const by = query?.companyId !== undefined ? "companyId" : "vendorId";
|
||||||
|
const id = (query?.companyId ?? query?.vendorId ?? "") as string | number;
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.vendors.roster(by, id),
|
||||||
|
queryFn: () => {
|
||||||
|
if (!query) throw new Error("Vendor id or company id is required");
|
||||||
|
return vendorCompanyRosterApi.get(query);
|
||||||
|
},
|
||||||
|
enabled: query !== null,
|
||||||
|
staleTime: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -73,6 +73,8 @@ export const queryKeys = {
|
||||||
[...queryKeys.vendors.all, "portalToken", vendorId] as const,
|
[...queryKeys.vendors.all, "portalToken", vendorId] as const,
|
||||||
deactivationImpact: (vendorId: string | number) =>
|
deactivationImpact: (vendorId: string | number) =>
|
||||||
[...queryKeys.vendors.all, "deactivationImpact", vendorId] as const,
|
[...queryKeys.vendors.all, "deactivationImpact", vendorId] as const,
|
||||||
|
roster: (by: "vendorId" | "companyId", id: string | number) =>
|
||||||
|
[...queryKeys.vendors.all, "roster", by, id] as const,
|
||||||
},
|
},
|
||||||
calendar: {
|
calendar: {
|
||||||
all: ["calendar"] as const,
|
all: ["calendar"] as const,
|
||||||
|
|
|
||||||
7
src/lib/format/na-phone.ts
Normal file
7
src/lib/format/na-phone.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
export function formatNorthAmericanPhone(input: string): string {
|
||||||
|
const digits = (input ?? "").replace(/\D+/g, "").slice(0, 10);
|
||||||
|
if (digits.length === 0) return "";
|
||||||
|
if (digits.length < 4) return `(${digits}`;
|
||||||
|
if (digits.length < 7) return `(${digits.slice(0, 3)}) ${digits.slice(3)}`;
|
||||||
|
return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
|
||||||
|
}
|
||||||
275
src/test/app/(protected)/vendors/use-vendor-roster-form.test.tsx
vendored
Normal file
275
src/test/app/(protected)/vendors/use-vendor-roster-form.test.tsx
vendored
Normal file
|
|
@ -0,0 +1,275 @@
|
||||||
|
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useController } from "react-hook-form";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const rosterGet = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("@/domain/vendors/api/vendor-company-roster-api", () => ({
|
||||||
|
vendorCompanyRosterApi: { get: (...args: unknown[]) => rosterGet(...args) },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/domain/vendors/use-cases/use-vendor-company-roster", () => ({
|
||||||
|
useVendorCompanyRoster: () => ({
|
||||||
|
data: undefined,
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
error: null,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/domain/vendors/use-cases/use-vendor-facets", () => ({
|
||||||
|
useVendorFacets: () => ({ data: { companies: [], trades: [] } }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/domain/vendors/use-cases/use-save-vendor-company-roster", async () => {
|
||||||
|
const actual = await vi.importActual<
|
||||||
|
typeof import("@/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 }) };
|
||||||
|
});
|
||||||
|
|
||||||
|
import { useVendorRosterForm } from "@/app/(protected)/vendors/_components/use-vendor-roster-form";
|
||||||
|
import type { VendorCompanyRoster, VendorFacetCompany } from "@/domain/vendors/types/vendor";
|
||||||
|
|
||||||
|
const company: VendorFacetCompany = {
|
||||||
|
companyId: 5,
|
||||||
|
name: "Gateway Plumbing",
|
||||||
|
companyPhone: "(314) 555-0100",
|
||||||
|
email: "dispatch@gateway.test",
|
||||||
|
address: "1 Main St",
|
||||||
|
city: "St. Louis",
|
||||||
|
state: "MO",
|
||||||
|
zip: "63101",
|
||||||
|
googleMapsUrl: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const roster: VendorCompanyRoster = {
|
||||||
|
companyId: 5,
|
||||||
|
rowVersion: "rv-1",
|
||||||
|
name: "Gateway Plumbing",
|
||||||
|
companyPhone: "(314) 555-0100",
|
||||||
|
email: "dispatch@gateway.test",
|
||||||
|
address: "1 Main St",
|
||||||
|
city: "St. Louis",
|
||||||
|
state: "MO",
|
||||||
|
zip: "63101",
|
||||||
|
googleMapsUrl: "",
|
||||||
|
notes: "",
|
||||||
|
technicians: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const secondCompany: VendorFacetCompany = {
|
||||||
|
...company,
|
||||||
|
companyId: 6,
|
||||||
|
name: "Metro HVAC",
|
||||||
|
companyPhone: "(314) 555-0110",
|
||||||
|
email: "dispatch@metro.test",
|
||||||
|
};
|
||||||
|
|
||||||
|
const secondRoster: VendorCompanyRoster = {
|
||||||
|
...roster,
|
||||||
|
companyId: 6,
|
||||||
|
name: "Metro HVAC",
|
||||||
|
companyPhone: "(314) 555-0110",
|
||||||
|
email: "dispatch@metro.test",
|
||||||
|
};
|
||||||
|
|
||||||
|
function createClient(): QueryClient {
|
||||||
|
return new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeWrapper(client: QueryClient) {
|
||||||
|
return function Wrapper({ children }: { children: ReactNode }) {
|
||||||
|
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function deferRoster(): {
|
||||||
|
promise: Promise<VendorCompanyRoster>;
|
||||||
|
resolve: (value: VendorCompanyRoster) => void;
|
||||||
|
} {
|
||||||
|
let resolve!: (value: VendorCompanyRoster) => void;
|
||||||
|
const promise = new Promise<VendorCompanyRoster>((resolver) => {
|
||||||
|
resolve = resolver;
|
||||||
|
});
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useVendorRosterForm load-error handling", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
rosterGet.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a safe retryable error when selecting an existing company fetch is rejected", async () => {
|
||||||
|
rosterGet.mockRejectedValueOnce(new Error("Internal server error"));
|
||||||
|
const { result } = renderHook(() => useVendorRosterForm({ mode: "create" }), {
|
||||||
|
wrapper: makeWrapper(createClient()),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.loadErrorMessage).toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.selectCompany(company);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.loadErrorMessage).toBe("Couldn't load that company's roster. Try again.");
|
||||||
|
expect(result.current.selectedCompanyId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves entered form data when selecting an existing company fetch is rejected", async () => {
|
||||||
|
rosterGet.mockRejectedValueOnce(new Error("Internal server error"));
|
||||||
|
const { result } = renderHook(
|
||||||
|
() => {
|
||||||
|
const form = useVendorRosterForm({ mode: "create" });
|
||||||
|
const name = useController({ control: form.control, name: "name" });
|
||||||
|
return { form, name };
|
||||||
|
},
|
||||||
|
{ wrapper: makeWrapper(createClient()) },
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
result.current.name.field.onChange("Draft Vendor");
|
||||||
|
});
|
||||||
|
expect(result.current.name.field.value).toBe("Draft Vendor");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.form.selectCompany(company);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.name.field.value).toBe("Draft Vendor");
|
||||||
|
expect(result.current.form.loadErrorMessage).toBe(
|
||||||
|
"Couldn't load that company's roster. Try again.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recovers and populates the form on retry after a rejected fetch", async () => {
|
||||||
|
rosterGet.mockRejectedValueOnce(new Error("boom"));
|
||||||
|
const { result } = renderHook(() => useVendorRosterForm({ mode: "create" }), {
|
||||||
|
wrapper: makeWrapper(createClient()),
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.selectCompany(company);
|
||||||
|
});
|
||||||
|
expect(result.current.loadErrorMessage).not.toBeNull();
|
||||||
|
|
||||||
|
rosterGet.mockResolvedValueOnce(roster);
|
||||||
|
await act(async () => {
|
||||||
|
result.current.retryLoad();
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(result.current.loadErrorMessage).toBeNull());
|
||||||
|
|
||||||
|
expect(result.current.selectedCompanyId).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears the selected roster when the form is reset", async () => {
|
||||||
|
rosterGet.mockResolvedValueOnce(roster);
|
||||||
|
const { result } = renderHook(() => useVendorRosterForm({ mode: "create" }), {
|
||||||
|
wrapper: makeWrapper(createClient()),
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.selectCompany(company);
|
||||||
|
});
|
||||||
|
expect(result.current.selectedCompanyId).toBe(5);
|
||||||
|
|
||||||
|
act(() => result.current.resetForm());
|
||||||
|
|
||||||
|
expect(result.current.selectedCompanyId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not expose raw server details in the error message", async () => {
|
||||||
|
rosterGet.mockRejectedValueOnce(new Error("Database connection failed at 10.0.0.1"));
|
||||||
|
const { result } = renderHook(() => useVendorRosterForm({ mode: "create" }), {
|
||||||
|
wrapper: makeWrapper(createClient()),
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.selectCompany(company);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.loadErrorMessage).not.toContain("Database");
|
||||||
|
expect(result.current.loadErrorMessage).not.toContain("10.0.0.1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useVendorRosterForm stale-selection handling", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
rosterGet.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the newest company roster when two lookups resolve out of order", async () => {
|
||||||
|
const first = deferRoster();
|
||||||
|
const second = deferRoster();
|
||||||
|
rosterGet.mockImplementationOnce(() => first.promise);
|
||||||
|
rosterGet.mockImplementationOnce(() => second.promise);
|
||||||
|
|
||||||
|
const { result } = renderHook(
|
||||||
|
() => {
|
||||||
|
const form = useVendorRosterForm({ mode: "create" });
|
||||||
|
const name = useController({ control: form.control, name: "name" });
|
||||||
|
return { form, name };
|
||||||
|
},
|
||||||
|
{ wrapper: makeWrapper(createClient()) },
|
||||||
|
);
|
||||||
|
|
||||||
|
let firstSelect!: Promise<void>;
|
||||||
|
let secondSelect!: Promise<void>;
|
||||||
|
act(() => {
|
||||||
|
firstSelect = result.current.form.selectCompany(company);
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
secondSelect = result.current.form.selectCompany(secondCompany);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
second.resolve(secondRoster);
|
||||||
|
await secondSelect;
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
first.resolve(roster);
|
||||||
|
await firstSelect;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.form.selectedCompanyId).toBe(6);
|
||||||
|
expect(result.current.name.field.value).toBe("Metro HVAC");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a free-text draft when a pending lookup resolves after clearing to free text", async () => {
|
||||||
|
const lookup = deferRoster();
|
||||||
|
rosterGet.mockImplementationOnce(() => lookup.promise);
|
||||||
|
|
||||||
|
const { result } = renderHook(
|
||||||
|
() => {
|
||||||
|
const form = useVendorRosterForm({ mode: "create" });
|
||||||
|
const name = useController({ control: form.control, name: "name" });
|
||||||
|
return { form, name };
|
||||||
|
},
|
||||||
|
{ wrapper: makeWrapper(createClient()) },
|
||||||
|
);
|
||||||
|
|
||||||
|
let pendingSelect!: Promise<void>;
|
||||||
|
act(() => {
|
||||||
|
pendingSelect = result.current.form.selectCompany(company);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.form.clearSelectedCompany("Draft Vendor");
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
result.current.name.field.onChange("Draft Vendor");
|
||||||
|
});
|
||||||
|
expect(result.current.name.field.value).toBe("Draft Vendor");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
lookup.resolve(roster);
|
||||||
|
await pendingSelect;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.form.selectedCompanyId).toBeNull();
|
||||||
|
expect(result.current.name.field.value).toBe("Draft Vendor");
|
||||||
|
});
|
||||||
|
});
|
||||||
90
src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx
vendored
Normal file
90
src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx
vendored
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
import { screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { VendorDetailDrawer } from "@/app/(protected)/vendors/_components/vendor-detail-drawer";
|
||||||
|
import { renderWithProviders } from "@/test/test-utils";
|
||||||
|
import type { VendorListItem } from "@/domain/vendors/types/vendor";
|
||||||
|
|
||||||
|
const useVendorCompanyRoster = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("@/domain/vendors/use-cases/use-vendor-company-roster", () => ({
|
||||||
|
useVendorCompanyRoster: (...args: unknown[]) => useVendorCompanyRoster(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const vendor: VendorListItem = {
|
||||||
|
id: 1,
|
||||||
|
companyId: "co-1",
|
||||||
|
companyName: "Gateway Plumbing",
|
||||||
|
contactName: "Adam Whyte",
|
||||||
|
email: "",
|
||||||
|
phone: "",
|
||||||
|
companyPhone: "",
|
||||||
|
googleMapsUrl: "",
|
||||||
|
notes: "",
|
||||||
|
totalJobs: 0,
|
||||||
|
city: "",
|
||||||
|
state: "",
|
||||||
|
tradeSpecialties: "",
|
||||||
|
isActive: true,
|
||||||
|
preferredContact: "Phone",
|
||||||
|
};
|
||||||
|
|
||||||
|
function rosterWith(technicians: Array<Record<string, unknown>>) {
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
companyId: "co-1",
|
||||||
|
rowVersion: "rv-1",
|
||||||
|
name: "Gateway Plumbing",
|
||||||
|
companyPhone: "(314) 555-0100",
|
||||||
|
email: "dispatch@gateway.test",
|
||||||
|
address: "",
|
||||||
|
city: "",
|
||||||
|
state: "",
|
||||||
|
zip: "",
|
||||||
|
googleMapsUrl: "",
|
||||||
|
notes: "",
|
||||||
|
technicians,
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("VendorDetailDrawer preferred-contact display", () => {
|
||||||
|
it("renders no preference label when preferredContact is absent", () => {
|
||||||
|
useVendorCompanyRoster.mockReturnValue(
|
||||||
|
rosterWith([
|
||||||
|
{ id: 7, contactName: "Adam", phone: "314-555-0198", isActive: true, totalJobs: 5 },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderWithProviders(<VendorDetailDrawer open={true} vendor={vendor} onClose={vi.fn()} />, {
|
||||||
|
route: "/vendors",
|
||||||
|
withAuth: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText("Adam")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/^Prefers/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the preference label when an explicit preferredContact is present", () => {
|
||||||
|
useVendorCompanyRoster.mockReturnValue(
|
||||||
|
rosterWith([
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
contactName: "Adam",
|
||||||
|
phone: "314-555-0198",
|
||||||
|
preferredContact: "Email",
|
||||||
|
isActive: true,
|
||||||
|
totalJobs: 5,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderWithProviders(<VendorDetailDrawer open={true} vendor={vendor} onClose={vi.fn()} />, {
|
||||||
|
route: "/vendors",
|
||||||
|
withAuth: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText("Prefers Email")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,194 +0,0 @@
|
||||||
import { screen } from "@testing-library/react";
|
|
||||||
import userEvent from "@testing-library/user-event";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { useForm, useWatch, type FieldErrors } from "react-hook-form";
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { VendorFormFields } from "@/app/(protected)/vendors/_components/vendor-form-fields";
|
|
||||||
import { vendorSchema, type VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
|
||||||
import type { VendorFacetCompany } from "@/domain/vendors/types/vendor";
|
|
||||||
import { renderWithProviders } from "@/test/test-utils";
|
|
||||||
|
|
||||||
const companies: VendorFacetCompany[] = [
|
|
||||||
{
|
|
||||||
companyId: "co-1",
|
|
||||||
name: "Gateway Plumbing",
|
|
||||||
companyPhone: "(314) 555-0198",
|
|
||||||
email: "dispatch@gatewayplumbing.com",
|
|
||||||
address: "1 Main St",
|
|
||||||
city: "St. Louis",
|
|
||||||
state: "MO",
|
|
||||||
zip: "63101",
|
|
||||||
googleMapsUrl: "https://maps.google.com/gateway",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const emptyForm: VendorFormValues = {
|
|
||||||
companyName: "",
|
|
||||||
contactName: "Tech Person",
|
|
||||||
email: "",
|
|
||||||
phone: "",
|
|
||||||
companyPhone: "",
|
|
||||||
googleMapsUrl: "",
|
|
||||||
notes: "",
|
|
||||||
totalJobs: 0,
|
|
||||||
address: "",
|
|
||||||
city: "",
|
|
||||||
state: "",
|
|
||||||
zip: "",
|
|
||||||
tradeSpecialties: "",
|
|
||||||
isActive: true,
|
|
||||||
companyId: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
function Harness({
|
|
||||||
defaultValues,
|
|
||||||
resetTarget,
|
|
||||||
}: {
|
|
||||||
defaultValues: VendorFormValues;
|
|
||||||
resetTarget: VendorFormValues;
|
|
||||||
}) {
|
|
||||||
const { control, reset, setValue } = useForm<VendorFormValues>({
|
|
||||||
resolver: zodResolver(vendorSchema),
|
|
||||||
defaultValues,
|
|
||||||
});
|
|
||||||
|
|
||||||
const onCompanySelected = (company: VendorFacetCompany | null) => {
|
|
||||||
if (!company) return;
|
|
||||||
setValue("companyId", company.companyId);
|
|
||||||
setValue("companyPhone", company.companyPhone);
|
|
||||||
setValue("email", company.email);
|
|
||||||
setValue("address", company.address);
|
|
||||||
setValue("city", company.city);
|
|
||||||
setValue("state", company.state);
|
|
||||||
setValue("zip", company.zip);
|
|
||||||
setValue("googleMapsUrl", company.googleMapsUrl);
|
|
||||||
};
|
|
||||||
|
|
||||||
const companyId = useWatch({ control, name: "companyId" });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<VendorFormFields
|
|
||||||
control={control}
|
|
||||||
errors={{} as FieldErrors<VendorFormValues>}
|
|
||||||
companies={companies}
|
|
||||||
tradeOptions={[]}
|
|
||||||
onCompanySelected={onCompanySelected}
|
|
||||||
/>
|
|
||||||
<output data-testid="company-id">{String(companyId ?? "")}</output>
|
|
||||||
<button type="button" onClick={() => reset(resetTarget)}>
|
|
||||||
reset-form
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const companyField = () => screen.getByRole("combobox", { name: "Company" }) as HTMLInputElement;
|
|
||||||
const companyIdOutput = () => screen.getByTestId("company-id");
|
|
||||||
|
|
||||||
describe("VendorFormFields company autocomplete state", () => {
|
|
||||||
it("clears a previously typed company when the form is reset (create-form reopen)", async () => {
|
|
||||||
renderWithProviders(<Harness defaultValues={emptyForm} resetTarget={emptyForm} />, {
|
|
||||||
withAuth: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
await userEvent.type(companyField(), "Leftover Co");
|
|
||||||
expect(companyField()).toHaveValue("Leftover Co");
|
|
||||||
|
|
||||||
await userEvent.click(screen.getByRole("button", { name: "reset-form" }));
|
|
||||||
|
|
||||||
expect(companyField()).toHaveValue("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows the switched company value instead of previously typed text when the form is reset", async () => {
|
|
||||||
renderWithProviders(
|
|
||||||
<Harness
|
|
||||||
defaultValues={emptyForm}
|
|
||||||
resetTarget={{ ...emptyForm, companyName: "Beta LLC", contactName: "Other Tech" }}
|
|
||||||
/>,
|
|
||||||
{ withAuth: false },
|
|
||||||
);
|
|
||||||
|
|
||||||
await userEvent.type(companyField(), "Acme");
|
|
||||||
expect(companyField()).toHaveValue("Acme");
|
|
||||||
|
|
||||||
await userEvent.click(screen.getByRole("button", { name: "reset-form" }));
|
|
||||||
|
|
||||||
expect(companyField()).toHaveValue("Beta LLC");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reflects the company provided by an external reset even when nothing was typed", async () => {
|
|
||||||
renderWithProviders(
|
|
||||||
<Harness
|
|
||||||
defaultValues={{ ...emptyForm, companyName: "Acme Corp" }}
|
|
||||||
resetTarget={{ ...emptyForm, companyName: "Beta LLC" }}
|
|
||||||
/>,
|
|
||||||
{ withAuth: false },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(companyField()).toHaveValue("Acme Corp");
|
|
||||||
|
|
||||||
await userEvent.click(screen.getByRole("button", { name: "reset-form" }));
|
|
||||||
|
|
||||||
expect(companyField()).toHaveValue("Beta LLC");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("updates the displayed company as the user types (freeSolo)", async () => {
|
|
||||||
renderWithProviders(<Harness defaultValues={emptyForm} resetTarget={emptyForm} />, {
|
|
||||||
withAuth: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
await userEvent.type(companyField(), "Acme");
|
|
||||||
|
|
||||||
expect(companyField()).toHaveValue("Acme");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("selects a company option, autofills company details, and shows the company name", async () => {
|
|
||||||
renderWithProviders(<Harness defaultValues={emptyForm} resetTarget={emptyForm} />, {
|
|
||||||
withAuth: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
await userEvent.type(companyField(), "Gate");
|
|
||||||
const option = await screen.findByRole("option", { name: "Gateway Plumbing" });
|
|
||||||
await userEvent.click(option);
|
|
||||||
|
|
||||||
expect(companyField()).toHaveValue("Gateway Plumbing");
|
|
||||||
expect(screen.getByRole("textbox", { name: "Company Phone" })).toHaveValue("(314) 555-0198");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sets companyId when a known company is selected and clears it when the text changes", async () => {
|
|
||||||
renderWithProviders(<Harness defaultValues={emptyForm} resetTarget={emptyForm} />, {
|
|
||||||
withAuth: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
await userEvent.type(companyField(), "Gate");
|
|
||||||
await userEvent.click(await screen.findByRole("option", { name: "Gateway Plumbing" }));
|
|
||||||
expect(companyIdOutput()).toHaveTextContent("co-1");
|
|
||||||
|
|
||||||
await userEvent.type(companyField(), "!");
|
|
||||||
expect(companyIdOutput()).toHaveTextContent("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps companyId null for free-typed company text that matches no option", async () => {
|
|
||||||
renderWithProviders(<Harness defaultValues={emptyForm} resetTarget={emptyForm} />, {
|
|
||||||
withAuth: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
await userEvent.type(companyField(), "Independent Vendor LLC");
|
|
||||||
expect(companyIdOutput()).toHaveTextContent("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("formats technician and company phones into the (XXX) XXX-XXXX mask", async () => {
|
|
||||||
renderWithProviders(<Harness defaultValues={emptyForm} resetTarget={emptyForm} />, {
|
|
||||||
withAuth: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const phone = screen.getByRole("textbox", { name: "Technician Phone" });
|
|
||||||
await userEvent.type(phone, "3145550198");
|
|
||||||
expect(phone).toHaveValue("(314) 555-0198");
|
|
||||||
|
|
||||||
const companyPhone = screen.getByRole("textbox", { name: "Company Phone" });
|
|
||||||
await userEvent.type(companyPhone, "3145550100");
|
|
||||||
expect(companyPhone).toHaveValue("(314) 555-0100");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -2,19 +2,26 @@ import { screen } 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 { renderWithProviders } from "@/test/test-utils";
|
import { renderWithProviders } from "@/test/test-utils";
|
||||||
|
|
||||||
const useVendorsList = vi.fn();
|
const useVendorsList = vi.fn();
|
||||||
const useVendorDetail = 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),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/domain/vendors/use-cases/use-vendor-detail", () => ({
|
vi.mock("@/domain/vendors/use-cases/use-vendor-company-roster", () => ({
|
||||||
useVendorDetail: (...args: unknown[]) => useVendorDetail(...args),
|
useVendorCompanyRoster: (...args: unknown[]) => useVendorCompanyRoster(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/domain/vendors/use-cases/use-vendor-deactivation-impact", () => ({
|
vi.mock("@/domain/vendors/use-cases/use-vendor-deactivation-impact", () => ({
|
||||||
|
|
@ -29,13 +36,17 @@ vi.mock("@/hooks/use-debounce", () => ({
|
||||||
useDebounce: (value: string) => value,
|
useDebounce: (value: string) => value,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const activeVendor = {
|
const activeVendor: VendorListItem = {
|
||||||
id: 1,
|
id: 1,
|
||||||
companyId: "co-1",
|
companyId: "co-1",
|
||||||
companyName: "Gateway Plumbing",
|
companyName: "Gateway Plumbing",
|
||||||
contactName: "Adam Whyte",
|
contactName: "Adam Whyte",
|
||||||
email: "dispatch@gatewayplumbing.com",
|
email: "dispatch@gatewayplumbing.com",
|
||||||
phone: "(314) 555-0198",
|
phone: "(314) 555-0198",
|
||||||
|
companyPhone: "(314) 555-0100",
|
||||||
|
googleMapsUrl: "",
|
||||||
|
notes: "",
|
||||||
|
totalJobs: 0,
|
||||||
city: "St. Louis",
|
city: "St. Louis",
|
||||||
state: "MO",
|
state: "MO",
|
||||||
tradeSpecialties: "Backflow Preventers",
|
tradeSpecialties: "Backflow Preventers",
|
||||||
|
|
@ -50,7 +61,7 @@ const inactiveVendor = {
|
||||||
isActive: false,
|
isActive: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
function result(items: (typeof activeVendor)[], totalCount: number) {
|
function result(items: VendorListItem[], totalCount: number) {
|
||||||
return {
|
return {
|
||||||
data: { items, totalCount, page: 1, pageSize: 12 },
|
data: { items, totalCount, page: 1, pageSize: 12 },
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
|
@ -60,7 +71,7 @@ function result(items: (typeof activeVendor)[], totalCount: number) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupDefaults() {
|
function setupDefaults() {
|
||||||
useVendorDetail.mockReturnValue({ data: undefined, isLoading: false, error: null });
|
useVendorCompanyRoster.mockReturnValue({ data: undefined, isLoading: false, error: null });
|
||||||
useVendorDeactivationImpact.mockReturnValue({
|
useVendorDeactivationImpact.mockReturnValue({
|
||||||
data: undefined,
|
data: undefined,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
|
@ -103,13 +114,40 @@ describe("VendorsListPage", () => {
|
||||||
|
|
||||||
renderWithProviders(<VendorsListPage />, { route: "/vendors", withAuth: false });
|
renderWithProviders(<VendorsListPage />, { route: "/vendors", withAuth: false });
|
||||||
|
|
||||||
expect(screen.getByText(/25 active technicians/)).toBeInTheDocument();
|
expect(screen.getByText(/25 active vendor entries/)).toBeInTheDocument();
|
||||||
expect(screen.queryByText(/3 active technicians/)).not.toBeInTheDocument();
|
expect(screen.queryByText(/3 active vendor entries/)).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();
|
||||||
useVendorDetail.mockReturnValue({ data: activeVendor, isLoading: false, error: null });
|
useVendorCompanyRoster.mockReturnValue({
|
||||||
|
data: {
|
||||||
|
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,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
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);
|
||||||
|
|
@ -160,4 +198,38 @@ describe("VendorsListPage", () => {
|
||||||
expect(screen.getByRole("button", { name: /^Deactivate$/ })).toBeDisabled();
|
expect(screen.getByRole("button", { name: /^Deactivate$/ })).toBeDisabled();
|
||||||
expect(mutate).not.toHaveBeenCalled();
|
expect(mutate).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("navigates to the vendor edit route when the table Edit control is clicked", async () => {
|
||||||
|
setupDefaults();
|
||||||
|
navigate.mockClear();
|
||||||
|
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" }));
|
||||||
|
|
||||||
|
expect(navigate).toHaveBeenCalledWith("/vendors/edit/1");
|
||||||
|
expect(
|
||||||
|
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 () => {
|
||||||
|
setupDefaults();
|
||||||
|
navigate.mockClear();
|
||||||
|
const companyOnlyVendor = { ...activeVendor, id: null, companyId: "co-9" };
|
||||||
|
useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
|
||||||
|
if (params.pageSize === 1) return result([], 1);
|
||||||
|
return params.isActive ? result([companyOnlyVendor], 1) : result([], 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProviders(<VendorsListPage />, { route: "/vendors", withAuth: false });
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" }));
|
||||||
|
|
||||||
|
expect(navigate).toHaveBeenCalledWith("/vendors/company/co-9");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
124
src/test/domain/vendors/api/vendor-company-roster-api.test.ts
vendored
Normal file
124
src/test/domain/vendors/api/vendor-company-roster-api.test.ts
vendored
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { API_PATHS } from "@/api/api-paths";
|
||||||
|
|
||||||
|
const apiGet = vi.fn();
|
||||||
|
const apiPost = vi.fn();
|
||||||
|
const apiPut = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("@/api/api", () => ({
|
||||||
|
apiGet: (...args: unknown[]) => apiGet(...args),
|
||||||
|
apiPost: (...args: unknown[]) => apiPost(...args),
|
||||||
|
apiPut: (...args: unknown[]) => apiPut(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("ky", () => ({
|
||||||
|
isHTTPError: (error: unknown) =>
|
||||||
|
error !== null && typeof error === "object" && "__kyHTTPError" in error,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { vendorCompanyRosterApi } from "@/domain/vendors/api/vendor-company-roster-api";
|
||||||
|
import { isVendorRosterConflictError } from "@/domain/vendors/lib/vendor-roster-conflict";
|
||||||
|
|
||||||
|
function httpError(status: number, body: unknown): Error {
|
||||||
|
const error = new Error("HTTPError") as Error & {
|
||||||
|
__kyHTTPError: true;
|
||||||
|
response: { status: number; json: () => Promise<unknown>; text: () => Promise<string> };
|
||||||
|
};
|
||||||
|
error.__kyHTTPError = true;
|
||||||
|
error.response = {
|
||||||
|
status,
|
||||||
|
json: () => Promise.resolve(body),
|
||||||
|
text: () => Promise.resolve(JSON.stringify(body)),
|
||||||
|
};
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("vendorCompanyRosterApi", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
apiGet.mockReset();
|
||||||
|
apiPost.mockReset();
|
||||||
|
apiPut.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fetches by vendorId or companyId with the correct query params", async () => {
|
||||||
|
apiGet.mockResolvedValueOnce({ data: { companyId: 5, name: "Solo Co" } });
|
||||||
|
|
||||||
|
await vendorCompanyRosterApi.get({ vendorId: 7 });
|
||||||
|
|
||||||
|
expect(apiGet).toHaveBeenCalledWith(API_PATHS.vendorCompanyRoster.base, {
|
||||||
|
searchParams: { vendorId: "7" },
|
||||||
|
});
|
||||||
|
|
||||||
|
apiGet.mockResolvedValueOnce({ data: { companyId: 5, name: "Solo Co" } });
|
||||||
|
await vendorCompanyRosterApi.get({ companyId: 5 });
|
||||||
|
expect(apiGet).toHaveBeenLastCalledWith(API_PATHS.vendorCompanyRoster.base, {
|
||||||
|
searchParams: { companyId: "5" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("posts the canonicalized create payload", async () => {
|
||||||
|
apiPost.mockResolvedValueOnce({ data: { companyId: 9, name: "Solo Co" } });
|
||||||
|
|
||||||
|
await vendorCompanyRosterApi.create({
|
||||||
|
name: "Solo Co",
|
||||||
|
companyPhone: "314-555-0100",
|
||||||
|
technicians: [{ contactName: "Adam", phone: "3145550198", preferredContact: "Phone" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(apiPost).toHaveBeenCalledWith(
|
||||||
|
API_PATHS.vendorCompanyRoster.base,
|
||||||
|
expect.objectContaining({
|
||||||
|
name: "Solo Co",
|
||||||
|
companyPhone: "(314) 555-0100",
|
||||||
|
technicians: [expect.objectContaining({ phone: "(314) 555-0198" })],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(apiPost.mock.calls[0]?.[1]).not.toHaveProperty("rowVersion");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts the rowVersion on the update payload", async () => {
|
||||||
|
apiPut.mockResolvedValueOnce({ data: { companyId: 5, name: "Solo Co" } });
|
||||||
|
|
||||||
|
await vendorCompanyRosterApi.update(5, { name: "Solo Co" }, "rv-1");
|
||||||
|
|
||||||
|
expect(apiPut).toHaveBeenCalledWith(
|
||||||
|
API_PATHS.vendorCompanyRoster.byCompany(5),
|
||||||
|
expect.objectContaining({ name: "Solo Co", rowVersion: "rv-1" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws an open-work-order conflict on a 409 with blocked work orders", async () => {
|
||||||
|
apiPut.mockRejectedValueOnce(
|
||||||
|
httpError(409, {
|
||||||
|
blockedWorkOrders: [
|
||||||
|
{ workOrderId: 501, workOrderNumber: "WO-501", workOrderTitle: "Boiler" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(vendorCompanyRosterApi.update(5, { name: "Solo Co" }, "rv-1")).rejects.toSatisfy(
|
||||||
|
(error: unknown) => {
|
||||||
|
if (!isVendorRosterConflictError(error)) return false;
|
||||||
|
return error.conflict.kind === "open-work-orders";
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws a stale conflict on a 409 without blocked work orders", async () => {
|
||||||
|
apiPut.mockRejectedValueOnce(httpError(409, { message: "rowversion mismatch" }));
|
||||||
|
|
||||||
|
await expect(vendorCompanyRosterApi.update(5, { name: "Solo Co" }, "rv-1")).rejects.toSatisfy(
|
||||||
|
(error: unknown) => {
|
||||||
|
if (!isVendorRosterConflictError(error)) return false;
|
||||||
|
return error.conflict.kind === "stale";
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rethrows non-conflict errors untouched", async () => {
|
||||||
|
const generic = new Error("boom");
|
||||||
|
apiPost.mockRejectedValueOnce(generic);
|
||||||
|
|
||||||
|
await expect(vendorCompanyRosterApi.create({ name: "Solo Co" })).rejects.toBe(generic);
|
||||||
|
});
|
||||||
|
});
|
||||||
157
src/test/domain/vendors/mappers/vendor-roster-mapper.test.ts
vendored
Normal file
157
src/test/domain/vendors/mappers/vendor-roster-mapper.test.ts
vendored
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
mapRosterConflict,
|
||||||
|
mapRosterTechnician,
|
||||||
|
mapVendorCompanyRoster,
|
||||||
|
mapVendorRosterToBackend,
|
||||||
|
} from "@/domain/vendors/mappers/vendor-roster-mapper";
|
||||||
|
|
||||||
|
describe("vendor roster mapper", () => {
|
||||||
|
it("maps a PascalCase aggregate payload including technicians", () => {
|
||||||
|
const roster = mapVendorCompanyRoster({
|
||||||
|
CompanyId: 101,
|
||||||
|
RowVersion: "rv-1",
|
||||||
|
Name: "Gateway Plumbing",
|
||||||
|
CompanyPhone: "314-555-0100",
|
||||||
|
Email: "dispatch@gateway.test",
|
||||||
|
Technicians: [
|
||||||
|
{
|
||||||
|
Id: 7,
|
||||||
|
ContactName: "Adam Whyte",
|
||||||
|
Phone: "314-555-0198",
|
||||||
|
Email: "adam@gateway.test",
|
||||||
|
PreferredContact: "Email",
|
||||||
|
TradeSpecialties: "Plumbing",
|
||||||
|
IsActive: true,
|
||||||
|
TotalJobs: 72,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(roster).toEqual({
|
||||||
|
companyId: 101,
|
||||||
|
rowVersion: "rv-1",
|
||||||
|
name: "Gateway Plumbing",
|
||||||
|
companyPhone: "314-555-0100",
|
||||||
|
email: "dispatch@gateway.test",
|
||||||
|
address: "",
|
||||||
|
city: "",
|
||||||
|
state: "",
|
||||||
|
zip: "",
|
||||||
|
googleMapsUrl: "",
|
||||||
|
notes: "",
|
||||||
|
technicians: [
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
contactName: "Adam Whyte",
|
||||||
|
phone: "314-555-0198",
|
||||||
|
email: "adam@gateway.test",
|
||||||
|
preferredContact: "Email",
|
||||||
|
tradeSpecialties: "Plumbing",
|
||||||
|
isActive: true,
|
||||||
|
totalJobs: 72,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps a company-only roster with a null vendor id and zero technicians", () => {
|
||||||
|
const roster = mapVendorCompanyRoster({ companyId: 5, name: "Solo Co" });
|
||||||
|
expect(roster.companyId).toBe(5);
|
||||||
|
expect(roster.technicians).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("canonicalizes phones and carries rowVersion on the backend payload", () => {
|
||||||
|
const payload = mapVendorRosterToBackend({
|
||||||
|
name: "Gateway Plumbing",
|
||||||
|
companyPhone: "314-555-0100",
|
||||||
|
email: "dispatch@gateway.test",
|
||||||
|
googleMapsUrl: "https://maps.google.com/x",
|
||||||
|
tradeSpecialties: "Plumbing",
|
||||||
|
rowVersion: "rv-2",
|
||||||
|
technicians: [{ contactName: "Adam", phone: "3145550198", preferredContact: "Phone" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(payload).toMatchObject({
|
||||||
|
name: "Gateway Plumbing",
|
||||||
|
companyPhone: "(314) 555-0100",
|
||||||
|
email: "dispatch@gateway.test",
|
||||||
|
rowVersion: "rv-2",
|
||||||
|
});
|
||||||
|
expect(payload.technicians).toEqual([
|
||||||
|
expect.objectContaining({ contactName: "Adam", phone: "(314) 555-0198" }),
|
||||||
|
]);
|
||||||
|
expect(payload).not.toHaveProperty("tradeSpecialties");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits rowVersion from the create payload when absent", () => {
|
||||||
|
const payload = mapVendorRosterToBackend({ name: "Solo Co", companyPhone: "314-555-0100" });
|
||||||
|
expect(payload).not.toHaveProperty("rowVersion");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps an open-work-order conflict payload", () => {
|
||||||
|
const conflict = mapRosterConflict(
|
||||||
|
{
|
||||||
|
blockedWorkOrders: [
|
||||||
|
{
|
||||||
|
workOrderId: 501,
|
||||||
|
workOrderNumber: "WO-501",
|
||||||
|
workOrderTitle: "Boiler repair",
|
||||||
|
status: "Dispatched",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"fallback",
|
||||||
|
);
|
||||||
|
expect(conflict.kind).toBe("open-work-orders");
|
||||||
|
expect(conflict.blockedWorkOrders[0]?.workOrderNumber).toBe("WO-501");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies a body without blocked work orders as stale", () => {
|
||||||
|
const conflict = mapRosterConflict({ message: "stale rowversion" }, "fallback");
|
||||||
|
expect(conflict.kind).toBe("stale");
|
||||||
|
expect(conflict.message).toBe("stale rowversion");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves an explicit Phone/Email/Text preferred contact on read", () => {
|
||||||
|
expect(
|
||||||
|
mapRosterTechnician({ contactName: "A", preferredContact: "Phone" }).preferredContact,
|
||||||
|
).toBe("Phone");
|
||||||
|
expect(
|
||||||
|
mapRosterTechnician({ contactName: "B", preferredContact: "Email" }).preferredContact,
|
||||||
|
).toBe("Email");
|
||||||
|
expect(
|
||||||
|
mapRosterTechnician({ contactName: "C", preferredContact: "Text" }).preferredContact,
|
||||||
|
).toBe("Text");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves an absent preferred contact as absent on read", () => {
|
||||||
|
const tech = mapRosterTechnician({ contactName: "Adam" });
|
||||||
|
expect(tech.preferredContact).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves an unknown preferred contact as absent on read", () => {
|
||||||
|
const tech = mapRosterTechnician({ contactName: "Adam", preferredContact: "Fax" });
|
||||||
|
expect(tech.preferredContact).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits preferredContact from the backend payload when absent", () => {
|
||||||
|
const payload = mapVendorRosterToBackend({
|
||||||
|
name: "Solo Co",
|
||||||
|
companyPhone: "314-555-0100",
|
||||||
|
technicians: [{ contactName: "Adam", phone: "3145550198" }],
|
||||||
|
});
|
||||||
|
const tech = (payload.technicians as Array<Record<string, unknown>>)[0];
|
||||||
|
expect(tech).not.toHaveProperty("preferredContact");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps an explicit preferredContact in the backend payload", () => {
|
||||||
|
const payload = mapVendorRosterToBackend({
|
||||||
|
name: "Solo Co",
|
||||||
|
companyPhone: "314-555-0100",
|
||||||
|
technicians: [{ contactName: "Adam", phone: "3145550198", preferredContact: "Email" }],
|
||||||
|
});
|
||||||
|
const tech = (payload.technicians as Array<Record<string, unknown>>)[0];
|
||||||
|
expect(tech.preferredContact).toBe("Email");
|
||||||
|
});
|
||||||
|
});
|
||||||
114
src/test/domain/vendors/schemas/vendor-roster-schema.test.ts
vendored
Normal file
114
src/test/domain/vendors/schemas/vendor-roster-schema.test.ts
vendored
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
emptyRosterTechnician,
|
||||||
|
emptyVendorCompanyRosterForm,
|
||||||
|
isAbsoluteHttpsUrl,
|
||||||
|
vendorCompanyRosterSchema,
|
||||||
|
vendorCompanyRosterUpdateSchema,
|
||||||
|
} from "@/domain/vendors/schemas/vendor-roster-schema";
|
||||||
|
|
||||||
|
const baseCompany = {
|
||||||
|
...emptyVendorCompanyRosterForm,
|
||||||
|
name: "Gateway Plumbing",
|
||||||
|
companyPhone: "(314) 555-0100",
|
||||||
|
email: "dispatch@gateway.test",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("vendorCompanyRosterSchema", () => {
|
||||||
|
it("requires a company name", () => {
|
||||||
|
const result = vendorCompanyRosterSchema.safeParse({ ...baseCompany, name: "" });
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires a company phone or email even with no technicians", () => {
|
||||||
|
const result = vendorCompanyRosterSchema.safeParse({
|
||||||
|
...emptyVendorCompanyRosterForm,
|
||||||
|
name: "Solo Co",
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
if (!result.success) {
|
||||||
|
expect(
|
||||||
|
result.error.issues.some((issue) => issue.message === "Company phone or email is required"),
|
||||||
|
).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a company with zero technicians when phone is present", () => {
|
||||||
|
const result = vendorCompanyRosterSchema.safeParse({
|
||||||
|
...emptyVendorCompanyRosterForm,
|
||||||
|
name: "Solo Co",
|
||||||
|
companyPhone: "(314) 555-0100",
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a company with email only and multiple technicians", () => {
|
||||||
|
const result = vendorCompanyRosterSchema.safeParse({
|
||||||
|
...baseCompany,
|
||||||
|
companyPhone: "",
|
||||||
|
technicians: [
|
||||||
|
{ ...emptyRosterTechnician, contactName: "Adam", phone: "314-555-0198" },
|
||||||
|
{ ...emptyRosterTechnician, contactName: "Beth", phone: "314-555-0200" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a technician row without a name or phone", () => {
|
||||||
|
const result = vendorCompanyRosterSchema.safeParse({
|
||||||
|
...baseCompany,
|
||||||
|
technicians: [{ ...emptyRosterTechnician, contactName: "" }],
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects malformed emails, non-HTTPS map URLs, and partial phones", () => {
|
||||||
|
const result = vendorCompanyRosterSchema.safeParse({
|
||||||
|
...baseCompany,
|
||||||
|
email: "not-an-email",
|
||||||
|
googleMapsUrl: "http://maps.google.com/x",
|
||||||
|
technicians: [{ ...emptyRosterTechnician, contactName: "Adam", phone: "(314)" }],
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a maps URL that is a non-absolute https prefix string", () => {
|
||||||
|
expect(isAbsoluteHttpsUrl("https://")).toBe(false);
|
||||||
|
expect(isAbsoluteHttpsUrl("maps.google.com/x")).toBe(false);
|
||||||
|
expect(isAbsoluteHttpsUrl("https://maps.google.com/x")).toBe(true);
|
||||||
|
expect(isAbsoluteHttpsUrl("")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unknown preferred contact", () => {
|
||||||
|
const result = vendorCompanyRosterSchema.safeParse({
|
||||||
|
...baseCompany,
|
||||||
|
technicians: [
|
||||||
|
{ ...emptyRosterTechnician, contactName: "Adam", preferredContact: "Fax" as never },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a technician with an absent preferred contact", () => {
|
||||||
|
const techWithoutPreference = { ...emptyRosterTechnician };
|
||||||
|
delete techWithoutPreference.preferredContact;
|
||||||
|
const result = vendorCompanyRosterSchema.safeParse({
|
||||||
|
...baseCompany,
|
||||||
|
technicians: [{ ...techWithoutPreference, contactName: "Adam", phone: "314-555-0198" }],
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("update schema requires a non-empty row version and company id", () => {
|
||||||
|
const result = vendorCompanyRosterUpdateSchema.safeParse({
|
||||||
|
...baseCompany,
|
||||||
|
companyId: 42,
|
||||||
|
rowVersion: "",
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes an empty default form with no technicians", () => {
|
||||||
|
expect(emptyVendorCompanyRosterForm.technicians).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
71
src/test/domain/vendors/use-cases/use-save-vendor-company-roster.test.ts
vendored
Normal file
71
src/test/domain/vendors/use-cases/use-save-vendor-company-roster.test.ts
vendored
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { getSingleStatusOnlyChange } from "@/domain/vendors/use-cases/use-save-vendor-company-roster";
|
||||||
|
import type { VendorCompanyRoster } from "@/domain/vendors/types/vendor";
|
||||||
|
|
||||||
|
const roster: VendorCompanyRoster = {
|
||||||
|
companyId: 10,
|
||||||
|
rowVersion: "rv-1",
|
||||||
|
name: "Legacy Plumbing",
|
||||||
|
companyPhone: "555-1234 ext 9",
|
||||||
|
email: "dispatch@example.test",
|
||||||
|
address: "1 Main St",
|
||||||
|
city: "St. Louis",
|
||||||
|
state: "MO",
|
||||||
|
zip: "63101",
|
||||||
|
googleMapsUrl: "http://maps.example.test/legacy",
|
||||||
|
notes: "Legacy values remain untouched",
|
||||||
|
technicians: [
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
contactName: "Taylor",
|
||||||
|
phone: "555-0199 ext 2",
|
||||||
|
email: "taylor@example.test",
|
||||||
|
preferredContact: "Phone",
|
||||||
|
tradeSpecialties: "Plumbing",
|
||||||
|
isActive: false,
|
||||||
|
totalJobs: 12,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const values = {
|
||||||
|
name: roster.name,
|
||||||
|
companyPhone: roster.companyPhone,
|
||||||
|
email: roster.email,
|
||||||
|
address: roster.address,
|
||||||
|
city: roster.city,
|
||||||
|
state: roster.state,
|
||||||
|
zip: roster.zip,
|
||||||
|
googleMapsUrl: roster.googleMapsUrl,
|
||||||
|
notes: roster.notes,
|
||||||
|
technicians: roster.technicians.map((technician) => ({
|
||||||
|
id: technician.id,
|
||||||
|
contactName: technician.contactName,
|
||||||
|
phone: technician.phone,
|
||||||
|
email: technician.email,
|
||||||
|
preferredContact: technician.preferredContact,
|
||||||
|
tradeSpecialties: technician.tradeSpecialties,
|
||||||
|
isActive: technician.isActive,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("getSingleStatusOnlyChange", () => {
|
||||||
|
it("preserves the sparse legacy status path when exactly one technician status changes", () => {
|
||||||
|
expect(
|
||||||
|
getSingleStatusOnlyChange(roster, {
|
||||||
|
...values,
|
||||||
|
technicians: [{ ...values.technicians[0], isActive: true }],
|
||||||
|
}),
|
||||||
|
).toEqual({ id: 7, isActive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires aggregate reconciliation when company or technician data changes", () => {
|
||||||
|
expect(getSingleStatusOnlyChange(roster, { ...values, notes: "changed" })).toBeNull();
|
||||||
|
expect(
|
||||||
|
getSingleStatusOnlyChange(roster, {
|
||||||
|
...values,
|
||||||
|
technicians: [{ ...values.technicians[0], contactName: "Renamed", isActive: true }],
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue