feat(vendors): add company roster workflows (SH-198) (#64)
Some checks failed
CI / ci (push) Has been cancelled
CI / governance (push) Has been cancelled
Deploy / deploy (push) Has been cancelled

* 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:
Alexandre Brandizzi 2026-08-04 09:53:48 -03:00 • committed by GitHub
parent 297efa8f73
commit ca7829036f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
45 changed files with 4109 additions and 2044 deletions

View file

@ -147,7 +147,7 @@ async function mockVendorApi(
const filtered = source.filter(
(vendor) =>
(!search ||
`${vendor.CompanyName} ${vendor.ContactName} ${vendor.TradeSpecialties}`
`${vendor.CompanyName} ${vendor.ContactName} ${vendor.TradeSpecialties} ${vendor.City} ${vendor.State}`
.toLowerCase()
.includes(search)) &&
(!company || vendor.CompanyName === company),
@ -160,6 +160,75 @@ async function mockVendorApi(
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) => {
const vendorId = Number(route.request().url().split("/").at(-2));
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) => {
const id = route.request().url().split("/").pop() ?? "";
if (route.request().method() === "PUT") {
@ -232,7 +310,7 @@ test.describe("Vendor directory prototype parity", () => {
await page.goto("/vendors");
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(
"aria-selected",
"true",
@ -251,16 +329,19 @@ test.describe("Vendor directory prototype parity", () => {
}
await expect(page.getByText("Gateway Plumbing")).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 page.getByPlaceholder("Search name, company, trade...").fill("metro");
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(
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.getByRole("button", { name: "Open vendor filters" }).click();
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("button", { name: "Apply" }).click();
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(
state.listUrls.some(
(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);
});
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,
}) => {
const state = await mockVendorApi(page);
await page.goto("/vendors");
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 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("option", { name: "Gateway Plumbing" }).click();
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("Google Maps URL")).toHaveValue("https://maps.google.com/gateway");
await page.getByRole("combobox", { name: "Add Trade" }).fill("Plumbing");
await page.getByRole("button", { name: "Add trade" }).click();
await page.getByRole("combobox", { name: "Add Trade" }).fill("HVAC");
await page.getByRole("button", { name: "Add trade" }).click();
await expect(page.getByRole("button", { name: "Move Plumbing up" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Move HVAC down" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Move HVAC up" })).toBeVisible();
await expect(page.getByRole("button", { name: "Move Plumbing down" })).toBeVisible();
await page.getByRole("button", { name: "Move HVAC up" }).click();
await expect(page.getByLabel("Preferred Contact")).toHaveCount(0);
await page.getByRole("button", { name: "Add technician" }).click();
await page.getByLabel("Technician Name").last().fill("New Technician");
const tradeInput = page.getByRole("combobox", { name: "Add Trade" }).last();
await tradeInput.fill("HVAC");
await tradeInput.press("Enter");
await tradeInput.fill("Plumbing");
await tradeInput.press("Enter");
await page.getByRole("button", { name: "Move Plumbing up" }).click();
await page.getByLabel("Notes").fill("Created in browser E2E");
await expect(page.getByText("Preferred Contact")).toHaveCount(0);
await page.getByRole("button", { name: "Add Vendor" }).last().click();
await expect(page.getByRole("dialog", { name: "Add Vendor" })).toHaveCount(0);
expect(state.createdBody).not.toHaveProperty("preferredContact");
expect(state.createdBody).toMatchObject({
companyName: "Gateway Plumbing",
companyId: 101,
contactName: "New Technician",
await page.getByRole("button", { name: "Add Vendor Company" }).last().click();
await expect(page.getByRole("dialog", { name: "Add Vendor Company" })).toHaveCount(0);
expect(state.updatedBody).toMatchObject({
name: "Gateway Plumbing",
companyPhone: "(314) 555-0100",
tradeSpecialties: "HVAC, Plumbing",
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();
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 detailDrawer.getByRole("button", { name: "Edit" }).click();
await expect(detailDrawer.getByLabel("Total Jobs")).toHaveValue("72");
await expect(detailDrawer.getByLabel("Total Jobs")).toHaveAttribute("readonly");
await detailDrawer.getByLabel("Notes").fill("Updated in browser E2E");
await detailDrawer.getByRole("switch", { name: "Active" }).click();
await detailDrawer.getByRole("button", { name: "Save" }).click();
await expect(page).toHaveURL(/\/vendors\/edit\/1$/);
await page.getByLabel("Notes").fill("Updated in browser E2E");
await page.getByRole("button", { name: "Save" }).click();
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 }) => {
@ -364,15 +456,11 @@ test.describe("Vendor directory prototype parity", () => {
await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click();
const detailDrawer = page.locator(".MuiDrawer-paper").last();
await detailDrawer.getByRole("button", { name: "Edit" }).click();
await expect(detailDrawer.getByRole("button", { name: "Save" })).toBeDisabled();
await detailDrawer.getByRole("switch", { name: "Inactive" }).click();
await detailDrawer.getByRole("button", { name: "Save" }).click();
await expect(page).toHaveURL(/\/vendors\/edit\/1$/);
await page.getByRole("switch", { name: "Inactive" }).first().click();
await page.getByRole("button", { name: "Save" }).click();
await expect.poll(() => state.updatedBody).toEqual({ isActive: true });
await expect(detailDrawer.getByText("Active").first()).toBeVisible();
await detailDrawer.getByRole("button", { name: "Close drawer" }).click();
await page.getByRole("tab", { name: "Active (2)" }).click();
await page.getByRole("tab", { name: "Active (2)", exact: true }).click();
await expect(page.getByText("Gateway Plumbing").first()).toBeVisible();
});
@ -389,13 +477,27 @@ test.describe("Vendor directory prototype parity", () => {
await expect(addButton).toBeFocused();
await addButton.click();
await expect(page.getByRole("button", { name: "Add Vendor" }).last()).toBeDisabled();
await page.getByLabel("Technician Name").fill("Independent Tech");
await expect(page.getByRole("button", { name: "Add Vendor Company" }).last()).toBeDisabled();
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");
expect(state.createdBody?.companyId).toBeNull();
await expect.poll(() => state.createdBody?.name).toBe("Independent Vendor LLC");
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 ({

View file

@ -1,5 +1,5 @@
import { execFileSync, spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
@ -38,7 +38,9 @@ function gitLines(args) {
function governedFiles() {
const tracked = gitLines(["ls-files"]);
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) {

View 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`);
});

View file

@ -126,6 +126,10 @@ export const API_PATHS = {
facets: "Vendor/facets",
deactivationImpact: "deactivation-impact",
},
vendorCompanyRoster: {
base: "vendor-company-roster",
byCompany: (companyId: string | number) => `vendor-company-roster/${companyId}`,
},
calendar: {
getEventsForCalendar: "Calendar/GetEventsForCalendar",
getEventList: "Calendar/GetEventList",

View 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,
};
}

View file

@ -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,
};
}

View 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,
};
}

View 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,
};
}

View 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],
);
}

View 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,
};
}

View file

@ -1,8 +1,5 @@
import { useEffect, useState } from "react";
import { useForm, useWatch } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect } from "react";
import {
Alert,
Box,
Button,
CircularProgress,
@ -13,140 +10,72 @@ import {
Stack,
Typography,
} from "@mui/material";
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 { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets";
import type { VendorFacetCompany } from "@/domain/vendors/types/vendor";
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 VendorCreateModalProps {
open: boolean;
onClose: () => void;
tradeOptions: string[];
}
const defaultValues: VendorFormValues = {
companyName: "",
contactName: "",
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;
export function VendorCreateModal({ open, onClose }: VendorCreateModalProps) {
const form = useVendorRosterForm({ mode: "create", onSuccess: onClose });
const { resetForm } = form;
useEffect(() => {
if (open) {
reset(defaultValues);
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 ?? [];
if (!open) resetForm();
}, [open, resetForm]);
return (
<Dialog
open={open}
onClose={createVendor.isPending ? undefined : onClose}
onClose={form.isSaving ? undefined : onClose}
fullWidth
maxWidth="sm"
maxWidth="md"
aria-labelledby="vendor-create-dialog-title"
slotProps={{ paper: { role: "dialog" } }}
>
<DialogTitle id="vendor-create-dialog-title">
Add Vendor
Add Vendor Company
<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>
</DialogTitle>
<DialogContent>
<Stack spacing={2} className="mt-1">
{Boolean(facetsError) && (
<Alert severity="warning">
Company autocomplete unavailable. You can still type a company manually.
</Alert>
{form.loadErrorMessage !== null && (
<VendorRosterLoadErrorAlert message={form.loadErrorMessage} onRetry={form.retryLoad} />
)}
{Boolean(submitError) && <Alert severity="error">{submitError}</Alert>}
{isLoadingFacets ? (
<CircularProgress size={24} />
) : (
<Box>
<VendorFormFields
control={control}
errors={errors}
companies={companies}
tradeOptions={tradeOptions}
onCompanySelected={onCompanySelected}
{form.conflict !== null && (
<VendorRosterConflictAlert conflict={form.conflict} onReload={form.reload} />
)}
<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>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={createVendor.isPending}>
<Button onClick={onClose} disabled={form.isSaving}>
Cancel
</Button>
<Button
color="primary"
variant="contained"
onClick={handleSubmit(onSubmit)}
disabled={createVendor.isPending || !isFormValid}
type="submit"
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>
</DialogActions>
</Dialog>

View 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 &quot;{target?.companyName}&quot;? 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>
);
}

View file

@ -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>
);
}

View file

@ -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 {
VendorDrawerActions,
VendorDrawerEdit,
VendorDrawerHeader,
VendorDrawerView,
} from "./vendor-detail-drawer-content";
import { useVendorDetailDrawerForm } from "./use-vendor-detail-drawer";
import { useVendorDetail } from "@/domain/vendors/use-cases/use-vendor-detail";
import type { VendorListItem } from "@/domain/vendors/types/vendor";
Alert,
Box,
Button,
Chip,
CircularProgress,
Divider,
Drawer,
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[] {
return (value ?? "")
@ -21,35 +33,266 @@ function splitTrades(value: string | undefined): string[] {
interface VendorDetailDrawerProps {
open: boolean;
vendor: VendorListItem | null;
mode: "view" | "edit";
onClose: () => void;
onModeChange: (mode: "view" | "edit") => void;
tradeOptions: string[];
}
export function VendorDetailDrawer({
open,
function SectionTitle({ children }: { children: ReactNode }) {
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,
mode,
onClose,
onModeChange,
tradeOptions,
}: VendorDetailDrawerProps) {
}: {
roster?: VendorCompanyRoster;
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 companyId = vendor?.companyId;
const hasVendorId = vendorId != null;
const {
data: detail,
data: roster,
isLoading,
error,
} = useVendorDetail(vendorId !== undefined && vendorId !== "" ? String(vendorId) : undefined);
const drawerForm = useVendorDetailDrawerForm({ detail, open, vendorId, onModeChange });
const trades = splitTrades(detail?.tradeSpecialties ?? vendor?.tradeSpecialties);
const mapsUrl = detail?.googleMapsUrl;
const pending = drawerForm.updateVendor.isPending;
} = useVendorCompanyRoster(
hasVendorId ? vendorId : undefined,
!hasVendorId ? (companyId ?? undefined) : undefined,
);
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 (
<Drawer
open={open}
onClose={pending ? undefined : onClose}
onClose={onClose}
anchor="right"
aria-labelledby="vendor-detail-drawer-title"
ModalProps={{ keepMounted: false }}
@ -61,43 +304,20 @@ export function VendorDetailDrawer({
}}
>
<Stack className="flex h-full flex-col">
<VendorDrawerHeader vendor={vendor} mode={mode} pending={pending} onClose={onClose} />
<DrawerHeader roster={roster} vendor={vendor} onClose={onClose} />
{isLoading ? (
<Box className="flex flex-1 items-center justify-center">
<CircularProgress size={28} />
</Box>
) : error ? (
<Alert severity="error" sx={{ m: 2 }}>
{error instanceof Error ? error.message : "Failed to load vendor"}
Failed to load vendor company. Try again.
</Alert>
) : detail ? (
) : roster ? (
<>
{mode === "view" ? (
<VendorDrawerView
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")}
/>
<DrawerBody roster={roster} />
<DrawerActions onEdit={() => navigate(editHref)} />
</>
) : (
<Box className="flex flex-1 items-center justify-center">

View file

@ -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>
);
}

View file

@ -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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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} />;
}

View file

@ -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} />;
}

View file

@ -1,78 +1,21 @@
import { useMemo, useState, type KeyboardEvent, type MouseEvent } from "react";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
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 { useMemo, useState } from "react";
import { Alert, Box, Tab, Tabs } from "@mui/material";
import { VendorCreateModal } from "./_components/vendor-create-modal";
import { VendorDetailDrawer } from "./_components/vendor-detail-drawer";
import { VendorDeactivationDialog } from "./_components/vendor-deactivation-dialog";
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 { 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 { 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 {
category: keyof VendorFilters;
@ -95,210 +38,61 @@ function describeChips(
}
export default function VendorsListPage() {
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 filters = useVendorListState();
const [filtersOpen, setFiltersOpen] = 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 [detailMode, setDetailMode] = useState<"view" | "edit">("view");
const deactivation = useVendorDeactivation();
const debouncedSearch = useDebounce(search, 300);
const deleteVendor = useDeleteVendor();
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: facets } = useVendorFacets(filters.isActive);
const { data, isLoading, isFetching, error } = useVendorsList(filters.listParams);
const { data: activeCountData } = useVendorsList({ isActive: true, page: 1, pageSize: 1 });
const { data: inactiveCountData } = useVendorsList({ isActive: false, page: 1, pageSize: 1 });
const rows = data?.items ?? [];
const totalCount = data?.totalCount ?? 0;
const activeCount = activeCountData?.totalCount ?? 0;
const inactiveCount = inactiveCountData?.totalCount ?? 0;
const globalCount = activeCount + inactiveCount;
const jobBucketLabels = useMemo(
() => new Map((facets?.jobBuckets ?? []).map((bucket) => [bucket.id, bucket.label])),
[facets],
);
const appliedChips = useMemo(
() => 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;
const appliedChips = describeChips(filters.appliedFilters, jobBucketLabels);
return (
<Box className="flex flex-col gap-4 p-4">
<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} 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]"
<VendorsListToolbar
globalCount={activeCount + inactiveCount}
activeCount={activeCount}
isFetching={isFetching}
isLoading={isLoading}
search={filters.search}
onSearchChange={filters.setSearchWithReset}
appliedFilterCount={appliedChips.length}
onOpenFilters={() => setFiltersOpen(true)}
onAddVendor={() => setCreateOpen(true)}
/>
<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
value={isActive ? "active" : "inactive"}
onChange={(_event, value: "active" | "inactive") => {
setIsActive(value === "active");
setPage(0);
}}
value={filters.isActive ? "active" : "inactive"}
onChange={(_event, value: "active" | "inactive") =>
filters.setIsActiveWithReset(value === "active")
}
aria-label="Vendor status"
>
<Tab value="active" label={`Active (${activeCount})`} />
<Tab value="inactive" label={`Inactive (${inactiveCount})`} />
</Tabs>
{appliedChips.length > 0 && (
<Stack direction="row" spacing={1} className="flex flex-wrap items-center">
<Typography variant="caption" sx={{ color: "text.secondary" }}>
Applied:
</Typography>
{appliedChips.map((chip) => (
<Chip
key={`${chip.category}-${chip.value}`}
size="small"
label={chip.label}
onDelete={() => handleRemoveChip(chip)}
aria-label={`Remove filter ${chip.label}`}
<VendorAppliedFiltersBar
chips={appliedChips.map((chip) => ({
key: `${chip.category}-${chip.value}`,
label: chip.label,
onRemove: () => filters.removeFilter(chip.category, chip.value),
}))}
onClearAll={filters.clearFilters}
/>
))}
<Button size="small" onClick={handleClearFilters}>
Clear all
</Button>
</Stack>
)}
{Boolean(error) && (
<Alert severity="error">
@ -306,307 +100,52 @@ export default function VendorsListPage() {
</Alert>
)}
<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>
{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"}
<VendorsTable
rows={rows}
isLoading={isLoading}
showError={Boolean(error)}
onOpenDetail={setDetailTarget}
onOpenDeactivation={deactivation.open}
/>
</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]}
<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}
/>
</Stack>
<VendorCreateModal
open={createOpen}
onClose={() => setCreateOpen(false)}
tradeOptions={tradeOptions}
/>
<VendorCreateModal open={createOpen} onClose={() => setCreateOpen(false)} />
<VendorFiltersDrawer
open={filtersOpen}
onClose={() => setFiltersOpen(false)}
facets={facets}
appliedFilters={appliedFilters}
onApply={handleApplyFilters}
appliedFilters={filters.appliedFilters}
onApply={filters.setAppliedFiltersWithReset}
/>
<VendorDetailDrawer
open={Boolean(detailTarget)}
vendor={detailTarget}
mode={detailMode}
onClose={closeDetail}
onModeChange={setDetailMode}
tradeOptions={tradeOptions}
onClose={() => setDetailTarget(null)}
/>
<Dialog
open={Boolean(deleteTarget)}
onClose={deleteVendor.isPending ? undefined : closeDeactivation}
>
<DialogTitle>Deactivate Vendor</DialogTitle>
<DialogContent>
<Stack spacing={2}>
<DialogContentText>
Deactivate &quot;{deleteTarget?.companyName}&quot;? Existing work-order and audit
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>
<VendorDeactivationDialog
target={deactivation.target}
isLoading={deactivation.isLoading}
isSaving={deactivation.isSaving}
error={deactivation.error}
impact={deactivation.impact}
impactError={deactivation.impactError}
onClose={deactivation.close}
onConfirm={deactivation.confirm}
/>
</Box>
);
}

View file

@ -1 +1,5 @@
export { default } from "./_components/vendor-form";
import VendorRosterPage from "./_components/vendor-roster-page";
export default function NewVendorCompanyPage() {
return <VendorRosterPage />;
}

View 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;
}
},
};

View 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;
}

View file

@ -38,10 +38,11 @@ function readNumber(record: Record<string, unknown>, ...keys: string[]): number
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;
if (typeof value === "string" || typeof value === "number") return value;
return "";
if (typeof value === "string") return value.trim() === "" ? null : value;
if (typeof value === "number" && Number.isFinite(value)) return value;
return null;
}
function readCompanyId(record: Record<string, unknown>): string | number | null {

View 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 };
}

View 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: [],
};

View file

@ -3,7 +3,7 @@ export type VendorPreferredContact = "Phone" | "Email" | "Text";
export const VENDOR_PREFERRED_CONTACT_VALUES: VendorPreferredContact[] = ["Phone", "Email", "Text"];
export interface VendorListItem {
id: string | number;
id: string | number | null;
companyId: string | number | null;
companyName: string;
contactName: string;
@ -109,3 +109,45 @@ export interface VendorDeactivationImpact {
canDeactivate: boolean;
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[];
}

View 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 };

View 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,
});
}

View file

@ -73,6 +73,8 @@ export const queryKeys = {
[...queryKeys.vendors.all, "portalToken", vendorId] as const,
deactivationImpact: (vendorId: string | number) =>
[...queryKeys.vendors.all, "deactivationImpact", vendorId] as const,
roster: (by: "vendorId" | "companyId", id: string | number) =>
[...queryKeys.vendors.all, "roster", by, id] as const,
},
calendar: {
all: ["calendar"] as const,

View 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)}`;
}

View 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");
});
});

View 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();
});
});

View file

@ -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");
});
});

View file

@ -2,19 +2,26 @@ import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import VendorsListPage from "@/app/(protected)/vendors/index";
import type { VendorListItem } from "@/domain/vendors/types/vendor";
import { renderWithProviders } from "@/test/test-utils";
const useVendorsList = vi.fn();
const useVendorDetail = vi.fn();
const useVendorCompanyRoster = vi.fn();
const useVendorDeactivationImpact = 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", () => ({
useVendorsList: (...args: unknown[]) => useVendorsList(...args),
}));
vi.mock("@/domain/vendors/use-cases/use-vendor-detail", () => ({
useVendorDetail: (...args: unknown[]) => useVendorDetail(...args),
vi.mock("@/domain/vendors/use-cases/use-vendor-company-roster", () => ({
useVendorCompanyRoster: (...args: unknown[]) => useVendorCompanyRoster(...args),
}));
vi.mock("@/domain/vendors/use-cases/use-vendor-deactivation-impact", () => ({
@ -29,13 +36,17 @@ vi.mock("@/hooks/use-debounce", () => ({
useDebounce: (value: string) => value,
}));
const activeVendor = {
const activeVendor: VendorListItem = {
id: 1,
companyId: "co-1",
companyName: "Gateway Plumbing",
contactName: "Adam Whyte",
email: "dispatch@gatewayplumbing.com",
phone: "(314) 555-0198",
companyPhone: "(314) 555-0100",
googleMapsUrl: "",
notes: "",
totalJobs: 0,
city: "St. Louis",
state: "MO",
tradeSpecialties: "Backflow Preventers",
@ -50,7 +61,7 @@ const inactiveVendor = {
isActive: false,
};
function result(items: (typeof activeVendor)[], totalCount: number) {
function result(items: VendorListItem[], totalCount: number) {
return {
data: { items, totalCount, page: 1, pageSize: 12 },
isLoading: false,
@ -60,7 +71,7 @@ function result(items: (typeof activeVendor)[], totalCount: number) {
}
function setupDefaults() {
useVendorDetail.mockReturnValue({ data: undefined, isLoading: false, error: null });
useVendorCompanyRoster.mockReturnValue({ data: undefined, isLoading: false, error: null });
useVendorDeactivationImpact.mockReturnValue({
data: undefined,
isLoading: false,
@ -103,13 +114,40 @@ describe("VendorsListPage", () => {
renderWithProviders(<VendorsListPage />, { route: "/vendors", withAuth: false });
expect(screen.getByText(/25 active technicians/)).toBeInTheDocument();
expect(screen.queryByText(/3 active technicians/)).not.toBeInTheDocument();
expect(screen.getByText(/25 active vendor entries/)).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 () => {
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 }) => {
if (params.pageSize === 1) return result([], 1);
return params.isActive ? result([activeVendor], 1) : result([], 0);
@ -160,4 +198,38 @@ describe("VendorsListPage", () => {
expect(screen.getByRole("button", { name: /^Deactivate$/ })).toBeDisabled();
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");
});
});

View 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);
});
});

View 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");
});
});

View 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([]);
});
});

View 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();
});
});