mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
feat(vendors): align directory UI with prototype
This commit is contained in:
parent
042b9db2ac
commit
a6054279a2
21 changed files with 2441 additions and 192 deletions
335
e2e/vendors/vendors.spec.ts
vendored
Normal file
335
e2e/vendors/vendors.spec.ts
vendored
Normal file
|
|
@ -0,0 +1,335 @@
|
||||||
|
import { expect, test, type Page, type Route } from "@playwright/test";
|
||||||
|
|
||||||
|
const TEST_USER = {
|
||||||
|
token: "vendor-e2e-token",
|
||||||
|
expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||||
|
email: "admin@seahavenind.com",
|
||||||
|
userRoles: "Admin",
|
||||||
|
phoneNumber: "5551234567",
|
||||||
|
fullname: "Vendor E2E Admin",
|
||||||
|
id: "1",
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeVendors = [
|
||||||
|
{
|
||||||
|
Id: 1,
|
||||||
|
CompanyName: "Gateway Plumbing",
|
||||||
|
ContactName: "Adam Whyte",
|
||||||
|
Email: "dispatch@gateway.test",
|
||||||
|
Phone: "314-555-0198",
|
||||||
|
CompanyPhone: "314-555-0100",
|
||||||
|
Address: "1 Market St",
|
||||||
|
City: "St. Louis",
|
||||||
|
State: "MO",
|
||||||
|
Zip: "63101",
|
||||||
|
TradeSpecialties: "Plumbing, Backflow Preventers",
|
||||||
|
GoogleMapsUrl: "https://maps.google.com/gateway",
|
||||||
|
Notes: "Preferred after-hours vendor",
|
||||||
|
TotalJobs: 72,
|
||||||
|
IsActive: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Id: 2,
|
||||||
|
CompanyName: "Metro HVAC",
|
||||||
|
ContactName: "Maya Chen",
|
||||||
|
Email: "maya@metro.test",
|
||||||
|
Phone: "314-555-0112",
|
||||||
|
CompanyPhone: "314-555-0110",
|
||||||
|
Address: "2 Pine St",
|
||||||
|
City: "Clayton",
|
||||||
|
State: "MO",
|
||||||
|
Zip: "63105",
|
||||||
|
TradeSpecialties: "HVAC",
|
||||||
|
GoogleMapsUrl: "",
|
||||||
|
Notes: "",
|
||||||
|
TotalJobs: 18,
|
||||||
|
IsActive: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const inactiveVendors = [
|
||||||
|
{
|
||||||
|
...activeVendors[0],
|
||||||
|
Id: 3,
|
||||||
|
CompanyName: "Drain Cleaning Bros",
|
||||||
|
ContactName: "Drew Banks",
|
||||||
|
TotalJobs: 151,
|
||||||
|
IsActive: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
interface MockState {
|
||||||
|
listUrls: string[];
|
||||||
|
createdBody?: Record<string, unknown>;
|
||||||
|
updatedBody?: Record<string, unknown>;
|
||||||
|
deletedId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fulfillJson(route: Route, body: unknown, status = 200) {
|
||||||
|
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function captureVisual(page: Page, path: string) {
|
||||||
|
if (!process.env.VENDOR_VISUAL_CAPTURE) return;
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
await page.screenshot({ path, fullPage: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mockVendorApi(page: Page, options: { listError?: boolean } = {}) {
|
||||||
|
const state: MockState = { listUrls: [] };
|
||||||
|
|
||||||
|
await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER);
|
||||||
|
|
||||||
|
await page.route("**/api/DropdownOptions/ByCategory/Trade*", (route) =>
|
||||||
|
fulfillJson(route, {
|
||||||
|
data: [
|
||||||
|
{ id: 1, value: "Plumbing" },
|
||||||
|
{ id: 2, value: "HVAC" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route("**/api/Vendor/facets*", (route) =>
|
||||||
|
fulfillJson(route, {
|
||||||
|
data: {
|
||||||
|
companies: activeVendors.map((vendor) => ({
|
||||||
|
name: vendor.CompanyName,
|
||||||
|
companyPhone: vendor.CompanyPhone,
|
||||||
|
email: vendor.Email,
|
||||||
|
address: vendor.Address,
|
||||||
|
city: vendor.City,
|
||||||
|
state: vendor.State,
|
||||||
|
zip: vendor.Zip,
|
||||||
|
googleMapsUrl: vendor.GoogleMapsUrl,
|
||||||
|
})),
|
||||||
|
trades: ["Plumbing", "Backflow Preventers", "HVAC"],
|
||||||
|
locations: [
|
||||||
|
{ city: "St. Louis", state: "MO", label: "St. Louis, MO" },
|
||||||
|
{ city: "Clayton", state: "MO", label: "Clayton, MO" },
|
||||||
|
],
|
||||||
|
jobBuckets: [
|
||||||
|
{ id: "under-50", label: "Under 50" },
|
||||||
|
{ id: "50-99", label: "50–99" },
|
||||||
|
{ id: "100-149", label: "100–149" },
|
||||||
|
{ id: "150-plus", label: "150+" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route("**/api/Vendor/GetVendorList*", async (route) => {
|
||||||
|
const url = new URL(route.request().url());
|
||||||
|
state.listUrls.push(url.toString());
|
||||||
|
if (options.listError && url.searchParams.get("pageSize") !== "1") {
|
||||||
|
await fulfillJson(route, { message: "Vendor directory unavailable" }, 500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isActive = url.searchParams.get("isActive") !== "false";
|
||||||
|
const source = isActive ? activeVendors : inactiveVendors;
|
||||||
|
const search = (url.searchParams.get("search") ?? "").toLowerCase();
|
||||||
|
const company = url.searchParams.get("companies[0]");
|
||||||
|
const filtered = source.filter(
|
||||||
|
(vendor) =>
|
||||||
|
(!search ||
|
||||||
|
`${vendor.CompanyName} ${vendor.ContactName} ${vendor.TradeSpecialties}`
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(search)) &&
|
||||||
|
(!company || vendor.CompanyName === company),
|
||||||
|
);
|
||||||
|
const isCount = url.searchParams.get("pageSize") === "1";
|
||||||
|
await fulfillJson(route, {
|
||||||
|
Data: isCount ? filtered.slice(0, 1) : filtered,
|
||||||
|
TotalCount: filtered.length,
|
||||||
|
PageNumber: 1,
|
||||||
|
PageSize: Number(url.searchParams.get("pageSize") ?? 12),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route(/\/api\/vendors\/\d+$/, async (route) => {
|
||||||
|
const id = route.request().url().split("/").pop() ?? "";
|
||||||
|
if (route.request().method() === "PUT") {
|
||||||
|
state.updatedBody = route.request().postDataJSON();
|
||||||
|
await fulfillJson(route, {
|
||||||
|
data: { ...activeVendors[0], ...state.updatedBody, Id: Number(id) },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (route.request().method() === "DELETE") {
|
||||||
|
state.deletedId = id;
|
||||||
|
await fulfillJson(route, { status: "200" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const vendor = [...activeVendors, ...inactiveVendors].find((item) => String(item.Id) === id);
|
||||||
|
await fulfillJson(route, { data: vendor });
|
||||||
|
});
|
||||||
|
await page.route(/\/api\/vendors\/?$/, async (route) => {
|
||||||
|
if (route.request().method() !== "POST") {
|
||||||
|
await route.fallback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.createdBody = route.request().postDataJSON();
|
||||||
|
await fulfillJson(route, { data: { ...state.createdBody, id: 99, totalJobs: 0 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("Vendor directory prototype parity", () => {
|
||||||
|
test("covers status tabs, global counts, search, facets, table fields, and filtered footer", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const state = await mockVendorApi(page);
|
||||||
|
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.getByRole("tab", { name: "Active (2)" })).toHaveAttribute(
|
||||||
|
"aria-selected",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
for (const heading of [
|
||||||
|
"Company",
|
||||||
|
"Technician",
|
||||||
|
"Location",
|
||||||
|
"Trade",
|
||||||
|
"Phone",
|
||||||
|
"Email",
|
||||||
|
"Total Jobs",
|
||||||
|
"Status",
|
||||||
|
]) {
|
||||||
|
await expect(page.getByRole("columnheader", { name: heading })).toBeVisible();
|
||||||
|
}
|
||||||
|
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 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();
|
||||||
|
expect(state.listUrls.some((url) => new URL(url).searchParams.get("search") === "metro")).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
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();
|
||||||
|
await captureVisual(page, "/tmp/seahaven-vendor-implemented-filters.png");
|
||||||
|
await page.getByRole("button", { name: /Company/ }).click();
|
||||||
|
await page.getByPlaceholder("All companies").click();
|
||||||
|
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();
|
||||||
|
expect(
|
||||||
|
state.listUrls.some(
|
||||||
|
(url) => new URL(url).searchParams.get("companies[0]") === "Gateway Plumbing",
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Deactivate vendor" }).first().click();
|
||||||
|
await expect(page.getByRole("dialog", { name: "Deactivate Vendor" })).toContainText(
|
||||||
|
"history will be preserved",
|
||||||
|
);
|
||||||
|
await page.getByRole("button", { name: "Deactivate", exact: true }).click();
|
||||||
|
await expect.poll(() => state.deletedId).toBe("1");
|
||||||
|
|
||||||
|
await page.getByRole("tab", { name: "Inactive (1)" }).click();
|
||||||
|
await expect(page.getByText("No vendors match the current filters.")).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "Clear all" }).click();
|
||||||
|
await expect(page.getByText("Drain Cleaning Bros")).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "Deactivate vendor" })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("creates with company autofill, then views and edits every detail field", 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 captureVisual(page, "/tmp/seahaven-vendor-implemented-add.png");
|
||||||
|
await page.getByRole("button", { name: "Add Vendor" }).last().click();
|
||||||
|
await expect(page.getByText("Technician name is required")).toBeVisible();
|
||||||
|
await expect(page.getByText("Company is required")).toBeVisible();
|
||||||
|
|
||||||
|
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.getByLabel("Email")).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 page.getByRole("button", { name: "Move HVAC up" }).click();
|
||||||
|
await page.getByLabel("Notes").fill("Created in browser E2E");
|
||||||
|
await page.getByRole("button", { name: "Add Vendor" }).last().click();
|
||||||
|
await expect(page.getByRole("dialog", { name: "Add Vendor" })).toHaveCount(0);
|
||||||
|
expect(state.createdBody).toMatchObject({
|
||||||
|
companyName: "Gateway Plumbing",
|
||||||
|
contactName: "New Technician",
|
||||||
|
companyPhone: "314-555-0100",
|
||||||
|
tradeSpecialties: "HVAC, Plumbing",
|
||||||
|
notes: "Created in browser E2E",
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click();
|
||||||
|
const detailDrawer = page.locator(".MuiDrawer-paper").last();
|
||||||
|
await expect(detailDrawer.getByRole("heading", { name: "Gateway Plumbing" })).toBeVisible();
|
||||||
|
await expect(detailDrawer.getByText("Adam Whyte")).toBeVisible();
|
||||||
|
await expect(detailDrawer.getByText("72")).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
detailDrawer.getByRole("link", { name: "Open in Google Maps" }).first(),
|
||||||
|
).toHaveAttribute("target", "_blank");
|
||||||
|
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.poll(() => state.updatedBody?.notes).toBe("Updated in browser E2E");
|
||||||
|
expect(state.updatedBody?.isActive).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("supports keyboard dismissal and free-text company creation", async ({ page }) => {
|
||||||
|
const state = await mockVendorApi(page);
|
||||||
|
await page.goto("/vendors");
|
||||||
|
|
||||||
|
const addButton = page.getByRole("button", { name: "+ Add Vendor" });
|
||||||
|
await addButton.focus();
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toBeVisible();
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toHaveCount(0);
|
||||||
|
await expect(addButton).toBeFocused();
|
||||||
|
|
||||||
|
await addButton.click();
|
||||||
|
await page.getByLabel("Technician Name").fill("Independent Tech");
|
||||||
|
await page.getByRole("combobox", { name: "Company" }).fill("Independent Vendor LLC");
|
||||||
|
await page.getByRole("button", { name: "Add Vendor" }).last().click();
|
||||||
|
|
||||||
|
await expect.poll(() => state.createdBody?.companyName).toBe("Independent Vendor LLC");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows API failure and keeps drawers usable at a mobile viewport", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await mockVendorApi(page, { listError: true });
|
||||||
|
await page.goto("/vendors");
|
||||||
|
|
||||||
|
await expect(page.getByRole("main").getByRole("alert")).toContainText(
|
||||||
|
/Vendor directory unavailable|failed|server error/i,
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
);
|
||||||
|
await page.getByRole("button", { name: "Open vendor filters" }).click();
|
||||||
|
const filterDrawer = page.locator(".MuiDrawer-paper").filter({ hasText: "Total Jobs" });
|
||||||
|
await expect(filterDrawer).toBeVisible();
|
||||||
|
const box = await filterDrawer.boundingBox();
|
||||||
|
expect(box?.width).toBeLessThanOrEqual(391);
|
||||||
|
await captureVisual(page, "/tmp/seahaven-vendor-implemented-mobile.png");
|
||||||
|
await page.getByRole("button", { name: "Close filters" }).click();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -109,6 +109,7 @@ export const API_PATHS = {
|
||||||
update: "Vendor/Update",
|
update: "Vendor/Update",
|
||||||
delete: "Vendor/Delete",
|
delete: "Vendor/Delete",
|
||||||
dropdown: "Vendor/Dropdown",
|
dropdown: "Vendor/Dropdown",
|
||||||
|
facets: "Vendor/facets",
|
||||||
},
|
},
|
||||||
calendar: {
|
calendar: {
|
||||||
getEventsForCalendar: "Calendar/GetEventsForCalendar",
|
getEventsForCalendar: "Calendar/GetEventsForCalendar",
|
||||||
|
|
|
||||||
146
src/app/(protected)/vendors/_components/vendor-create-modal.tsx
vendored
Normal file
146
src/app/(protected)/vendors/_components/vendor-create-modal.tsx
vendored
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
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";
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<VendorFormValues>({
|
||||||
|
resolver: zodResolver(vendorSchema),
|
||||||
|
defaultValues,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
reset(defaultValues);
|
||||||
|
setSubmitError(null);
|
||||||
|
}
|
||||||
|
}, [open, reset]);
|
||||||
|
|
||||||
|
const onCompanySelected = (company: VendorFacetCompany | null) => {
|
||||||
|
if (!company) return;
|
||||||
|
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 onSubmit = (values: VendorFormValues) => {
|
||||||
|
setSubmitError(null);
|
||||||
|
createVendor.mutate(values, {
|
||||||
|
onSuccess: () => {
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
setSubmitError(error.message || "Failed to create vendor");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const companies = facets?.companies ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={createVendor.isPending ? undefined : onClose}
|
||||||
|
fullWidth
|
||||||
|
maxWidth="sm"
|
||||||
|
aria-labelledby="vendor-create-dialog-title"
|
||||||
|
slotProps={{ paper: { role: "dialog" } }}
|
||||||
|
>
|
||||||
|
<DialogTitle id="vendor-create-dialog-title">
|
||||||
|
Add Vendor
|
||||||
|
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.5 }}>
|
||||||
|
Register a technician and their vendor company.
|
||||||
|
</Typography>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Stack spacing={2} className="mt-1">
|
||||||
|
{facetsError && (
|
||||||
|
<Alert severity="warning">
|
||||||
|
Company autocomplete unavailable. You can still type a company manually.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{submitError && <Alert severity="error">{submitError}</Alert>}
|
||||||
|
{isLoadingFacets ? (
|
||||||
|
<CircularProgress size={24} />
|
||||||
|
) : (
|
||||||
|
<Box>
|
||||||
|
<VendorFormFields
|
||||||
|
control={control}
|
||||||
|
errors={errors}
|
||||||
|
companies={companies}
|
||||||
|
tradeOptions={tradeOptions}
|
||||||
|
onCompanySelected={onCompanySelected}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={onClose} disabled={createVendor.isPending}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
variant="contained"
|
||||||
|
onClick={handleSubmit(onSubmit)}
|
||||||
|
disabled={createVendor.isPending}
|
||||||
|
>
|
||||||
|
{createVendor.isPending ? "Saving..." : "Add Vendor"}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
466
src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx
vendored
Normal file
466
src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx
vendored
Normal file
|
|
@ -0,0 +1,466 @@
|
||||||
|
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||||
|
import { useForm, Controller } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
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,
|
||||||
|
CircularProgress,
|
||||||
|
Drawer,
|
||||||
|
FormControlLabel,
|
||||||
|
IconButton,
|
||||||
|
Link,
|
||||||
|
Stack,
|
||||||
|
Switch,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { VendorFormFields } from "./vendor-form-fields";
|
||||||
|
import { vendorSchema, type VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
||||||
|
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 type { Vendor, VendorListItem } from "@/domain/vendors/types/vendor";
|
||||||
|
|
||||||
|
const DRAWER_WIDTH = 420;
|
||||||
|
|
||||||
|
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 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VendorDetailDrawerProps {
|
||||||
|
open: boolean;
|
||||||
|
vendor: VendorListItem | null;
|
||||||
|
mode: "view" | "edit";
|
||||||
|
onClose: () => void;
|
||||||
|
onModeChange: (mode: "view" | "edit") => void;
|
||||||
|
tradeOptions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VendorDetailDrawer({
|
||||||
|
open,
|
||||||
|
vendor,
|
||||||
|
mode,
|
||||||
|
onClose,
|
||||||
|
onModeChange,
|
||||||
|
tradeOptions,
|
||||||
|
}: VendorDetailDrawerProps) {
|
||||||
|
const vendorId = vendor?.id;
|
||||||
|
const { data: facets } = useVendorFacets();
|
||||||
|
const {
|
||||||
|
data: detail,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
} = useVendorDetail(vendorId !== undefined && vendorId !== "" ? String(vendorId) : undefined);
|
||||||
|
const updateVendor = useUpdateVendor();
|
||||||
|
|
||||||
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
|
const [original, setOriginal] = useState<VendorFormValues | null>(null);
|
||||||
|
|
||||||
|
const companies = useMemo(() => facets?.companies ?? [], [facets]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
setValue,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<VendorFormValues>({
|
||||||
|
resolver: zodResolver(vendorSchema),
|
||||||
|
mode: "onTouched",
|
||||||
|
defaultValues: {
|
||||||
|
companyName: "",
|
||||||
|
contactName: "",
|
||||||
|
email: "",
|
||||||
|
phone: "",
|
||||||
|
companyPhone: "",
|
||||||
|
googleMapsUrl: "",
|
||||||
|
notes: "",
|
||||||
|
totalJobs: 0,
|
||||||
|
address: "",
|
||||||
|
city: "",
|
||||||
|
state: "",
|
||||||
|
zip: "",
|
||||||
|
tradeSpecialties: "",
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && detail) {
|
||||||
|
const values = toFormValues(detail);
|
||||||
|
reset(values);
|
||||||
|
setOriginal(values);
|
||||||
|
setSubmitError(null);
|
||||||
|
}
|
||||||
|
if (!open) {
|
||||||
|
setOriginal(null);
|
||||||
|
setSubmitError(null);
|
||||||
|
}
|
||||||
|
}, [open, detail, reset]);
|
||||||
|
|
||||||
|
const onCompanySelected = (company: (typeof companies)[number] | null) => {
|
||||||
|
if (!company) return;
|
||||||
|
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 handleCancel = () => {
|
||||||
|
if (original) {
|
||||||
|
reset(original);
|
||||||
|
}
|
||||||
|
setSubmitError(null);
|
||||||
|
onModeChange("view");
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = (values: VendorFormValues) => {
|
||||||
|
if (vendorId === undefined || vendorId === null || vendorId === "") return;
|
||||||
|
setSubmitError(null);
|
||||||
|
updateVendor.mutate(
|
||||||
|
{ id: vendorId, values },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
onModeChange("view");
|
||||||
|
},
|
||||||
|
onError: (err: Error) => {
|
||||||
|
setSubmitError(err.message || "Failed to save vendor");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const trades = splitTrades(detail?.tradeSpecialties ?? vendor?.tradeSpecialties);
|
||||||
|
const mapsUrl = detail?.googleMapsUrl;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
open={open}
|
||||||
|
onClose={updateVendor.isPending ? undefined : onClose}
|
||||||
|
anchor="right"
|
||||||
|
aria-labelledby="vendor-detail-drawer-title"
|
||||||
|
ModalProps={{ keepMounted: false }}
|
||||||
|
sx={{
|
||||||
|
"& .MuiDrawer-paper": {
|
||||||
|
width: { xs: "100%", sm: DRAWER_WIDTH },
|
||||||
|
maxWidth: "100%",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack className="flex h-full flex-col">
|
||||||
|
<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={updateVendor.isPending}
|
||||||
|
>
|
||||||
|
<CloseIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{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"}
|
||||||
|
</Alert>
|
||||||
|
) : detail ? (
|
||||||
|
<>
|
||||||
|
{mode === "view" ? (
|
||||||
|
<Stack spacing={3} className="flex-1 overflow-y-auto" sx={{ px: 3, py: 3 }}>
|
||||||
|
{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"}
|
||||||
|
/>
|
||||||
|
{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(", ")}
|
||||||
|
/>
|
||||||
|
{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>
|
||||||
|
|
||||||
|
<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, idx) => (
|
||||||
|
<Chip
|
||||||
|
key={`${trade}-${idx}`}
|
||||||
|
size="small"
|
||||||
|
label={idx === 0 ? `${trade} (primary)` : trade}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</DetailSection>
|
||||||
|
|
||||||
|
<DetailSection title="Activity">
|
||||||
|
<DetailField label="Total Jobs" value={String(detail.totalJobs ?? 0)} />
|
||||||
|
</DetailSection>
|
||||||
|
|
||||||
|
{detail.notes && (
|
||||||
|
<DetailSection title="Notes">
|
||||||
|
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>
|
||||||
|
{detail.notes}
|
||||||
|
</Typography>
|
||||||
|
</DetailSection>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Box
|
||||||
|
component="form"
|
||||||
|
id="vendor-edit-form"
|
||||||
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
|
className="flex flex-1 flex-col overflow-y-auto"
|
||||||
|
sx={{ px: 3, py: 3 }}
|
||||||
|
>
|
||||||
|
<Stack spacing={3}>
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
spacing={2}
|
||||||
|
sx={{
|
||||||
|
px: 3,
|
||||||
|
py: 2,
|
||||||
|
borderTop: (theme) => `1px solid ${theme.palette.divider}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mode === "view" ? (
|
||||||
|
<>
|
||||||
|
{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={() => onModeChange("edit")}
|
||||||
|
startIcon={<EditOutlinedIcon />}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={handleCancel}
|
||||||
|
disabled={updateVendor.isPending}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
type="submit"
|
||||||
|
form="vendor-edit-form"
|
||||||
|
disabled={updateVendor.isPending}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
{updateVendor.isPending ? "Saving..." : "Save"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Box className="flex flex-1 items-center justify-center">
|
||||||
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||||
|
No vendor selected.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
316
src/app/(protected)/vendors/_components/vendor-filters-drawer.tsx
vendored
Normal file
316
src/app/(protected)/vendors/_components/vendor-filters-drawer.tsx
vendored
Normal file
|
|
@ -0,0 +1,316 @@
|
||||||
|
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||||
|
import BusinessOutlinedIcon from "@mui/icons-material/BusinessOutlined";
|
||||||
|
import BuildOutlinedIcon from "@mui/icons-material/BuildOutlined";
|
||||||
|
import CloseIcon from "@mui/icons-material/Close";
|
||||||
|
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||||
|
import LocationOnOutlinedIcon from "@mui/icons-material/LocationOnOutlined";
|
||||||
|
import TagOutlinedIcon from "@mui/icons-material/TagOutlined";
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionDetails,
|
||||||
|
AccordionSummary,
|
||||||
|
Autocomplete,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Chip,
|
||||||
|
Drawer,
|
||||||
|
IconButton,
|
||||||
|
Stack,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import type {
|
||||||
|
VendorFacets,
|
||||||
|
VendorFacetCompany,
|
||||||
|
VendorFacetJobBucket,
|
||||||
|
VendorFacetLocation,
|
||||||
|
} from "@/domain/vendors/types/vendor";
|
||||||
|
import { emptyVendorFilters, type VendorFilters } from "./vendor-filters";
|
||||||
|
|
||||||
|
interface VendorFiltersDrawerProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
facets: VendorFacets | undefined;
|
||||||
|
appliedFilters: VendorFilters;
|
||||||
|
onApply: (filters: VendorFilters) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DRAWER_WIDTH = 360;
|
||||||
|
|
||||||
|
function FilterSection({
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Accordion disableGutters elevation={0} square sx={{ borderBottom: 1, borderColor: "divider" }}>
|
||||||
|
<AccordionSummary expandIcon={<ExpandMoreIcon />} aria-controls={`${title}-filters-content`}>
|
||||||
|
<Stack direction="row" spacing={1.5} sx={{ alignItems: "center" }}>
|
||||||
|
<Box sx={{ color: "primary.main", display: "flex" }}>{icon}</Box>
|
||||||
|
<Typography variant="subtitle1" className="font-semibold">
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</AccordionSummary>
|
||||||
|
<AccordionDetails id={`${title}-filters-content`} sx={{ pt: 0, pb: 2.5 }}>
|
||||||
|
{children}
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VendorFiltersDrawer({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
facets,
|
||||||
|
appliedFilters,
|
||||||
|
onApply,
|
||||||
|
}: VendorFiltersDrawerProps) {
|
||||||
|
const [draft, setDraft] = useState<VendorFilters>(appliedFilters);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setDraft(appliedFilters);
|
||||||
|
}
|
||||||
|
}, [open, appliedFilters]);
|
||||||
|
|
||||||
|
const companyOptions = useMemo(() => facets?.companies ?? [], [facets]);
|
||||||
|
const tradeOptions = useMemo(() => facets?.trades ?? [], [facets]);
|
||||||
|
const locationOptions = useMemo(() => facets?.locations ?? [], [facets]);
|
||||||
|
const jobBucketOptions = useMemo(() => facets?.jobBuckets ?? [], [facets]);
|
||||||
|
|
||||||
|
const activeCount =
|
||||||
|
draft.companies.length + draft.trades.length + draft.locations.length + draft.jobBuckets.length;
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
setDraft(emptyVendorFilters);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApply = () => {
|
||||||
|
onApply(draft);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
anchor="right"
|
||||||
|
aria-labelledby="vendor-filters-drawer-title"
|
||||||
|
ModalProps={{ keepMounted: false }}
|
||||||
|
sx={{
|
||||||
|
"& .MuiDrawer-paper": {
|
||||||
|
width: { xs: "100%", sm: DRAWER_WIDTH },
|
||||||
|
maxWidth: "100%",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack className="flex h-full flex-col">
|
||||||
|
<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} sx={{ alignItems: "center" }}>
|
||||||
|
<Typography id="vendor-filters-drawer-title" variant="h6" component="h2">
|
||||||
|
Filters
|
||||||
|
</Typography>
|
||||||
|
{activeCount > 0 && <Chip size="small" color="primary" label={activeCount} />}
|
||||||
|
</Stack>
|
||||||
|
<IconButton aria-label="Close filters" onClick={onClose} size="small">
|
||||||
|
<CloseIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Box className="flex-1 overflow-y-auto" sx={{ px: 2.5 }}>
|
||||||
|
<FilterSection title="Company" icon={<BusinessOutlinedIcon />}>
|
||||||
|
<Stack direction="row" sx={{ alignItems: "center", justifyContent: "space-between" }}>
|
||||||
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||||
|
Select vendor companies
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={() =>
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
companies:
|
||||||
|
current.companies.length === companyOptions.length
|
||||||
|
? []
|
||||||
|
: companyOptions.map((company) => company.name),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{draft.companies.length === companyOptions.length && companyOptions.length > 0
|
||||||
|
? "Clear all"
|
||||||
|
: "Select all"}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
<Autocomplete
|
||||||
|
multiple
|
||||||
|
size="small"
|
||||||
|
options={companyOptions}
|
||||||
|
getOptionLabel={(option) => (typeof option === "string" ? option : option.name)}
|
||||||
|
isOptionEqualToValue={(option, value) =>
|
||||||
|
typeof option === "string" || typeof value === "string"
|
||||||
|
? option === value
|
||||||
|
: option.name === value.name
|
||||||
|
}
|
||||||
|
value={companyOptions.filter((option) => draft.companies.includes(option.name))}
|
||||||
|
onChange={(_event, value) => {
|
||||||
|
const next = (value as (string | VendorFacetCompany)[]).map((entry) =>
|
||||||
|
typeof entry === "string" ? entry : entry.name,
|
||||||
|
);
|
||||||
|
setDraft((current) => ({ ...current, companies: next }));
|
||||||
|
}}
|
||||||
|
renderOption={(props, option, { selected }) => {
|
||||||
|
const { key, ...optionProps } = props;
|
||||||
|
return (
|
||||||
|
<li key={key} {...optionProps}>
|
||||||
|
<Checkbox checked={selected} size="small" sx={{ mr: 1 }} />
|
||||||
|
{option.name}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
renderValue={(value, getItemProps) =>
|
||||||
|
value.map((option, index) => {
|
||||||
|
const label = typeof option === "string" ? option : option.name;
|
||||||
|
const tagProps = getItemProps({ index });
|
||||||
|
const { key, ...chipProps } = tagProps;
|
||||||
|
return <Chip key={key} size="small" label={label} {...chipProps} />;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
placeholder={draft.companies.length === 0 ? "All companies" : ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FilterSection>
|
||||||
|
|
||||||
|
<FilterSection title="Trade" icon={<BuildOutlinedIcon />}>
|
||||||
|
<Autocomplete
|
||||||
|
multiple
|
||||||
|
size="small"
|
||||||
|
options={tradeOptions}
|
||||||
|
value={draft.trades}
|
||||||
|
onChange={(_event, value) =>
|
||||||
|
setDraft((current) => ({ ...current, trades: value as string[] }))
|
||||||
|
}
|
||||||
|
renderValue={(value, getItemProps) =>
|
||||||
|
value.map((option, index) => {
|
||||||
|
const tagProps = getItemProps({ index });
|
||||||
|
const { key, ...chipProps } = tagProps;
|
||||||
|
return <Chip key={key} size="small" label={option} {...chipProps} />;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
placeholder={draft.trades.length === 0 ? "All trades" : ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FilterSection>
|
||||||
|
|
||||||
|
<FilterSection title="Location" icon={<LocationOnOutlinedIcon />}>
|
||||||
|
<Autocomplete
|
||||||
|
multiple
|
||||||
|
size="small"
|
||||||
|
options={locationOptions}
|
||||||
|
getOptionLabel={(option) => (typeof option === "string" ? option : option.label)}
|
||||||
|
isOptionEqualToValue={(option, value) =>
|
||||||
|
typeof option === "string" || typeof value === "string"
|
||||||
|
? option === value
|
||||||
|
: option.label === value.label
|
||||||
|
}
|
||||||
|
value={locationOptions.filter((option) => draft.locations.includes(option.label))}
|
||||||
|
onChange={(_event, value) => {
|
||||||
|
const next = (value as (string | VendorFacetLocation)[]).map((entry) =>
|
||||||
|
typeof entry === "string" ? entry : entry.label,
|
||||||
|
);
|
||||||
|
setDraft((current) => ({ ...current, locations: next }));
|
||||||
|
}}
|
||||||
|
renderValue={(value, getItemProps) =>
|
||||||
|
value.map((option, index) => {
|
||||||
|
const label = typeof option === "string" ? option : option.label;
|
||||||
|
const tagProps = getItemProps({ index });
|
||||||
|
const { key, ...chipProps } = tagProps;
|
||||||
|
return <Chip key={key} size="small" label={label} {...chipProps} />;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
placeholder={draft.locations.length === 0 ? "All locations" : ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FilterSection>
|
||||||
|
|
||||||
|
<FilterSection title="Total Jobs" icon={<TagOutlinedIcon />}>
|
||||||
|
<Autocomplete
|
||||||
|
multiple
|
||||||
|
size="small"
|
||||||
|
options={jobBucketOptions}
|
||||||
|
getOptionLabel={(option) => (typeof option === "string" ? option : option.label)}
|
||||||
|
isOptionEqualToValue={(option, value) =>
|
||||||
|
typeof option === "string" || typeof value === "string"
|
||||||
|
? option === value
|
||||||
|
: option.id === value.id
|
||||||
|
}
|
||||||
|
value={jobBucketOptions.filter((option) => draft.jobBuckets.includes(option.id))}
|
||||||
|
onChange={(_event, value) => {
|
||||||
|
const next = (value as (string | VendorFacetJobBucket)[]).map((entry) =>
|
||||||
|
typeof entry === "string" ? entry : entry.id,
|
||||||
|
);
|
||||||
|
setDraft((current) => ({ ...current, jobBuckets: next }));
|
||||||
|
}}
|
||||||
|
renderValue={(value, getItemProps) =>
|
||||||
|
value.map((option, index) => {
|
||||||
|
const label = typeof option === "string" ? option : option.label;
|
||||||
|
const tagProps = getItemProps({ index });
|
||||||
|
const { key, ...chipProps } = tagProps;
|
||||||
|
return <Chip key={key} size="small" label={label} {...chipProps} />;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
placeholder={draft.jobBuckets.length === 0 ? "All job counts" : ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FilterSection>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
spacing={2}
|
||||||
|
sx={{
|
||||||
|
px: 3,
|
||||||
|
py: 2,
|
||||||
|
borderTop: (theme) => `1px solid ${theme.palette.divider}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button variant="outlined" onClick={handleClear} disabled={activeCount === 0} fullWidth>
|
||||||
|
Clear all
|
||||||
|
</Button>
|
||||||
|
<Button variant="contained" onClick={handleApply} fullWidth>
|
||||||
|
Apply
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
src/app/(protected)/vendors/_components/vendor-filters.ts
vendored
Normal file
13
src/app/(protected)/vendors/_components/vendor-filters.ts
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
export interface VendorFilters {
|
||||||
|
companies: string[];
|
||||||
|
trades: string[];
|
||||||
|
locations: string[];
|
||||||
|
jobBuckets: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const emptyVendorFilters: VendorFilters = {
|
||||||
|
companies: [],
|
||||||
|
trades: [],
|
||||||
|
locations: [],
|
||||||
|
jobBuckets: [],
|
||||||
|
};
|
||||||
345
src/app/(protected)/vendors/_components/vendor-form-fields.tsx
vendored
Normal file
345
src/app/(protected)/vendors/_components/vendor-form-fields.tsx
vendored
Normal file
|
|
@ -0,0 +1,345 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitTrades(value: string | undefined): string[] {
|
||||||
|
return (value ?? "")
|
||||||
|
.split(",")
|
||||||
|
.map((trade) => trade.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinTrades(trades: string[]): string {
|
||||||
|
return trades.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VendorFormFields({
|
||||||
|
control,
|
||||||
|
errors,
|
||||||
|
companies,
|
||||||
|
tradeOptions,
|
||||||
|
onCompanySelected,
|
||||||
|
showTotalJobs = false,
|
||||||
|
}: VendorFormFieldsProps) {
|
||||||
|
const [companyInput, setCompanyInput] = useState("");
|
||||||
|
const [tradeInput, setTradeInput] = useState("");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack spacing={3}>
|
||||||
|
<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" fullWidth />}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" className="font-semibold uppercase">
|
||||||
|
Company
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={2} className="mt-2">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="companyName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<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={companyInput || (typeof field.value === "string" ? field.value : "")}
|
||||||
|
onInputChange={(_event, value, reason) => {
|
||||||
|
if (reason === "input") {
|
||||||
|
setCompanyInput(value);
|
||||||
|
field.onChange(value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onChange={(_event, value, reason) => {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
field.onChange(value);
|
||||||
|
setCompanyInput(value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (value) {
|
||||||
|
field.onChange(value.name);
|
||||||
|
setCompanyInput(value.name);
|
||||||
|
if (reason === "selectOption") {
|
||||||
|
onCompanySelected(value);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
field.onChange("");
|
||||||
|
setCompanyInput("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
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" 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>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<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}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
aria-label={`Move ${trade} up`}
|
||||||
|
disabled={index === 0}
|
||||||
|
onClick={() => move(index, -1)}
|
||||||
|
>
|
||||||
|
<ArrowUpwardIcon fontSize="small" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
aria-label={`Move ${trade} down`}
|
||||||
|
disabled={index === trades.length - 1}
|
||||||
|
onClick={() => move(index, 1)}
|
||||||
|
>
|
||||||
|
<ArrowDownwardIcon fontSize="small" />
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{showTotalJobs && (
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="totalJobs"
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
value={field.value ?? 0}
|
||||||
|
label="Total Jobs"
|
||||||
|
type="number"
|
||||||
|
slotProps={{ input: { readOnly: true } }}
|
||||||
|
helperText="Calculated from work-order history"
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="notes"
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField {...field} label="Notes" multiline minRows={3} fullWidth />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,33 +1,37 @@
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { Controller, useForm, useWatch } from "react-hook-form";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useNavigate, useParams } from "react-router";
|
import { useNavigate, useParams } from "react-router";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
FormControlLabel,
|
FormControlLabel,
|
||||||
FormGroup,
|
|
||||||
FormLabel,
|
|
||||||
Paper,
|
Paper,
|
||||||
Stack,
|
Stack,
|
||||||
TextField,
|
Switch,
|
||||||
Typography,
|
Typography,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { VendorPortalTokenPanel } from "./vendor-portal-token-panel";
|
import { VendorPortalTokenPanel } from "./vendor-portal-token-panel";
|
||||||
|
import { VendorFormFields } from "./vendor-form-fields";
|
||||||
import { vendorSchema, type VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
import { vendorSchema, type VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
||||||
import { useCreateVendor } from "@/domain/vendors/use-cases/use-create-vendor";
|
import { useCreateVendor } from "@/domain/vendors/use-cases/use-create-vendor";
|
||||||
import { useUpdateVendor } from "@/domain/vendors/use-cases/use-update-vendor";
|
import { useUpdateVendor } from "@/domain/vendors/use-cases/use-update-vendor";
|
||||||
import { useVendorDetail } from "@/domain/vendors/use-cases/use-vendor-detail";
|
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 { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
|
||||||
|
import type { VendorFacetCompany } from "@/domain/vendors/types/vendor";
|
||||||
|
|
||||||
const defaultValues: VendorFormValues = {
|
const defaultValues: VendorFormValues = {
|
||||||
companyName: "",
|
companyName: "",
|
||||||
contactName: "",
|
contactName: "",
|
||||||
email: "",
|
email: "",
|
||||||
phone: "",
|
phone: "",
|
||||||
|
companyPhone: "",
|
||||||
|
googleMapsUrl: "",
|
||||||
|
notes: "",
|
||||||
|
totalJobs: 0,
|
||||||
address: "",
|
address: "",
|
||||||
city: "",
|
city: "",
|
||||||
state: "",
|
state: "",
|
||||||
|
|
@ -42,12 +46,12 @@ export default function VendorFormPage() {
|
||||||
const isEdit = Boolean(id);
|
const isEdit = Boolean(id);
|
||||||
|
|
||||||
const { data: vendor, isLoading, error } = useVendorDetail(id);
|
const { data: vendor, isLoading, error } = useVendorDetail(id);
|
||||||
|
const { data: facets } = useVendorFacets();
|
||||||
const { data: trades = [] } = useDropdownOptionsByCategory("Trade");
|
const { data: trades = [] } = useDropdownOptionsByCategory("Trade");
|
||||||
const createVendor = useCreateVendor();
|
const createVendor = useCreateVendor();
|
||||||
const updateVendor = useUpdateVendor();
|
const updateVendor = useUpdateVendor();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
reset,
|
reset,
|
||||||
setValue,
|
setValue,
|
||||||
|
|
@ -65,6 +69,10 @@ export default function VendorFormPage() {
|
||||||
contactName: vendor.contactName,
|
contactName: vendor.contactName,
|
||||||
email: vendor.email,
|
email: vendor.email,
|
||||||
phone: vendor.phone,
|
phone: vendor.phone,
|
||||||
|
companyPhone: vendor.companyPhone,
|
||||||
|
googleMapsUrl: vendor.googleMapsUrl,
|
||||||
|
notes: vendor.notes,
|
||||||
|
totalJobs: vendor.totalJobs,
|
||||||
address: vendor.address,
|
address: vendor.address,
|
||||||
city: vendor.city,
|
city: vendor.city,
|
||||||
state: vendor.state,
|
state: vendor.state,
|
||||||
|
|
@ -75,27 +83,25 @@ export default function VendorFormPage() {
|
||||||
}
|
}
|
||||||
}, [vendor, isEdit, reset]);
|
}, [vendor, isEdit, reset]);
|
||||||
|
|
||||||
const tradeSpecialties = useWatch({ control, name: "tradeSpecialties" });
|
const onCompanySelected = (company: VendorFacetCompany | null) => {
|
||||||
const selectedTrades = (tradeSpecialties || "")
|
if (!company) return;
|
||||||
.split(",")
|
setValue("companyPhone", company.companyPhone);
|
||||||
.map((trade) => trade.trim())
|
setValue("email", company.email);
|
||||||
.filter(Boolean);
|
setValue("address", company.address);
|
||||||
|
setValue("city", company.city);
|
||||||
const toggleTrade = (tradeValue: string) => {
|
setValue("state", company.state);
|
||||||
const updated = selectedTrades.includes(tradeValue)
|
setValue("zip", company.zip);
|
||||||
? selectedTrades.filter((trade) => trade !== tradeValue)
|
setValue("googleMapsUrl", company.googleMapsUrl);
|
||||||
: [...selectedTrades, tradeValue];
|
|
||||||
setValue("tradeSpecialties", updated.join(", "));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const isSaving = createVendor.isPending || updateVendor.isPending;
|
const isSaving = createVendor.isPending || updateVendor.isPending;
|
||||||
|
|
||||||
const onSubmit = (values: VendorFormValues) => {
|
const onSubmit = (values: VendorFormValues) => {
|
||||||
if (isEdit && id) {
|
if (isEdit && id) {
|
||||||
updateVendor.mutate({ id, values });
|
updateVendor.mutate({ id, values }, { onSuccess: () => navigate("/vendors") });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
createVendor.mutate(values);
|
createVendor.mutate(values, { onSuccess: () => navigate("/vendors") });
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isEdit && isLoading) {
|
if (isEdit && isLoading) {
|
||||||
|
|
@ -120,72 +126,26 @@ export default function VendorFormPage() {
|
||||||
|
|
||||||
<Box component="form" onSubmit={handleSubmit(onSubmit)}>
|
<Box component="form" onSubmit={handleSubmit(onSubmit)}>
|
||||||
<Stack spacing={3}>
|
<Stack spacing={3}>
|
||||||
<Typography variant="subtitle2" className="font-semibold uppercase">
|
|
||||||
General Details
|
|
||||||
</Typography>
|
|
||||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
|
||||||
<TextField
|
|
||||||
label="Company Name"
|
|
||||||
required
|
|
||||||
fullWidth
|
|
||||||
error={Boolean(errors.companyName)}
|
|
||||||
helperText={errors.companyName?.message}
|
|
||||||
{...register("companyName")}
|
|
||||||
/>
|
|
||||||
<TextField label="Contact Name" fullWidth {...register("contactName")} />
|
|
||||||
</Stack>
|
|
||||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
|
||||||
<TextField
|
|
||||||
label="Email"
|
|
||||||
required
|
|
||||||
fullWidth
|
|
||||||
type="email"
|
|
||||||
error={Boolean(errors.email)}
|
|
||||||
helperText={errors.email?.message}
|
|
||||||
{...register("email")}
|
|
||||||
/>
|
|
||||||
<TextField label="Phone" fullWidth {...register("phone")} />
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Typography variant="subtitle2" className="font-semibold uppercase">
|
|
||||||
Address
|
|
||||||
</Typography>
|
|
||||||
<TextField label="Address" fullWidth {...register("address")} />
|
|
||||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
|
||||||
<TextField label="City" fullWidth {...register("city")} />
|
|
||||||
<TextField label="State" fullWidth placeholder="e.g., IL" {...register("state")} />
|
|
||||||
<TextField label="Zip" fullWidth {...register("zip")} />
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<FormGroup>
|
|
||||||
<FormLabel component="legend">Trade Specialties</FormLabel>
|
|
||||||
<Box className="mt-2 flex flex-wrap gap-2">
|
|
||||||
{trades.map((trade) => (
|
|
||||||
<FormControlLabel
|
|
||||||
key={String(trade.id)}
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={selectedTrades.includes(trade.value)}
|
|
||||||
onChange={() => toggleTrade(trade.value)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label={trade.value}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</FormGroup>
|
|
||||||
|
|
||||||
<Controller
|
<Controller
|
||||||
name="isActive"
|
name="isActive"
|
||||||
control={control}
|
control={control}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={<Checkbox checked={field.value} onChange={field.onChange} />}
|
control={<Switch checked={Boolean(field.value)} onChange={field.onChange} />}
|
||||||
label="Active"
|
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 && <VendorPortalTokenPanel vendorId={id} />}
|
{isEdit && id && <VendorPortalTokenPanel vendorId={id} />}
|
||||||
|
|
||||||
<Stack
|
<Stack
|
||||||
|
|
|
||||||
455
src/app/(protected)/vendors/index.tsx
vendored
455
src/app/(protected)/vendors/index.tsx
vendored
|
|
@ -1,9 +1,11 @@
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useNavigate } from "react-router";
|
|
||||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
|
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
|
||||||
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
||||||
|
import FilterListIcon from "@mui/icons-material/FilterList";
|
||||||
|
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
|
Avatar,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Chip,
|
Chip,
|
||||||
|
|
@ -14,8 +16,10 @@ import {
|
||||||
DialogContentText,
|
DialogContentText,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
IconButton,
|
IconButton,
|
||||||
|
Link,
|
||||||
Paper,
|
Paper,
|
||||||
Stack,
|
Stack,
|
||||||
|
Tab,
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
TableCell,
|
TableCell,
|
||||||
|
|
@ -23,39 +27,156 @@ import {
|
||||||
TableHead,
|
TableHead,
|
||||||
TablePagination,
|
TablePagination,
|
||||||
TableRow,
|
TableRow,
|
||||||
|
Tabs,
|
||||||
TextField,
|
TextField,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Typography,
|
Typography,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
|
import { VendorCreateModal } from "./_components/vendor-create-modal";
|
||||||
|
import { VendorDetailDrawer } from "./_components/vendor-detail-drawer";
|
||||||
|
import { VendorFiltersDrawer } from "./_components/vendor-filters-drawer";
|
||||||
|
import { emptyVendorFilters, type VendorFilters } from "./_components/vendor-filters";
|
||||||
import type { VendorListItem } from "@/domain/vendors/types/vendor";
|
import type { VendorListItem } from "@/domain/vendors/types/vendor";
|
||||||
import { useDeleteVendor } from "@/domain/vendors/use-cases/use-delete-vendor";
|
import { useDeleteVendor } from "@/domain/vendors/use-cases/use-delete-vendor";
|
||||||
|
import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets";
|
||||||
import { useVendorsList } from "@/domain/vendors/use-cases/use-vendors-list";
|
import { useVendorsList } from "@/domain/vendors/use-cases/use-vendors-list";
|
||||||
|
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
|
||||||
import { useDebounce } from "@/hooks/use-debounce";
|
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;
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeChips(
|
||||||
|
filters: VendorFilters,
|
||||||
|
jobBucketLabels: Map<string, string>,
|
||||||
|
): AppliedChip[] {
|
||||||
|
const chips: AppliedChip[] = [];
|
||||||
|
filters.companies.forEach((value) => chips.push({ category: "companies", value, label: value }));
|
||||||
|
filters.trades.forEach((value) => chips.push({ category: "trades", value, label: value }));
|
||||||
|
filters.locations.forEach((value) => chips.push({ category: "locations", value, label: value }));
|
||||||
|
filters.jobBuckets.forEach((value) =>
|
||||||
|
chips.push({ category: "jobBuckets", value, label: jobBucketLabels.get(value) ?? value }),
|
||||||
|
);
|
||||||
|
return chips;
|
||||||
|
}
|
||||||
|
|
||||||
export default function VendorsListPage() {
|
export default function VendorsListPage() {
|
||||||
const navigate = useNavigate();
|
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
const [pageSize, setPageSize] = useState(12);
|
const [pageSize, setPageSize] = useState(12);
|
||||||
|
const [isActive, setIsActive] = useState(true);
|
||||||
|
const [appliedFilters, setAppliedFilters] = useState<VendorFilters>(emptyVendorFilters);
|
||||||
|
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<VendorListItem | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<VendorListItem | null>(null);
|
||||||
|
const [detailTarget, setDetailTarget] = useState<VendorListItem | null>(null);
|
||||||
|
const [detailMode, setDetailMode] = useState<"view" | "edit">("view");
|
||||||
|
|
||||||
const debouncedSearch = useDebounce(search, 300);
|
const debouncedSearch = useDebounce(search, 300);
|
||||||
const deleteVendor = useDeleteVendor();
|
const deleteVendor = useDeleteVendor();
|
||||||
|
const { data: facets } = useVendorFacets();
|
||||||
|
const { data: trades = [] } = useDropdownOptionsByCategory("Trade");
|
||||||
|
const tradeOptions = useMemo(() => trades.map((trade) => trade.value), [trades]);
|
||||||
|
|
||||||
const listParams = useMemo(
|
const listParams = useMemo(
|
||||||
() => ({ search: debouncedSearch, page: page + 1, pageSize }),
|
() => ({
|
||||||
[debouncedSearch, page, pageSize],
|
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, isLoading, isFetching, error } = useVendorsList(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 rows = data?.items ?? [];
|
||||||
const totalCount = data?.totalCount ?? 0;
|
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 handleDeleteConfirm = () => {
|
const handleDeleteConfirm = () => {
|
||||||
if (!deleteTarget) return;
|
if (!deleteTarget) return;
|
||||||
deleteVendor.mutate(deleteTarget.id, { onSuccess: () => setDeleteTarget(null) });
|
deleteVendor.mutate(deleteTarget.id, { onSuccess: () => setDeleteTarget(null) });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const showLoadingRow = isLoading;
|
||||||
|
const showEmptyRow = !isLoading && !error && rows.length === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className="flex flex-col gap-4 p-4">
|
<Box className="flex flex-col gap-4 p-4">
|
||||||
<Stack
|
<Stack
|
||||||
|
|
@ -66,23 +187,12 @@ export default function VendorsListPage() {
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stack
|
<Stack direction="row" spacing={2} sx={{ alignItems: "baseline" }}>
|
||||||
direction="row"
|
|
||||||
spacing={2}
|
|
||||||
sx={{
|
|
||||||
alignItems: "baseline",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="h5" component="h1">
|
<Typography variant="h5" component="h1">
|
||||||
Vendors
|
Vendors / Technicians
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||||
variant="body2"
|
{globalCount} technicians · {activeCount} active
|
||||||
sx={{
|
|
||||||
color: "text.secondary",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{totalCount} total
|
|
||||||
</Typography>
|
</Typography>
|
||||||
{isFetching && !isLoading && <CircularProgress size={16} />}
|
{isFetching && !isLoading && <CircularProgress size={16} />}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
@ -90,9 +200,7 @@ export default function VendorsListPage() {
|
||||||
<Stack
|
<Stack
|
||||||
direction={{ xs: "column", sm: "row" }}
|
direction={{ xs: "column", sm: "row" }}
|
||||||
spacing={2}
|
spacing={2}
|
||||||
sx={{
|
sx={{ alignItems: { xs: "stretch", sm: "center" } }}
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<TextField
|
<TextField
|
||||||
size="small"
|
size="small"
|
||||||
|
|
@ -101,120 +209,261 @@ export default function VendorsListPage() {
|
||||||
setSearch(e.target.value);
|
setSearch(e.target.value);
|
||||||
setPage(0);
|
setPage(0);
|
||||||
}}
|
}}
|
||||||
placeholder="Search vendors..."
|
placeholder="Search name, company, trade..."
|
||||||
className="min-w-sidebar"
|
className="min-w-[260px]"
|
||||||
/>
|
/>
|
||||||
<Button variant="contained" onClick={() => navigate("/vendors/new")}>
|
<Button
|
||||||
+ New Vendor
|
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>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
value={isActive ? "active" : "inactive"}
|
||||||
|
onChange={(_event, value: "active" | "inactive") => {
|
||||||
|
setIsActive(value === "active");
|
||||||
|
setPage(0);
|
||||||
|
}}
|
||||||
|
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}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<Button size="small" onClick={handleClearFilters}>
|
||||||
|
Clear all
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<Alert severity="error">
|
<Alert severity="error">
|
||||||
{error instanceof Error ? error.message : "Failed to load vendors"}
|
{error instanceof Error ? error.message : "Failed to load vendors"}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
<TableContainer component={Paper} variant="outlined">
|
|
||||||
<Table size="small">
|
<TableContainer component={Paper} variant="outlined" sx={{ overflowX: "auto" }}>
|
||||||
|
<Table size="small" sx={{ minWidth: 960 }}>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell className="font-semibold">Company</TableCell>
|
{TABLE_COLUMNS.map((column) => (
|
||||||
<TableCell className="font-semibold">Contact</TableCell>
|
<TableCell
|
||||||
<TableCell className="font-semibold">Email</TableCell>
|
key={column}
|
||||||
<TableCell className="font-semibold">Phone</TableCell>
|
align={column === "View" || column === "Edit" ? "center" : "left"}
|
||||||
<TableCell className="font-semibold">Trades</TableCell>
|
className="font-semibold"
|
||||||
<TableCell className="font-semibold">Status</TableCell>
|
>
|
||||||
<TableCell align="right" className="font-semibold">
|
{column}
|
||||||
Actions
|
</TableCell>
|
||||||
</TableCell>
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{isLoading ? (
|
{showLoadingRow ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={7} align="center" className="py-10">
|
<TableCell colSpan={TABLE_COLUMNS.length} align="center" className="py-10">
|
||||||
<CircularProgress size={28} />
|
<CircularProgress size={28} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : rows.length === 0 ? (
|
) : showEmptyRow ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={7} align="center" className="py-10">
|
<TableCell colSpan={TABLE_COLUMNS.length} align="center" className="py-10">
|
||||||
<Typography
|
<Typography sx={{ color: "text.secondary" }}>
|
||||||
sx={{
|
No vendors match the current filters.
|
||||||
color: "text.secondary",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
No vendors found
|
|
||||||
</Typography>
|
</Typography>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
rows.map((row) => (
|
rows.map((row) => {
|
||||||
<TableRow key={String(row.id)} hover>
|
const trades = splitTrades(row.tradeSpecialties);
|
||||||
<TableCell>
|
const primaryTrade = trades[0] ?? "—";
|
||||||
<Button
|
const extraTrades = trades.length > 1 ? trades.length - 1 : 0;
|
||||||
variant="text"
|
const locationText = [row.city, row.state].filter(Boolean).join(", ") || "—";
|
||||||
size="small"
|
return (
|
||||||
className="font-semibold"
|
<TableRow key={String(row.id)} hover>
|
||||||
onClick={() => navigate(`/vendors/edit/${row.id}`)}
|
<TableCell>
|
||||||
>
|
<Typography className="font-semibold">{row.companyName || "—"}</Typography>
|
||||||
{row.companyName || "—"}
|
</TableCell>
|
||||||
</Button>
|
<TableCell>
|
||||||
</TableCell>
|
<Stack direction="row" spacing={1.5} sx={{ alignItems: "center" }}>
|
||||||
<TableCell>{row.contactName || "—"}</TableCell>
|
<Avatar
|
||||||
<TableCell>{row.email || "—"}</TableCell>
|
sx={{
|
||||||
<TableCell>{row.phone || "—"}</TableCell>
|
width: 28,
|
||||||
<TableCell>{row.tradeSpecialties || "—"}</TableCell>
|
height: 28,
|
||||||
<TableCell>
|
fontSize: 12,
|
||||||
<Chip
|
backgroundColor: "var(--accent, #1976d2)",
|
||||||
size="small"
|
}}
|
||||||
label={row.isActive ? "Active" : "Inactive"}
|
>
|
||||||
color={row.isActive ? "success" : "default"}
|
{getInitials(row.contactName)}
|
||||||
/>
|
</Avatar>
|
||||||
</TableCell>
|
<Typography variant="body2">{row.contactName || "—"}</Typography>
|
||||||
<TableCell align="right">
|
</Stack>
|
||||||
<Tooltip title="Edit">
|
</TableCell>
|
||||||
<IconButton
|
<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">
|
||||||
|
{row.phone}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{row.email ? (
|
||||||
|
<Link href={`mailto:${row.email}`} underline="hover">
|
||||||
|
{row.email}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{row.totalJobs ?? 0}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip
|
||||||
size="small"
|
size="small"
|
||||||
aria-label="Edit vendor"
|
label={row.isActive ? "Active" : "Inactive"}
|
||||||
onClick={() => navigate(`/vendors/edit/${row.id}`)}
|
color={row.isActive ? "success" : "default"}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="center">
|
||||||
|
<Tooltip title="View details">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label={`View vendor ${row.companyName}`}
|
||||||
|
onClick={() => openDetail(row, "view")}
|
||||||
|
>
|
||||||
|
<VisibilityOutlinedIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="center">
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
spacing={0.5}
|
||||||
|
sx={{ alignItems: "center", justifyContent: "center" }}
|
||||||
>
|
>
|
||||||
<EditOutlinedIcon fontSize="small" />
|
<Tooltip title="Edit">
|
||||||
</IconButton>
|
<IconButton
|
||||||
</Tooltip>
|
size="small"
|
||||||
<Tooltip title="Delete">
|
aria-label={`Edit vendor ${row.companyName}`}
|
||||||
<IconButton
|
onClick={() => openDetail(row, "edit")}
|
||||||
size="small"
|
>
|
||||||
aria-label="Delete vendor"
|
<EditOutlinedIcon fontSize="small" />
|
||||||
onClick={() => setDeleteTarget(row)}
|
</IconButton>
|
||||||
>
|
</Tooltip>
|
||||||
<DeleteOutlineIcon fontSize="small" />
|
{row.isActive && (
|
||||||
</IconButton>
|
<Tooltip title="Deactivate">
|
||||||
</Tooltip>
|
<IconButton
|
||||||
</TableCell>
|
size="small"
|
||||||
</TableRow>
|
aria-label="Deactivate vendor"
|
||||||
))
|
onClick={() => setDeleteTarget(row)}
|
||||||
|
>
|
||||||
|
<DeleteOutlineIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</TableContainer>
|
</TableContainer>
|
||||||
<TablePagination
|
|
||||||
component="div"
|
<Stack
|
||||||
count={totalCount}
|
direction={{ xs: "column", sm: "row" }}
|
||||||
page={page}
|
spacing={2}
|
||||||
onPageChange={(_e, nextPage) => setPage(nextPage)}
|
sx={{
|
||||||
rowsPerPage={pageSize}
|
alignItems: { xs: "stretch", sm: "center" },
|
||||||
onRowsPerPageChange={(e) => {
|
justifyContent: "space-between",
|
||||||
setPageSize(Number(e.target.value));
|
|
||||||
setPage(0);
|
|
||||||
}}
|
}}
|
||||||
rowsPerPageOptions={[12, 24, 48]}
|
>
|
||||||
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||||
|
{rows.length} {isActive ? "active" : "inactive"}{" "}
|
||||||
|
{rows.length === 1 ? "technician" : "technicians"}
|
||||||
|
{(appliedChips.length > 0 || debouncedSearch) && " · filtered"}
|
||||||
|
{` · ${activeCount} active · ${inactiveCount} inactive`}
|
||||||
|
</Typography>
|
||||||
|
<TablePagination
|
||||||
|
component="div"
|
||||||
|
count={totalCount}
|
||||||
|
page={page}
|
||||||
|
onPageChange={(_e, nextPage) => setPage(nextPage)}
|
||||||
|
rowsPerPage={pageSize}
|
||||||
|
onRowsPerPageChange={(e) => {
|
||||||
|
setPageSize(Number(e.target.value));
|
||||||
|
setPage(0);
|
||||||
|
}}
|
||||||
|
rowsPerPageOptions={[12, 24, 48]}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<VendorCreateModal
|
||||||
|
open={createOpen}
|
||||||
|
onClose={() => setCreateOpen(false)}
|
||||||
|
tradeOptions={tradeOptions}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<VendorFiltersDrawer
|
||||||
|
open={filtersOpen}
|
||||||
|
onClose={() => setFiltersOpen(false)}
|
||||||
|
facets={facets}
|
||||||
|
appliedFilters={appliedFilters}
|
||||||
|
onApply={handleApplyFilters}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<VendorDetailDrawer
|
||||||
|
open={Boolean(detailTarget)}
|
||||||
|
vendor={detailTarget}
|
||||||
|
mode={detailMode}
|
||||||
|
onClose={closeDetail}
|
||||||
|
onModeChange={setDetailMode}
|
||||||
|
tradeOptions={tradeOptions}
|
||||||
|
/>
|
||||||
|
|
||||||
<Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)}>
|
<Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)}>
|
||||||
<DialogTitle>Delete Vendor</DialogTitle>
|
<DialogTitle>Deactivate Vendor</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogContentText>
|
<DialogContentText>
|
||||||
Are you sure you want to delete "{deleteTarget?.companyName}"?
|
Deactivate "{deleteTarget?.companyName}"? Existing work-order and audit
|
||||||
|
history will be preserved.
|
||||||
</DialogContentText>
|
</DialogContentText>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
|
|
@ -227,7 +476,7 @@ export default function VendorsListPage() {
|
||||||
onClick={handleDeleteConfirm}
|
onClick={handleDeleteConfirm}
|
||||||
disabled={deleteVendor.isPending}
|
disabled={deleteVendor.isPending}
|
||||||
>
|
>
|
||||||
{deleteVendor.isPending ? "Deleting..." : "Delete"}
|
{deleteVendor.isPending ? "Deactivating..." : "Deactivate"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
|
||||||
29
src/domain/vendors/api/vendors-api.ts
vendored
29
src/domain/vendors/api/vendors-api.ts
vendored
|
|
@ -3,23 +3,45 @@ import { apiDelete, apiGet, apiPost, apiPut } from "@/api/api";
|
||||||
import { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response";
|
import { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response";
|
||||||
import {
|
import {
|
||||||
mapVendor,
|
mapVendor,
|
||||||
|
mapVendorFacets,
|
||||||
mapVendorListItems,
|
mapVendorListItems,
|
||||||
mapVendorPortalToken,
|
mapVendorPortalToken,
|
||||||
mapVendorToBackend,
|
mapVendorToBackend,
|
||||||
} from "@/domain/vendors/mappers/vendor-mapper";
|
} from "@/domain/vendors/mappers/vendor-mapper";
|
||||||
import type {
|
import type {
|
||||||
Vendor,
|
Vendor,
|
||||||
|
VendorFacets,
|
||||||
VendorPortalToken,
|
VendorPortalToken,
|
||||||
VendorsListParams,
|
VendorsListParams,
|
||||||
VendorsListResult,
|
VendorsListResult,
|
||||||
} from "@/domain/vendors/types/vendor";
|
} from "@/domain/vendors/types/vendor";
|
||||||
|
|
||||||
|
function pushRepeated(
|
||||||
|
searchParams: Record<string, string | number>,
|
||||||
|
key: string,
|
||||||
|
values: string[] | undefined,
|
||||||
|
): void {
|
||||||
|
if (!values || values.length === 0) return;
|
||||||
|
values.forEach((value, index) => {
|
||||||
|
if (!value) return;
|
||||||
|
searchParams[`${key}[${index}]`] = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function buildListParams(params: VendorsListParams): Record<string, string | number> {
|
function buildListParams(params: VendorsListParams): Record<string, string | number> {
|
||||||
return {
|
const searchParams: Record<string, string | number> = {
|
||||||
search: params.search ?? "",
|
search: params.search ?? "",
|
||||||
page: params.page ?? 1,
|
page: params.page ?? 1,
|
||||||
pageSize: params.pageSize ?? 12,
|
pageSize: params.pageSize ?? 12,
|
||||||
};
|
};
|
||||||
|
if (params.isActive !== undefined) {
|
||||||
|
searchParams.isActive = String(params.isActive);
|
||||||
|
}
|
||||||
|
pushRepeated(searchParams, "companies", params.companies);
|
||||||
|
pushRepeated(searchParams, "trades", params.trades);
|
||||||
|
pushRepeated(searchParams, "locations", params.locations);
|
||||||
|
pushRepeated(searchParams, "jobBuckets", params.jobBuckets);
|
||||||
|
return searchParams;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const vendorsApi = {
|
export const vendorsApi = {
|
||||||
|
|
@ -39,6 +61,11 @@ export const vendorsApi = {
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getFacets: async (): Promise<VendorFacets> => {
|
||||||
|
const data = await apiGet<unknown>(API_PATHS.vendor.facets);
|
||||||
|
return mapVendorFacets(handleApiResponse(data));
|
||||||
|
},
|
||||||
|
|
||||||
getById: async (id: string | number): Promise<Vendor> => {
|
getById: async (id: string | number): Promise<Vendor> => {
|
||||||
const data = await apiGet<unknown>(`${API_PATHS.rest.vendors}/${id}`);
|
const data = await apiGet<unknown>(`${API_PATHS.rest.vendors}/${id}`);
|
||||||
return mapVendor(handleApiResponse(data));
|
return mapVendor(handleApiResponse(data));
|
||||||
|
|
|
||||||
91
src/domain/vendors/mappers/vendor-mapper.ts
vendored
91
src/domain/vendors/mappers/vendor-mapper.ts
vendored
|
|
@ -1,4 +1,12 @@
|
||||||
import type { Vendor, VendorListItem, VendorPortalToken } from "@/domain/vendors/types/vendor";
|
import type {
|
||||||
|
Vendor,
|
||||||
|
VendorFacetCompany,
|
||||||
|
VendorFacetJobBucket,
|
||||||
|
VendorFacetLocation,
|
||||||
|
VendorFacets,
|
||||||
|
VendorListItem,
|
||||||
|
VendorPortalToken,
|
||||||
|
} from "@/domain/vendors/types/vendor";
|
||||||
|
|
||||||
function asRecord(raw: unknown): Record<string, unknown> {
|
function asRecord(raw: unknown): Record<string, unknown> {
|
||||||
return typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
|
return typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
|
||||||
|
|
@ -13,6 +21,18 @@ function readString(record: Record<string, unknown>, ...keys: string[]): string
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readNumber(record: Record<string, unknown>, ...keys: string[]): number {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = record[key];
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (Number.isFinite(parsed)) return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
function readId(record: Record<string, unknown>): string | number {
|
function readId(record: Record<string, unknown>): string | number {
|
||||||
const value = record.id ?? record.Id;
|
const value = record.id ?? record.Id;
|
||||||
if (typeof value === "string" || typeof value === "number") return value;
|
if (typeof value === "string" || typeof value === "number") return value;
|
||||||
|
|
@ -35,6 +55,12 @@ export function mapVendorListItem(raw: unknown): VendorListItem {
|
||||||
contactName: readString(item, "contactName", "ContactName"),
|
contactName: readString(item, "contactName", "ContactName"),
|
||||||
email: readString(item, "email", "Email"),
|
email: readString(item, "email", "Email"),
|
||||||
phone: readString(item, "phone", "Phone"),
|
phone: readString(item, "phone", "Phone"),
|
||||||
|
companyPhone: readString(item, "companyPhone", "CompanyPhone"),
|
||||||
|
googleMapsUrl: readString(item, "googleMapsUrl", "GoogleMapsUrl"),
|
||||||
|
notes: readString(item, "notes", "Notes"),
|
||||||
|
totalJobs: readNumber(item, "totalJobs", "TotalJobs"),
|
||||||
|
city: readString(item, "city", "City"),
|
||||||
|
state: readString(item, "state", "State"),
|
||||||
tradeSpecialties: readString(item, "tradeSpecialties", "TradeSpecialties"),
|
tradeSpecialties: readString(item, "tradeSpecialties", "TradeSpecialties"),
|
||||||
isActive: readBool(item, "isActive", "IsActive"),
|
isActive: readBool(item, "isActive", "IsActive"),
|
||||||
};
|
};
|
||||||
|
|
@ -52,6 +78,10 @@ export function mapVendor(raw: unknown): Vendor {
|
||||||
contactName: readString(item, "contactName", "ContactName"),
|
contactName: readString(item, "contactName", "ContactName"),
|
||||||
email: readString(item, "email", "Email"),
|
email: readString(item, "email", "Email"),
|
||||||
phone: readString(item, "phone", "Phone"),
|
phone: readString(item, "phone", "Phone"),
|
||||||
|
companyPhone: readString(item, "companyPhone", "CompanyPhone"),
|
||||||
|
googleMapsUrl: readString(item, "googleMapsUrl", "GoogleMapsUrl"),
|
||||||
|
notes: readString(item, "notes", "Notes"),
|
||||||
|
totalJobs: readNumber(item, "totalJobs", "TotalJobs"),
|
||||||
address: readString(item, "address", "Address"),
|
address: readString(item, "address", "Address"),
|
||||||
city: readString(item, "city", "City"),
|
city: readString(item, "city", "City"),
|
||||||
state: readString(item, "state", "State"),
|
state: readString(item, "state", "State"),
|
||||||
|
|
@ -68,6 +98,9 @@ export function mapVendorToBackend(vendor: unknown): Record<string, unknown> {
|
||||||
contactName: item.contactName ?? "",
|
contactName: item.contactName ?? "",
|
||||||
email: item.email ?? "",
|
email: item.email ?? "",
|
||||||
phone: item.phone ?? "",
|
phone: item.phone ?? "",
|
||||||
|
companyPhone: item.companyPhone ?? "",
|
||||||
|
googleMapsUrl: item.googleMapsUrl ?? "",
|
||||||
|
notes: item.notes ?? "",
|
||||||
address: item.address ?? "",
|
address: item.address ?? "",
|
||||||
city: item.city ?? "",
|
city: item.city ?? "",
|
||||||
state: item.state ?? "",
|
state: item.state ?? "",
|
||||||
|
|
@ -82,6 +115,62 @@ export function mapVendorToBackend(vendor: unknown): Record<string, unknown> {
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mapFacetCompany(raw: unknown): VendorFacetCompany {
|
||||||
|
const item = asRecord(raw);
|
||||||
|
return {
|
||||||
|
name: readString(item, "name", "Name"),
|
||||||
|
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"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapFacetLocation(raw: unknown): VendorFacetLocation {
|
||||||
|
const item = asRecord(raw);
|
||||||
|
const city = readString(item, "city", "City");
|
||||||
|
const state = readString(item, "state", "State");
|
||||||
|
const label = readString(item, "label", "Label");
|
||||||
|
return {
|
||||||
|
city,
|
||||||
|
state,
|
||||||
|
label: label || [city, state].filter(Boolean).join(", "),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapFacetJobBucket(raw: unknown): VendorFacetJobBucket {
|
||||||
|
const item = asRecord(raw);
|
||||||
|
return {
|
||||||
|
id: readString(item, "id", "Id"),
|
||||||
|
label: readString(item, "label", "Label"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapVendorFacets(raw: unknown): VendorFacets {
|
||||||
|
const item = asRecord(raw);
|
||||||
|
const companiesRaw = item.companies ?? item.Companies;
|
||||||
|
const tradesRaw = item.trades ?? item.Trades;
|
||||||
|
const locationsRaw = item.locations ?? item.Locations;
|
||||||
|
const jobBucketsRaw = item.jobBuckets ?? item.JobBuckets;
|
||||||
|
|
||||||
|
return {
|
||||||
|
companies: Array.isArray(companiesRaw) ? companiesRaw.map(mapFacetCompany) : [],
|
||||||
|
trades: Array.isArray(tradesRaw)
|
||||||
|
? tradesRaw
|
||||||
|
.map((entry) => {
|
||||||
|
if (typeof entry === "string") return entry;
|
||||||
|
return readString(asRecord(entry), "name", "Name", "value", "Value", "label", "Label");
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
: [],
|
||||||
|
locations: Array.isArray(locationsRaw) ? locationsRaw.map(mapFacetLocation) : [],
|
||||||
|
jobBuckets: Array.isArray(jobBucketsRaw) ? jobBucketsRaw.map(mapFacetJobBucket) : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function mapVendorPortalToken(raw: unknown): VendorPortalToken | null {
|
export function mapVendorPortalToken(raw: unknown): VendorPortalToken | null {
|
||||||
const item = asRecord(raw);
|
const item = asRecord(raw);
|
||||||
const portalUrl = readString(item, "portalUrl", "PortalUrl");
|
const portalUrl = readString(item, "portalUrl", "PortalUrl");
|
||||||
|
|
|
||||||
18
src/domain/vendors/schemas/vendor-schema.ts
vendored
18
src/domain/vendors/schemas/vendor-schema.ts
vendored
|
|
@ -1,10 +1,22 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const httpsUrl = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.refine(
|
||||||
|
(value) => value === "" || /^https:\/\/.+/i.test(value),
|
||||||
|
"Google Maps URL must use HTTPS",
|
||||||
|
);
|
||||||
|
|
||||||
export const vendorSchema = z.object({
|
export const vendorSchema = z.object({
|
||||||
companyName: z.string().min(1, "Company name is required"),
|
companyName: z.string().min(1, "Company is required"),
|
||||||
contactName: z.string().optional(),
|
contactName: z.string().min(1, "Technician name is required"),
|
||||||
email: z.string().min(1, "Email is required").email("Invalid email"),
|
email: z.union([z.string().email("Invalid email"), z.literal("")]).optional(),
|
||||||
phone: z.string().optional(),
|
phone: z.string().optional(),
|
||||||
|
companyPhone: z.string().optional(),
|
||||||
|
googleMapsUrl: httpsUrl.optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
totalJobs: z.number().optional(),
|
||||||
address: z.string().optional(),
|
address: z.string().optional(),
|
||||||
city: z.string().optional(),
|
city: z.string().optional(),
|
||||||
state: z.string().optional(),
|
state: z.string().optional(),
|
||||||
|
|
|
||||||
44
src/domain/vendors/types/vendor.ts
vendored
44
src/domain/vendors/types/vendor.ts
vendored
|
|
@ -4,6 +4,12 @@ export interface VendorListItem {
|
||||||
contactName: string;
|
contactName: string;
|
||||||
email: string;
|
email: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
|
companyPhone: string;
|
||||||
|
googleMapsUrl: string;
|
||||||
|
notes: string;
|
||||||
|
totalJobs: number;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
tradeSpecialties: string;
|
tradeSpecialties: string;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
}
|
}
|
||||||
|
|
@ -14,6 +20,10 @@ export interface Vendor {
|
||||||
contactName: string;
|
contactName: string;
|
||||||
email: string;
|
email: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
|
companyPhone: string;
|
||||||
|
googleMapsUrl: string;
|
||||||
|
notes: string;
|
||||||
|
totalJobs: number;
|
||||||
address: string;
|
address: string;
|
||||||
city: string;
|
city: string;
|
||||||
state: string;
|
state: string;
|
||||||
|
|
@ -24,8 +34,13 @@ export interface Vendor {
|
||||||
|
|
||||||
export interface VendorsListParams {
|
export interface VendorsListParams {
|
||||||
search?: string;
|
search?: string;
|
||||||
|
isActive?: boolean;
|
||||||
page?: number;
|
page?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
|
companies?: string[];
|
||||||
|
trades?: string[];
|
||||||
|
locations?: string[];
|
||||||
|
jobBuckets?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VendorsListResult {
|
export interface VendorsListResult {
|
||||||
|
|
@ -35,6 +50,35 @@ export interface VendorsListResult {
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VendorFacetCompany {
|
||||||
|
name: string;
|
||||||
|
companyPhone: string;
|
||||||
|
email: string;
|
||||||
|
address: string;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
zip: string;
|
||||||
|
googleMapsUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VendorFacetLocation {
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VendorFacetJobBucket {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VendorFacets {
|
||||||
|
companies: VendorFacetCompany[];
|
||||||
|
trades: string[];
|
||||||
|
locations: VendorFacetLocation[];
|
||||||
|
jobBuckets: VendorFacetJobBucket[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface VendorPortalToken {
|
export interface VendorPortalToken {
|
||||||
portalUrl: string;
|
portalUrl: string;
|
||||||
issuedAt: string;
|
issuedAt: string;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
|
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
|
||||||
import { useNavigate } from "react-router";
|
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import { vendorsApi } from "@/domain/vendors/api/vendors-api";
|
import { vendorsApi } from "@/domain/vendors/api/vendors-api";
|
||||||
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
||||||
|
|
@ -7,7 +6,6 @@ import type { Vendor } from "@/domain/vendors/types/vendor";
|
||||||
import { queryKeys } from "@/infra/query-key/query-key";
|
import { queryKeys } from "@/infra/query-key/query-key";
|
||||||
|
|
||||||
export function useCreateVendor(): UseMutationResult<Vendor, Error, VendorFormValues> {
|
export function useCreateVendor(): UseMutationResult<Vendor, Error, VendorFormValues> {
|
||||||
const navigate = useNavigate();
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|
@ -15,7 +13,6 @@ export function useCreateVendor(): UseMutationResult<Vendor, Error, VendorFormVa
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void queryClient.invalidateQueries({ queryKey: queryKeys.vendors.all });
|
void queryClient.invalidateQueries({ queryKey: queryKeys.vendors.all });
|
||||||
toast.success("Vendor created");
|
toast.success("Vendor created");
|
||||||
navigate("/vendors");
|
|
||||||
},
|
},
|
||||||
onError: (error: Error) => {
|
onError: (error: Error) => {
|
||||||
toast.error(error.message || "Failed to create vendor");
|
toast.error(error.message || "Failed to create vendor");
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,10 @@ export function useDeleteVendor(): UseMutationResult<void, Error, string | numbe
|
||||||
mutationFn: (id: string | number) => vendorsApi.delete(id),
|
mutationFn: (id: string | number) => vendorsApi.delete(id),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void queryClient.invalidateQueries({ queryKey: queryKeys.vendors.all });
|
void queryClient.invalidateQueries({ queryKey: queryKeys.vendors.all });
|
||||||
toast.success("Vendor deleted");
|
toast.success("Vendor deactivated");
|
||||||
},
|
},
|
||||||
onError: (error: Error) => {
|
onError: (error: Error) => {
|
||||||
toast.error(error.message || "Failed to delete vendor");
|
toast.error(error.message || "Failed to deactivate vendor");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
|
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
|
||||||
import { useNavigate } from "react-router";
|
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import { vendorsApi } from "@/domain/vendors/api/vendors-api";
|
import { vendorsApi } from "@/domain/vendors/api/vendors-api";
|
||||||
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
||||||
|
|
@ -12,7 +11,6 @@ interface UpdateVendorInput {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useUpdateVendor(): UseMutationResult<Vendor, Error, UpdateVendorInput> {
|
export function useUpdateVendor(): UseMutationResult<Vendor, Error, UpdateVendorInput> {
|
||||||
const navigate = useNavigate();
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|
@ -21,7 +19,6 @@ export function useUpdateVendor(): UseMutationResult<Vendor, Error, UpdateVendor
|
||||||
void queryClient.invalidateQueries({ queryKey: queryKeys.vendors.all });
|
void queryClient.invalidateQueries({ queryKey: queryKeys.vendors.all });
|
||||||
void queryClient.invalidateQueries({ queryKey: queryKeys.vendors.detail(variables.id) });
|
void queryClient.invalidateQueries({ queryKey: queryKeys.vendors.detail(variables.id) });
|
||||||
toast.success("Vendor updated");
|
toast.success("Vendor updated");
|
||||||
navigate("/vendors");
|
|
||||||
},
|
},
|
||||||
onError: (error: Error) => {
|
onError: (error: Error) => {
|
||||||
toast.error(error.message || "Failed to update vendor");
|
toast.error(error.message || "Failed to update vendor");
|
||||||
|
|
|
||||||
12
src/domain/vendors/use-cases/use-vendor-facets.ts
vendored
Normal file
12
src/domain/vendors/use-cases/use-vendor-facets.ts
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
|
||||||
|
import { vendorsApi } from "@/domain/vendors/api/vendors-api";
|
||||||
|
import type { VendorFacets } from "@/domain/vendors/types/vendor";
|
||||||
|
import { queryKeys } from "@/infra/query-key/query-key";
|
||||||
|
|
||||||
|
export function useVendorFacets(): UseQueryResult<VendorFacets, Error> {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.vendors.facets(),
|
||||||
|
queryFn: () => vendorsApi.getFacets(),
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -52,6 +52,7 @@ export const queryKeys = {
|
||||||
list: (params?: Record<string, unknown>) =>
|
list: (params?: Record<string, unknown>) =>
|
||||||
[...queryKeys.vendors.all, "list", params ?? {}] as const,
|
[...queryKeys.vendors.all, "list", params ?? {}] as const,
|
||||||
detail: (id: string | number) => [...queryKeys.vendors.all, "detail", id] as const,
|
detail: (id: string | number) => [...queryKeys.vendors.all, "detail", id] as const,
|
||||||
|
facets: () => [...queryKeys.vendors.all, "facets"] as const,
|
||||||
portalToken: (vendorId: string | number) =>
|
portalToken: (vendorId: string | number) =>
|
||||||
[...queryKeys.vendors.all, "portalToken", vendorId] as const,
|
[...queryKeys.vendors.all, "portalToken", vendorId] as const,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
68
src/test/app/(protected)/vendors/vendors-list.test.tsx
vendored
Normal file
68
src/test/app/(protected)/vendors/vendors-list.test.tsx
vendored
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
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 { renderWithProviders } from "@/test/test-utils";
|
||||||
|
|
||||||
|
const useVendorsList = vi.fn();
|
||||||
|
const mutate = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("@/domain/vendors/use-cases/use-vendors-list", () => ({
|
||||||
|
useVendorsList: (...args: unknown[]) => useVendorsList(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/domain/vendors/use-cases/use-delete-vendor", () => ({
|
||||||
|
useDeleteVendor: () => ({ mutate, isPending: false }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/hooks/use-debounce", () => ({
|
||||||
|
useDebounce: (value: string) => value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const activeVendor = {
|
||||||
|
id: 1,
|
||||||
|
companyName: "Gateway Plumbing",
|
||||||
|
contactName: "Adam Whyte",
|
||||||
|
email: "dispatch@gatewayplumbing.com",
|
||||||
|
phone: "(314) 555-0198",
|
||||||
|
city: "St. Louis",
|
||||||
|
state: "MO",
|
||||||
|
tradeSpecialties: "Backflow Preventers",
|
||||||
|
isActive: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const inactiveVendor = {
|
||||||
|
...activeVendor,
|
||||||
|
id: 2,
|
||||||
|
companyName: "Drain Cleaning Bros",
|
||||||
|
isActive: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function result(items: (typeof activeVendor)[], totalCount: number) {
|
||||||
|
return {
|
||||||
|
data: { items, totalCount, page: 1, pageSize: 12 },
|
||||||
|
isLoading: false,
|
||||||
|
isFetching: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("VendorsListPage", () => {
|
||||||
|
it("renders prototype-aligned status tabs and location, then hides deactivation for inactive rows", async () => {
|
||||||
|
useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
|
||||||
|
if (params.pageSize === 1) return result([], params.isActive ? 1 : 1);
|
||||||
|
return params.isActive ? result([activeVendor], 1) : result([inactiveVendor], 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProviders(<VendorsListPage />, { route: "/vendors", withAuth: false });
|
||||||
|
|
||||||
|
expect(screen.getByRole("heading", { name: "Vendors / Technicians" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("St. Louis, MO")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Deactivate vendor" })).toBeInTheDocument();
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("tab", { name: "Inactive (1)" }));
|
||||||
|
|
||||||
|
expect(screen.getByText("Drain Cleaning Bros")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: "Deactivate vendor" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
135
src/test/domain/vendors/api/vendors-api.test.ts
vendored
Normal file
135
src/test/domain/vendors/api/vendors-api.test.ts
vendored
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
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),
|
||||||
|
apiDelete: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { vendorsApi } from "@/domain/vendors/api/vendors-api";
|
||||||
|
|
||||||
|
describe("vendorsApi.getList", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
apiGet.mockReset();
|
||||||
|
apiPost.mockReset();
|
||||||
|
apiPut.mockReset();
|
||||||
|
apiGet.mockResolvedValue({
|
||||||
|
Data: [
|
||||||
|
{
|
||||||
|
Id: 7,
|
||||||
|
CompanyName: "Gateway Plumbing",
|
||||||
|
ContactName: "Adam Whyte",
|
||||||
|
City: "St. Louis",
|
||||||
|
State: "MO",
|
||||||
|
IsActive: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
TotalCount: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forwards the status filter and maps list location fields", async () => {
|
||||||
|
const result = await vendorsApi.getList({ search: "gateway", isActive: false, page: 1 });
|
||||||
|
|
||||||
|
expect(apiGet).toHaveBeenCalledWith(API_PATHS.vendor.getList, {
|
||||||
|
searchParams: { search: "gateway", isActive: "false", page: 1, pageSize: 12 },
|
||||||
|
});
|
||||||
|
expect(result.items[0]).toMatchObject({ city: "St. Louis", state: "MO", isActive: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forwards all selected facets as repeated indexed query parameters", async () => {
|
||||||
|
await vendorsApi.getList({
|
||||||
|
companies: ["Gateway Plumbing", "Metro HVAC"],
|
||||||
|
trades: ["Plumbing"],
|
||||||
|
locations: ["St. Louis, MO"],
|
||||||
|
jobBuckets: ["50-99"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(apiGet).toHaveBeenCalledWith(API_PATHS.vendor.getList, {
|
||||||
|
searchParams: {
|
||||||
|
search: "",
|
||||||
|
page: 1,
|
||||||
|
pageSize: 12,
|
||||||
|
"companies[0]": "Gateway Plumbing",
|
||||||
|
"companies[1]": "Metro HVAC",
|
||||||
|
"trades[0]": "Plumbing",
|
||||||
|
"locations[0]": "St. Louis, MO",
|
||||||
|
"jobBuckets[0]": "50-99",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps company profiles and labels returned by the facets endpoint", async () => {
|
||||||
|
apiGet.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
companies: [
|
||||||
|
{
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
trades: ["Plumbing"],
|
||||||
|
locations: [{ city: "St. Louis", state: "MO" }],
|
||||||
|
jobBuckets: [{ id: "50-99", label: "50–99" }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const facets = await vendorsApi.getFacets();
|
||||||
|
|
||||||
|
expect(apiGet).toHaveBeenCalledWith(API_PATHS.vendor.facets);
|
||||||
|
expect(facets.companies[0]).toMatchObject({
|
||||||
|
name: "Gateway Plumbing",
|
||||||
|
companyPhone: "314-555-0100",
|
||||||
|
googleMapsUrl: "https://maps.google.com/gateway",
|
||||||
|
});
|
||||||
|
expect(facets.locations[0]?.label).toBe("St. Louis, MO");
|
||||||
|
expect(facets.jobBuckets[0]).toEqual({ id: "50-99", label: "50–99" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends all editable prototype fields when creating and updating", async () => {
|
||||||
|
const values = {
|
||||||
|
companyName: "Gateway Plumbing",
|
||||||
|
contactName: "Adam Whyte",
|
||||||
|
phone: "314-555-0198",
|
||||||
|
companyPhone: "314-555-0100",
|
||||||
|
email: "dispatch@gateway.test",
|
||||||
|
address: "1 Market St",
|
||||||
|
city: "St. Louis",
|
||||||
|
state: "MO",
|
||||||
|
zip: "63101",
|
||||||
|
tradeSpecialties: "Plumbing, HVAC",
|
||||||
|
googleMapsUrl: "https://maps.google.com/gateway",
|
||||||
|
notes: "Preferred after-hours vendor",
|
||||||
|
totalJobs: 72,
|
||||||
|
isActive: true,
|
||||||
|
};
|
||||||
|
apiPost.mockResolvedValueOnce({ data: values });
|
||||||
|
apiPut.mockResolvedValueOnce({ data: { ...values, isActive: false } });
|
||||||
|
|
||||||
|
await vendorsApi.create(values);
|
||||||
|
await vendorsApi.update(7, { ...values, isActive: false });
|
||||||
|
|
||||||
|
expect(apiPost).toHaveBeenCalledWith(
|
||||||
|
API_PATHS.rest.vendors,
|
||||||
|
expect.objectContaining({ companyName: "Gateway Plumbing", notes: values.notes }),
|
||||||
|
);
|
||||||
|
expect(apiPut).toHaveBeenCalledWith(
|
||||||
|
`${API_PATHS.rest.vendors}/7`,
|
||||||
|
expect.objectContaining({ companyName: "Gateway Plumbing", isActive: false }),
|
||||||
|
);
|
||||||
|
expect(apiPost.mock.calls[0]?.[1]).not.toHaveProperty("totalJobs");
|
||||||
|
expect(apiPut.mock.calls[0]?.[1]).not.toHaveProperty("totalJobs");
|
||||||
|
});
|
||||||
|
});
|
||||||
36
src/test/domain/vendors/schemas/vendor-schema.test.ts
vendored
Normal file
36
src/test/domain/vendors/schemas/vendor-schema.test.ts
vendored
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { vendorSchema } from "@/domain/vendors/schemas/vendor-schema";
|
||||||
|
|
||||||
|
const validVendor = {
|
||||||
|
companyName: "Gateway Plumbing",
|
||||||
|
contactName: "Adam Whyte",
|
||||||
|
email: "dispatch@gateway.test",
|
||||||
|
googleMapsUrl: "https://maps.google.com/gateway",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("vendorSchema", () => {
|
||||||
|
it("requires both the company and technician identity", () => {
|
||||||
|
const result = vendorSchema.safeParse({ ...validVendor, companyName: "", contactName: "" });
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
if (!result.success) {
|
||||||
|
expect(result.error.flatten().fieldErrors.companyName).toContain("Company is required");
|
||||||
|
expect(result.error.flatten().fieldErrors.contactName).toContain(
|
||||||
|
"Technician name is required",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts an empty optional email and HTTPS map URL", () => {
|
||||||
|
expect(vendorSchema.safeParse({ ...validVendor, email: "" }).success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects malformed email and non-HTTPS map links", () => {
|
||||||
|
expect(
|
||||||
|
vendorSchema.safeParse({
|
||||||
|
...validVendor,
|
||||||
|
email: "not-an-email",
|
||||||
|
googleMapsUrl: "http://maps.google.com/gateway",
|
||||||
|
}).success,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue